post-me
Version:
Use web Workers and other Windows through a simple Promise API
628 lines (618 loc) • 20.2 kB
JavaScript
const MARKER = '@post-me';
function createUniqueIdFn() {
let __id = 0;
return function () {
const id = __id;
__id += 1;
return id;
};
}
/**
* A concrete implementation of the {@link Emitter} interface
*
* @public
*/
class ConcreteEmitter {
constructor() {
this._listeners = {};
}
/** {@inheritDoc Emitter.addEventListener} */
addEventListener(eventName, listener) {
let listeners = this._listeners[eventName];
if (!listeners) {
listeners = new Set();
this._listeners[eventName] = listeners;
}
listeners.add(listener);
}
/** {@inheritDoc Emitter.removeEventListener} */
removeEventListener(eventName, listener) {
let listeners = this._listeners[eventName];
if (!listeners) {
return;
}
listeners.delete(listener);
}
/** {@inheritDoc Emitter.once} */
once(eventName) {
return new Promise((resolve) => {
const listener = (data) => {
this.removeEventListener(eventName, listener);
resolve(data);
};
this.addEventListener(eventName, listener);
});
}
/** @internal */
emit(eventName, data) {
let listeners = this._listeners[eventName];
if (!listeners) {
return;
}
listeners.forEach((listener) => {
listener(data);
});
}
/** @internal */
removeAllListeners() {
Object.values(this._listeners).forEach((listeners) => {
if (listeners) {
listeners.clear();
}
});
}
}
var MessageType;
(function (MessageType) {
MessageType["HandshakeRequest"] = "handshake-request";
MessageType["HandshakeResponse"] = "handshake-response";
MessageType["Call"] = "call";
MessageType["Response"] = "response";
MessageType["Error"] = "error";
MessageType["Event"] = "event";
MessageType["Callback"] = "callback";
})(MessageType || (MessageType = {}));
// Message Creators
function createHandshakeRequestMessage(sessionId) {
return {
type: MARKER,
action: MessageType.HandshakeRequest,
sessionId,
};
}
function createHandshakeResponseMessage(sessionId) {
return {
type: MARKER,
action: MessageType.HandshakeResponse,
sessionId,
};
}
function createCallMessage(sessionId, requestId, methodName, args) {
return {
type: MARKER,
action: MessageType.Call,
sessionId,
requestId,
methodName,
args,
};
}
function createResponsMessage(sessionId, requestId, result, error) {
const message = {
type: MARKER,
action: MessageType.Response,
sessionId,
requestId,
};
if (result !== undefined) {
message.result = result;
}
if (error !== undefined) {
message.error = error;
}
return message;
}
function createCallbackMessage(sessionId, requestId, callbackId, args) {
return {
type: MARKER,
action: MessageType.Callback,
sessionId,
requestId,
callbackId,
args,
};
}
function createEventMessage(sessionId, eventName, payload) {
return {
type: MARKER,
action: MessageType.Event,
sessionId,
eventName,
payload,
};
}
// Type Guards
function isMessage(m) {
return m && m.type === MARKER;
}
function isHandshakeRequestMessage(m) {
return isMessage(m) && m.action === MessageType.HandshakeRequest;
}
function isHandshakeResponseMessage(m) {
return isMessage(m) && m.action === MessageType.HandshakeResponse;
}
function isCallMessage(m) {
return isMessage(m) && m.action === MessageType.Call;
}
function isResponseMessage(m) {
return isMessage(m) && m.action === MessageType.Response;
}
function isCallbackMessage(m) {
return isMessage(m) && m.action === MessageType.Callback;
}
function isEventMessage(m) {
return isMessage(m) && m.action === MessageType.Event;
}
function makeCallbackEvent(requestId) {
return `callback_${requestId}`;
}
function makeResponseEvent(requestId) {
return `response_${requestId}`;
}
class Dispatcher extends ConcreteEmitter {
constructor(messenger, sessionId) {
super();
this.uniqueId = createUniqueIdFn();
this.messenger = messenger;
this.sessionId = sessionId;
this.removeMessengerListener = this.messenger.addMessageListener(this.messengerListener.bind(this));
}
messengerListener(event) {
const { data } = event;
if (!isMessage(data)) {
return;
}
if (this.sessionId !== data.sessionId) {
return;
}
if (isCallMessage(data)) {
this.emit(MessageType.Call, data);
}
else if (isResponseMessage(data)) {
this.emit(makeResponseEvent(data.requestId), data);
}
else if (isEventMessage(data)) {
this.emit(MessageType.Event, data);
}
else if (isCallbackMessage(data)) {
this.emit(makeCallbackEvent(data.requestId), data);
}
}
callOnRemote(methodName, args, transfer) {
const requestId = this.uniqueId();
const callbackEvent = makeCallbackEvent(requestId);
const responseEvent = makeResponseEvent(requestId);
const message = createCallMessage(this.sessionId, requestId, methodName, args);
this.messenger.postMessage(message, transfer);
return { callbackEvent, responseEvent };
}
respondToRemote(requestId, value, error, transfer) {
if (error instanceof Error) {
error = {
name: error.name,
message: error.message,
};
}
const message = createResponsMessage(this.sessionId, requestId, value, error);
this.messenger.postMessage(message, transfer);
}
callbackToRemote(requestId, callbackId, args) {
const message = createCallbackMessage(this.sessionId, requestId, callbackId, args);
this.messenger.postMessage(message);
}
emitToRemote(eventName, payload, transfer) {
const message = createEventMessage(this.sessionId, eventName, payload);
this.messenger.postMessage(message, transfer);
}
close() {
this.removeMessengerListener();
this.removeAllListeners();
}
}
class ParentHandshakeDispatcher extends ConcreteEmitter {
constructor(messenger, sessionId) {
super();
this.messenger = messenger;
this.sessionId = sessionId;
this.removeMessengerListener = this.messenger.addMessageListener(this.messengerListener.bind(this));
}
messengerListener(event) {
const { data } = event;
if (!isMessage(data)) {
return;
}
if (this.sessionId !== data.sessionId) {
return;
}
if (isHandshakeResponseMessage(data)) {
this.emit(data.sessionId, data);
}
}
initiateHandshake() {
const message = createHandshakeRequestMessage(this.sessionId);
this.messenger.postMessage(message);
return this.sessionId;
}
close() {
this.removeMessengerListener();
this.removeAllListeners();
}
}
class ChildHandshakeDispatcher extends ConcreteEmitter {
constructor(messenger) {
super();
this.messenger = messenger;
this.removeMessengerListener = this.messenger.addMessageListener(this.messengerListener.bind(this));
}
messengerListener(event) {
const { data } = event;
if (isHandshakeRequestMessage(data)) {
this.emit(MessageType.HandshakeRequest, data);
}
}
acceptHandshake(sessionId) {
const message = createHandshakeResponseMessage(sessionId);
this.messenger.postMessage(message);
}
close() {
this.removeMessengerListener();
this.removeAllListeners();
}
}
var ProxyType;
(function (ProxyType) {
ProxyType["Callback"] = "callback";
})(ProxyType || (ProxyType = {}));
function createCallbackProxy(callbackId) {
return {
type: MARKER,
proxy: ProxyType.Callback,
callbackId,
};
}
function isCallbackProxy(p) {
return p && p.type === MARKER && p.proxy === ProxyType.Callback;
}
class ConcreteRemoteHandle extends ConcreteEmitter {
constructor(dispatcher) {
super();
this._dispatcher = dispatcher;
this._callTransfer = {};
this._dispatcher.addEventListener(MessageType.Event, this._handleEvent.bind(this));
}
close() {
this.removeAllListeners();
}
setCallTransfer(methodName, transfer) {
this._callTransfer[methodName] = transfer;
}
call(methodName, ...args) {
return this.customCall(methodName, args);
}
customCall(methodName, args, options = {}) {
return new Promise((resolve, reject) => {
const sanitizedArgs = [];
const callbacks = [];
let callbackId = 0;
args.forEach((arg) => {
if (typeof arg === 'function') {
callbacks.push(arg);
sanitizedArgs.push(createCallbackProxy(callbackId));
callbackId += 1;
}
else {
sanitizedArgs.push(arg);
}
});
const hasCallbacks = callbacks.length > 0;
let callbackListener = undefined;
if (hasCallbacks) {
callbackListener = (data) => {
const { callbackId, args } = data;
callbacks[callbackId](...args);
};
}
let transfer = options.transfer;
if (transfer === undefined && this._callTransfer[methodName]) {
transfer = this._callTransfer[methodName](...sanitizedArgs);
}
const { callbackEvent, responseEvent } = this._dispatcher.callOnRemote(methodName, sanitizedArgs, transfer);
if (hasCallbacks) {
this._dispatcher.addEventListener(callbackEvent, callbackListener);
}
this._dispatcher.once(responseEvent).then((response) => {
if (callbackListener) {
this._dispatcher.removeEventListener(callbackEvent, callbackListener);
}
const { result, error } = response;
if (error !== undefined) {
reject(error);
}
else {
resolve(result);
}
});
});
}
_handleEvent(data) {
const { eventName, payload } = data;
this.emit(eventName, payload);
}
}
class ConcreteLocalHandle {
constructor(dispatcher, localMethods) {
this._dispatcher = dispatcher;
this._methods = localMethods;
this._returnTransfer = {};
this._emitTransfer = {};
this._dispatcher.addEventListener(MessageType.Call, this._handleCall.bind(this));
}
emit(eventName, payload, options = {}) {
let transfer = options.transfer;
if (transfer === undefined && this._emitTransfer[eventName]) {
transfer = this._emitTransfer[eventName](payload);
}
this._dispatcher.emitToRemote(eventName, payload, transfer);
}
setMethods(methods) {
this._methods = methods;
}
setMethod(methodName, method) {
this._methods[methodName] = method;
}
setReturnTransfer(methodName, transfer) {
this._returnTransfer[methodName] = transfer;
}
setEmitTransfer(eventName, transfer) {
this._emitTransfer[eventName] = transfer;
}
_handleCall(data) {
const { requestId, methodName, args } = data;
const callMethod = new Promise((resolve, reject) => {
const method = this._methods[methodName];
if (typeof method !== 'function') {
reject(new Error(`The method "${methodName}" has not been implemented.`));
return;
}
const desanitizedArgs = args.map((arg) => {
if (isCallbackProxy(arg)) {
const { callbackId } = arg;
return (...args) => {
this._dispatcher.callbackToRemote(requestId, callbackId, args);
};
}
else {
return arg;
}
});
Promise.resolve(this._methods[methodName](...desanitizedArgs))
.then(resolve)
.catch(reject);
});
callMethod
.then((result) => {
let transfer;
if (this._returnTransfer[methodName]) {
transfer = this._returnTransfer[methodName](result);
}
this._dispatcher.respondToRemote(requestId, result, undefined, transfer);
})
.catch((error) => {
this._dispatcher.respondToRemote(requestId, undefined, error);
});
}
}
class ConcreteConnection {
constructor(dispatcher, localMethods) {
this._dispatcher = dispatcher;
this._localHandle = new ConcreteLocalHandle(dispatcher, localMethods);
this._remoteHandle = new ConcreteRemoteHandle(dispatcher);
}
close() {
this._dispatcher.close();
this.remoteHandle().close();
}
localHandle() {
return this._localHandle;
}
remoteHandle() {
return this._remoteHandle;
}
}
const uniqueSessionId = createUniqueIdFn();
const runUntil = (worker, condition, unfulfilled, maxAttempts, attemptInterval) => {
let attempt = 0;
const fn = () => {
if (!condition() && (attempt < maxAttempts || maxAttempts < 1)) {
worker();
attempt += 1;
setTimeout(fn, attemptInterval);
}
else if (!condition() && attempt >= maxAttempts && maxAttempts >= 1) {
unfulfilled();
}
};
fn();
};
/**
* Initiate the handshake from the Parent side
*
* @param messenger - The Messenger used to send and receive messages from the other end
* @param localMethods - The methods that will be exposed to the other end
* @param maxAttempts - The maximum number of handshake attempts
* @param attemptsInterval - The interval between handshake attempts
* @returns A Promise to an active {@link Connection} to the other end
*
* @public
*/
function ParentHandshake(messenger, localMethods = {}, maxAttempts = 5, attemptsInterval = 100) {
const thisSessionId = uniqueSessionId();
let connected = false;
return new Promise((resolve, reject) => {
const handshakeDispatcher = new ParentHandshakeDispatcher(messenger, thisSessionId);
handshakeDispatcher.once(thisSessionId).then((response) => {
connected = true;
handshakeDispatcher.close();
const { sessionId } = response;
const dispatcher = new Dispatcher(messenger, sessionId);
const connection = new ConcreteConnection(dispatcher, localMethods);
resolve(connection);
});
runUntil(() => handshakeDispatcher.initiateHandshake(), () => connected, () => reject(new Error(`Handshake failed, reached maximum number of attempts`)), maxAttempts, attemptsInterval);
});
}
/**
* Initiate the handshake from the Child side
*
* @param messenger - The Messenger used to send and receive messages from the other end
* @param localMethods - The methods that will be exposed to the other end
* @returns A Promise to an active {@link Connection} to the other end
*
* @public
*/
function ChildHandshake(messenger, localMethods = {}) {
return new Promise((resolve, reject) => {
const handshakeDispatcher = new ChildHandshakeDispatcher(messenger);
handshakeDispatcher.once(MessageType.HandshakeRequest).then((response) => {
const { sessionId } = response;
handshakeDispatcher.acceptHandshake(sessionId);
handshakeDispatcher.close();
const dispatcher = new Dispatcher(messenger, sessionId);
const connection = new ConcreteConnection(dispatcher, localMethods);
resolve(connection);
});
});
}
const acceptableMessageEvent = (event, remoteWindow, acceptedOrigin) => {
const { source, origin } = event;
if (source !== remoteWindow) {
return false;
}
if (origin !== acceptedOrigin && acceptedOrigin !== '*') {
return false;
}
return true;
};
/**
* A concrete implementation of {@link Messenger} used to communicate with another Window.
*
* @public
*
*/
class WindowMessenger {
constructor({ localWindow, remoteWindow, remoteOrigin, }) {
localWindow = localWindow || window;
this.postMessage = (message, transfer) => {
remoteWindow.postMessage(message, remoteOrigin, transfer);
};
this.addMessageListener = (listener) => {
const outerListener = (event) => {
if (acceptableMessageEvent(event, remoteWindow, remoteOrigin)) {
listener(event);
}
};
localWindow.addEventListener('message', outerListener);
const removeListener = () => {
localWindow.removeEventListener('message', outerListener);
};
return removeListener;
};
}
}
/** @public */
class BareMessenger {
constructor(postable) {
this.postMessage = (message, transfer = []) => {
postable.postMessage(message, transfer);
};
this.addMessageListener = (listener) => {
const outerListener = (event) => {
listener(event);
};
postable.addEventListener('message', outerListener);
const removeListener = () => {
postable.removeEventListener('message', outerListener);
};
return removeListener;
};
}
}
/**
* A concrete implementation of {@link Messenger} used to communicate with a Worker.
*
* Takes a {@link Postable} representing the `Worker` (when calling from
* the parent context) or the `self` `DedicatedWorkerGlobalScope` object
* (when calling from the child context).
*
* @public
*
*/
class WorkerMessenger extends BareMessenger {
constructor({ worker }) {
super(worker);
}
}
/**
* A concrete implementation of {@link Messenger} used to communicate with a MessagePort.
*
* @public
*
*/
class PortMessenger extends BareMessenger {
constructor({ port }) {
port.start();
super(port);
}
}
/**
* Create a logger function with a specific namespace
*
* @param namespace - The namespace will be prepended to all the arguments passed to the logger function
* @param log - The underlying logger (`console.log` by default)
*
* @public
*
*/
function debug(namespace, log) {
log = log || console.debug || console.log || (() => { });
return (...data) => {
log(namespace, ...data);
};
}
/**
* Decorate a {@link Messenger} so that it will log any message exchanged
* @param messenger - The Messenger that will be decorated
* @param log - The logger function that will receive each message
* @returns A decorated Messenger
*
* @public
*
*/
function DebugMessenger(messenger, log) {
log = log || debug('post-me');
const debugListener = function (event) {
const { data } = event;
log('⬅️ received message', data);
};
messenger.addMessageListener(debugListener);
return {
postMessage: function (message, transfer) {
log('➡️ sending message', message);
messenger.postMessage(message, transfer);
},
addMessageListener: function (listener) {
return messenger.addMessageListener(listener);
},
};
}
export { BareMessenger, ChildHandshake, ConcreteEmitter, DebugMessenger, ParentHandshake, PortMessenger, WindowMessenger, WorkerMessenger, debug };