@readium/navigator-html-injectables
Version:
An embeddable solution for connecting frames of HTML publications with a Readium Navigator
168 lines (151 loc) • 6.4 kB
text/typescript
import { ReadiumWindow } from "../helpers/dom";
import { CommsEventKey, CommsCommandKey } from "./keys";
import { mid } from "./mid";
export const COMMS_VERSION = 1;
export interface CommsMessage {
_readium: number; // Sanity/version-checking field
_channel: string; // Channel ID
id?: string; // Optional (but recommended!) unique identifier
strict?: boolean; // Whether or not the event *must* be handled by the receiver
key: CommsEventKey | CommsCommandKey; // The "key" for identification to the listener
data: unknown; // The data to be sent to the module
}
export interface Registrant {
module: string;
cb: CommsCallback;
}
export type CommsAck = (ok: boolean) => void;
export type CommsCallback = (data: unknown, ack: CommsAck) => void; // TODO: maybe more than void?
/**
* Comms is basically a wrapper around window.postMessage that
* adds structure to the messages and lets modules register callbacks.
*/
export class Comms {
private readonly wnd: ReadiumWindow;
private destination: ReadiumWindow | null = null;
private registrar = new Map<CommsCommandKey, Registrant[]>();
private origin: string = "";
private channelId: string = "";
constructor(wnd: ReadiumWindow) {
this.wnd = wnd;
wnd.addEventListener("message", this.receiver);
}
private receive(event: MessageEvent) {
if(event.source === null) throw Error("Event source is null");
if(typeof event.data !== "object") return;
const data = event.data as CommsMessage; // Cast it as a CommsMessage
if(!("_readium" in data) || !data._readium || data._readium <= 0) return; // Not for us
if(data.key === "_ping") {
// The "ping" gives us a destination we bind to for posting events
if(!this.destination) {
this.destination = event.source as Window as ReadiumWindow;
this.origin = event.origin;
this.channelId = data._channel;
// Make sure we're communicating with a host on the same comms version
if(data._readium !== COMMS_VERSION) {
if(data._readium > COMMS_VERSION)
this.send("error", `received comms version ${data._readium} higher than ${COMMS_VERSION}`);
else
this.send("error", `received comms version ${data._readium} lower than ${COMMS_VERSION}`);
this.destination = null;
this.origin = "";
this.channelId = "";
return;
}
this.send("_pong", undefined);
this.preLog.forEach(d => this.send("log", d));
this.preLog = [];
}
return;
} else if(this.channelId) {
// Enforce matching channel ID and origin
if(
data._channel !== this.channelId ||
event.origin !== this.origin
) return;
} else {
// Ignore any messages beside _ping if not initialized
return;
}
this.handle(data);
}
private receiver = this.receive.bind(this);
private handle(data: CommsMessage) {
const listeners = this.registrar.get(data.key as CommsCommandKey);
if(!listeners || listeners.length === 0) {
if(data.strict) this.send("_unhandled", data); // Let the sender know the data was not handled by any listener
return;
}
listeners.forEach(l => l.cb(data.data, (ok: boolean) => {
this.send("_ack", ok, data.id); // Acknowledge handling of the event
}));
}
public register(key: CommsCommandKey | CommsCommandKey[], module: string, callback: CommsCallback) {
if(!Array.isArray(key)) key = [key];
key.forEach(k => {
const listeners = this.registrar.get(k);
if(listeners && listeners.length >= 0) {
const existing = listeners.find(l => l.module === module);
if(existing) throw new Error(`Trying to register another callback for combination of event ${k} and module ${module}`);
listeners.push({
cb: callback,
module
})
this.registrar.set(k, listeners);
} else
this.registrar.set(k, [{
cb: callback,
module
}]);
})
}
public unregister(key: CommsCommandKey | CommsCommandKey[], module: string) {
if(!Array.isArray(key)) key = [key];
key.forEach(k => {
const listeners = this.registrar.get(k);
if(!listeners || listeners.length === 0) return;
listeners.splice(listeners.findIndex(l => l.module === module), 1);
});
}
public unregisterAll(module: string) {
this.registrar.forEach((v, k) => this.registrar.set(k, v.filter(r => r.module !== module)));
}
// Convenience function for logging data
private preLog: any[] = [];
public log(...data: any[]) {
if(!this.destination) this.preLog.push(data);
else this.send("log", data);
}
public get ready() {
return !!this.destination;
}
public destroy() {
this.destination = null;
this.channelId = "";
this.preLog = [];
this.registrar.clear();
this.wnd.removeEventListener("message", this.receiver);
}
public send(key: CommsEventKey, data: unknown, id: unknown = undefined, transfer: Transferable[] = []) {
if(!this.destination) throw Error("Attempted to send comms message before destination has been initialized");
const msg = {
_readium: COMMS_VERSION,
_channel: this.channelId,
id: id ?? mid(),
// scrict,
key,
data
} as CommsMessage;
try {
this.destination.postMessage(msg, {
targetOrigin: this.origin,
transfer
});
} catch (error) {
// Fallback for when browser doesn't support WindowPostMessageOptions
// For example, older Safari versions
if(transfer.length > 0) throw error;
this.destination.postMessage(msg, this.origin, transfer);
}
}
}