(function() { function randInt(bits) { if (bits < 1 || bits > 53) { throw new TypeError(); } else { if (bits >= 1 && bits <= 30) { return 0 | ((1 << bits) * Math.random()); } else { var high = (0 | ((1 << (bits - 30)) * Math.random())) * (1 << 30); var low = 0 | ((1 << 30) * Math.random()); return high + low; } } } function toHex(x, length) { var rv = x.toString(16); while (rv.length < length) { rv = "0" + rv; } return rv; } function createUuid() { return [toHex(randInt(32), 8), toHex(randInt(16), 4), toHex(0x4000 | randInt(12), 4), toHex(0x8000 | randInt(14), 4), toHex(randInt(48), 12)].join("-"); } /** * Cache of WebSocket instances per channel * * For reading there can only be one channel with each UUID, so we * just have a simple map of {uuid: WebSocket}. The socket can be * closed when the channel is closed. * * For writing there can be many channels for each uuid. Those can * share a websocket (within a specific global), so we have a map * of {uuid: [WebSocket, count]}. Count is incremented when a * channel is opened with a given uuid, and decremented when its * closed. When the count reaches zero we can close the underlying * socket. */ class SocketCache { constructor() { this.readSockets = new Map(); this.writeSockets = new Map(); }; async getOrCreate(type, uuid, onmessage=null) { function createSocket() { let protocol = self.isSecureContext ? "wss" : "ws"; let port = self.isSecureContext? "{{ports[wss][0]}}" : "{{ports[ws][0]}}"; let url = `${protocol}://{{host}}:${port}/msg_channel?uuid=${uuid}&direction=${type}`; let socket = new WebSocket(url); if (onmessage !== null) { socket.onmessage = onmessage; }; return new Promise(resolve => socket.addEventListener("open", () => resolve(socket))); } let socket; if (type === "read") { if (this.readSockets.has(uuid)) { throw new Error("Can't create multiple read sockets with same UUID"); } socket = await createSocket(); // If the socket is closed by the server, ensure it's removed from the cache socket.addEventListener("close", () => this.readSockets.delete(uuid)); this.readSockets.set(uuid, socket); } else if (type === "write") { let count; if (onmessage !== null) { throw new Error("Can't set message handler for write sockets"); } if (this.writeSockets.has(uuid)) { [socket, count] = this.writeSockets.get(uuid); } else { socket = await createSocket(); count = 0; } count += 1; // If the socket is closed by the server, ensure it's removed from the cache socket.addEventListener("close", () => this.writeSockets.delete(uuid)); this.writeSockets.set(uuid, [socket, count]); } else { throw new Error(`Unknown type ${type}`); } return socket; }; async close(type, uuid) { let target = type === "read" ? this.readSockets : this.writeSockets; const data = target.get(uuid); if (!data) { return; } let count, socket; if (type == "read") { socket = data; count = 0; } else if (type === "write") { [socket, count] = data; count -= 1; if (count > 0) { target.set(uuid, [socket, count]); } }; if (count <= 0 && socket) { target.delete(uuid); socket.close(1000); await new Promise(resolve => socket.addEventListener("close", resolve)); } }; async closeAll() { let sockets = []; this.readSockets.forEach(value => sockets.push(value)); this.writeSockets.forEach(value => sockets.push(value[0])); let closePromises = sockets.map(socket => new Promise(resolve => socket.addEventListener("close", resolve))); sockets.forEach(socket => socket.close(1000)); this.readSockets.clear(); this.writeSockets.clear(); await Promise.all(closePromises); } } const socketCache = new SocketCache(); /** * Abstract base class for objects that allow sending / receiving * messages over a channel. */ class Channel { type = null; constructor(uuid) { /** UUID for the channel */ this.uuid = uuid; this.socket = null; this.eventListeners = { connect: new Set(), close: new Set() }; } hasConnection() { return this.socket !== null && this.socket.readyState <= WebSocket.OPEN; } /** * Connect to the channel. * * @param {Function} onmessage - Event handler function for * the underlying websocket message. */ async connect(onmessage) { if (this.hasConnection()) { return; } this.socket = await socketCache.getOrCreate(this.type, this.uuid, onmessage); this._dispatch("connect"); } /** * Close the channel and underlying websocket connection */ async close() { this.socket = null; await socketCache.close(this.type, this.uuid); this._dispatch("close"); } /** * Add an event callback function. Supported message types are * "connect", "close", and "message" (for ``RecvChannel``). * * @param {string} type - Message type. * @param {Function} fn - Callback function. This is called * with an event-like object, with ``type`` and ``data`` * properties. */ addEventListener(type, fn) { if (typeof type !== "string") { throw new TypeError(`Expected string, got ${typeof type}`); } if (typeof fn !== "function") { throw new TypeError(`Expected function, got ${typeof fn}`); } if (!this.eventListeners.hasOwnProperty(type)) { throw new Error(`Unrecognised event type ${type}`); } this.eventListeners[type].add(fn); }; /** * Remove an event callback function. * * @param {string} type - Event type. * @param {Function} fn - Callback function to remove. */ removeEventListener(type, fn) { if (!typeof type === "string") { throw new TypeError(`Expected string, got ${typeof type}`); } if (typeof fn !== "function") { throw new TypeError(`Expected function, got ${typeof fn}`); } let listeners = this.eventListeners[type]; if (listeners) { listeners.delete(fn); } }; _dispatch(type, data) { let listeners = this.eventListeners[type]; if (listeners) { // If any listener throws we end up not calling the other // listeners. This hopefully makes debugging easier, but // is different to DOM event listeners. listeners.forEach(fn => fn({type, data})); } }; } /** * Send messages over a channel */ class SendChannel extends Channel { type = "write"; /** * Connect to the channel. Automatically called when sending the * first message. */ async connect() { return super.connect(null); } async _send(cmd, body=null) { if (!this.hasConnection()) { await this.connect(); } this.socket.send(JSON.stringify([cmd, body])); } /** * Send a message. The message object must be JSON-serializable. * * @param {Object} msg - The message object to send. */ async send(msg) { await this._send("message", msg); } /** * Disconnect the associated `RecvChannel <#RecvChannel>`_, if * any, on the server side. */ async disconnectReader() { await this._send("disconnectReader"); } /** * Disconnect this channel on the server side. */ async delete() { await this._send("delete"); } }; self.SendChannel = SendChannel; const recvChannelsCreated = new Set(); /** * Receive messages over a channel */ class RecvChannel extends Channel { type = "read"; constructor(uuid) { if (recvChannelsCreated.has(uuid)) { throw new Error(`Already created RecvChannel with id ${uuid}`); } super(uuid); this.eventListeners.message = new Set(); } async connect() { if (this.hasConnection()) { return; } await super.connect(event => this.readMessage(event.data)); } readMessage(data) { let msg = JSON.parse(data); this._dispatch("message", msg); } /** * Wait for the next message and return it (after passing it to * existing handlers) * * @returns {Promise} - Promise that resolves to the message data. */ nextMessage() { return new Promise(resolve => { let fn = ({data}) => { this.removeEventListener("message", fn); resolve(data); }; this.addEventListener("message", fn); }); } } /** * Create a new channel pair * * @returns {Array} - Array of [RecvChannel, SendChannel] for the same channel. */ self.channel = function() { let uuid = createUuid(); let recvChannel = new RecvChannel(uuid); let sendChannel = new SendChannel(uuid); return [recvChannel, sendChannel]; }; /** * Create an unconnected channel defined by a `uuid` in * ``location.href`` for listening for `RemoteGlobal * <#RemoteGlobal>`_ messages. * * @returns {RemoteGlobalCommandRecvChannel} - Disconnected channel */ self.global_channel = function() { let uuid = new URLSearchParams(location.search).get("uuid"); if (!uuid) { throw new Error("URL must have a uuid parameter to use as a RemoteGlobal"); } return new RemoteGlobalCommandRecvChannel(new RecvChannel(uuid)); }; /** * Start listening for `RemoteGlobal <#RemoteGlobal>`_ messages on * a channel defined by a `uuid` in `location.href` * * @returns {RemoteGlobalCommandRecvChannel} - Connected channel */ self.start_global_channel = async function() { let channel = self.global_channel(); await channel.connect(); return channel; }; /** * Close all WebSockets used by channels in the current realm. * */ self.close_all_channel_sockets = async function() { await socketCache.closeAll(); // Spinning the event loop after the close events is necessary to // ensure that the channels really are closed and don't affect // bfcache behaviour in at least some implementations. await new Promise(resolve => setTimeout(resolve, 0)); }; /** * Handler for `RemoteGlobal <#RemoteGlobal>`_ commands. * * This can't be constructed directly but must be obtained from * `global_channel() <#global_channel>`_ or * `start_global_channel() <#start_global_channel>`_. */ class RemoteGlobalCommandRecvChannel { constructor(recvChannel) { this.channel = recvChannel; this.uuid = recvChannel.uuid; this.channel.addEventListener("message", ({data}) => this.handleMessage(data)); this.messageHandlers = new Set(); }; /** * Connect to the channel and start handling messages. */ async connect() { await this.channel.connect(); } /** * Close the channel and underlying websocket connection */ async close() { await this.channel.close(); } async handleMessage(msg) { const {id, command, params, respChannel} = msg; let result = {}; let resp = {id, result}; if (command === "call") { const fn = deserialize(params.fn); const args = params.args.map(deserialize); try { let resultValue = await fn(...args); result.result = serialize(resultValue); } catch(e) { let exception = serialize(e); const getAsInt = (obj, prop) => { let value = prop in obj ? parseInt(obj[prop]) : 0; return Number.isNaN(value) ? 0 : value; }; result.exceptionDetails = { text: e.toString(), lineNumber: getAsInt(e, "lineNumber"), columnNumber: getAsInt(e, "columnNumber"), exception }; } } else if (command === "postMessage") { this.messageHandlers.forEach(fn => fn(deserialize(params.msg))); } if (respChannel) { let chan = deserialize(respChannel); await chan.connect(); await chan.send(resp); } } /** * Add a handler for ``postMessage`` messages * * @param {Function} fn - Callback function that receives the * message. */ addMessageHandler(fn) { this.messageHandlers.add(fn); } /** * Remove a handler for ``postMessage`` messages * * @param {Function} fn - Callback function to remove */ removeMessageHandler(fn) { this.messageHandlers.delete(fn); } /** * Wait for the next ``postMessage`` message and return it * (after passing it to existing handlers) * * @returns {Promise} - Promise that resolves to the message. */ nextMessage() { return new Promise(resolve => { let fn = (msg) => { this.removeMessageHandler(fn); resolve(msg); }; this.addMessageHandler(fn); }); } } class RemoteGlobalResponseRecvChannel { constructor(recvChannel) { this.channel = recvChannel; this.channel.addEventListener("message", ({data}) => this.handleMessage(data)); this.responseHandlers = new Map(); } setResponseHandler(commandId, fn) { this.responseHandlers.set(commandId, fn); } handleMessage(msg) { let {id, result} = msg; let handler = this.responseHandlers.get(id); if (handler) { this.responseHandlers.delete(id); handler(result); } } close() { return this.channel.close(); } } /** * Object representing a remote global that has a * `RemoteGlobalCommandRecvChannel * <#RemoteGlobalCommandRecvChannel>`_ */ class RemoteGlobal { /** * Create a new RemoteGlobal object. * * This doesn't actually construct the global itself; that * must be done elsewhere, with a ``uuid`` query parameter in * its URL set to the same as the ``uuid`` property of this * object. * * @param {SendChannel|string} [dest] - Either a SendChannel * to the destination, or the UUID of the destination. If * ommitted, a new UUID is generated, which can be used when * constructing the URL for the global. * */ constructor(dest) { if (dest === undefined || dest === null) { dest = createUuid(); } if (typeof dest == "string") { /** UUID for the global */ this.uuid = dest; this.sendChannel = new SendChannel(dest); } else if (dest instanceof SendChannel) { this.sendChannel = dest; this.uuid = dest.uuid; } else { throw new TypeError("Unrecognised type, expected string or SendChannel"); } this.recvChannel = null; this.respChannel = null; this.connected = false; this.commandId = 0; } /** * Connect to the channel. Automatically called when sending the * first message */ async connect() { if (this.connected) { return; } let [recvChannel, respChannel] = self.channel(); await Promise.all([this.sendChannel.connect(), recvChannel.connect()]); this.recvChannel = new RemoteGlobalResponseRecvChannel(recvChannel); this.respChannel = respChannel; this.connected = true; } async sendMessage(command, params, hasResp=true) { if (!this.connected) { await this.connect(); } let msg = {id: this.commandId++, command, params}; if (hasResp) { msg.respChannel = serialize(this.respChannel); } let response; if (hasResp) { response = new Promise(resolve => this.recvChannel.setResponseHandler(msg.id, resolve)); } else { response = null; } this.sendChannel.send(msg); return await response; } /** * Run the function ``fn`` in the remote global, passing arguments * ``args``, and return the result after awaiting any returned * promise. * * @param {Function} fn - Function to run in the remote global. * @param {...Any} args - Arguments to pass to the function * @returns {Promise} - Promise resolving to the return value * of the function. */ async call(fn, ...args) { let result = await this.sendMessage("call", {fn: serialize(fn), args: args.map(x => serialize(x))}, true); if (result.exceptionDetails) { throw deserialize(result.exceptionDetails.exception); } return deserialize(result.result); } /** * Post a message to the remote * * @param {Any} msg - The message to send. */ async postMessage(msg) { await this.sendMessage("postMessage", {msg: serialize(msg)}, false); } /** * Disconnect the associated `RemoteGlobalCommandRecvChannel * <#RemoteGlobalCommandRecvChannel>`_, if any, on the server * side. * * @returns {Promise} - Resolved once the channel is disconnected. */ disconnectReader() { // This causes any readers to disconnect until they are explicitly reconnected return this.sendChannel.disconnectReader(); } /** * Close the channel and underlying websocket connections */ close() { let closers = [this.sendChannel.close()]; if (this.recvChannel !== null) { closers.push(this.recvChannel.close()); } if (this.respChannel !== null) { closers.push(this.respChannel.close()); } return Promise.all(closers); } } self.RemoteGlobal = RemoteGlobal; function typeName(value) { let type = typeof value; if (type === "undefined" || type === "string" || type === "boolean" || type === "number" || type === "bigint" || type === "symbol" || type === "function") { return type; } if (value === null) { return "null"; } // The handling of cross-global objects here is broken if (value instanceof RemoteObject) { return "remoteobject"; } if (value instanceof SendChannel) { return "sendchannel"; } if (value instanceof RecvChannel) { return "recvchannel"; } if (value instanceof Error) { return "error"; } if (Array.isArray(value)) { return "array"; } let constructor = value.constructor && value.constructor.name; if (constructor === "RegExp" || constructor === "Date" || constructor === "Map" || constructor === "Set" || constructor == "WeakMap" || constructor == "WeakSet") { return constructor.toLowerCase(); } // The handling of cross-global objects here is broken if (typeof window == "object" && window === self) { if (value instanceof Element) { return "element"; } if (value instanceof Document) { return "document"; } if (value instanceof Node) { return "node"; } if (value instanceof Window) { return "window"; } } if (Promise.resolve(value) === value) { return "promise"; } return "object"; } let remoteObjectsById = new Map(); function remoteId(obj) { let rv; rv = createUuid(); remoteObjectsById.set(rv, obj); return rv; } /** * Representation of a non-primitive type passed through a channel */ class RemoteObject { constructor(type, objectId) { this.type = type; this.objectId = objectId; } /** * Create a RemoteObject containing a handle to reference obj * * @param {Any} obj - The object to reference. */ static from(obj) { let type = typeName(obj); let id = remoteId(obj); return new RemoteObject(type, id); } /** * Return the local object referenced by the ``objectId`` of * this ``RemoteObject``, or ``null`` if there isn't a such an * object in this realm. */ toLocal() { if (remoteObjectsById.has(this.objectId)) { return remoteObjectsById.get(this.objectId); } return null; } /** * Remove the object from the local cache. This means that future * calls to ``toLocal`` with the same objectId will always return * ``null``. */ delete() { remoteObjectsById.delete(this.objectId); } } self.RemoteObject = RemoteObject; /** * Serialize an object as a JSON-compatible representation. * * The format used is similar (but not identical to) * `WebDriver-BiDi * `_. * * Each item to be serialized can have the following fields: * * type - The name of the type being represented e.g. "string", or * "map". For primitives this matches ``typeof``, but for * ``object`` types that have particular support in the protocol * e.g. arrays and maps, it is a custom value. * * value - A serialized representation of the object value. For * container types this is a JSON container (i.e. an object or an * array) containing a serialized representation of the child * values. * * objectId - An integer used to handle object graphs. Where * an object is present more than once in the serialization, the * first instance has both ``value`` and ``objectId`` fields, but * when encountered again, only ``objectId`` is present, with the * same value as the first instance of the object. * * @param {Any} inValue - The value to be serialized. * @returns {Object} - The serialized object value. */ function serialize(inValue) { const queue = [{item: inValue}]; let outValue = null; // Map from container object input to output value let objectsSeen = new Map(); let lastObjectId = 0; /* Instead of making this recursive, use a queue holding the objects to be * serialized. Each item in the queue can have the following properties: * * item (required) - the input item to be serialized * * target - For collections, the output serialized object to * which the serialization of the current item will be added. * * targetName - For serializing object members, the name of * the property. For serializing maps either "key" or "value", * depending on whether the item represents a key or a value * in the map. */ while (queue.length > 0) { const {item, target, targetName} = queue.shift(); let type = typeName(item); let serialized = {type}; if (objectsSeen.has(item)) { let outputValue = objectsSeen.get(item); if (!outputValue.hasOwnProperty("objectId")) { outputValue.objectId = lastObjectId++; } serialized.objectId = outputValue.objectId; } else { switch (type) { case "undefined": case "null": break; case "string": case "boolean": serialized.value = item; break; case "number": if (item !== item) { serialized.value = "NaN"; } else if (item === 0 && 1/item == Number.NEGATIVE_INFINITY) { serialized.value = "-0"; } else if (item === Number.POSITIVE_INFINITY) { serialized.value = "+Infinity"; } else if (item === Number.NEGATIVE_INFINITY) { serialized.value = "-Infinity"; } else { serialized.value = item; } break; case "bigint": case "function": serialized.value = item.toString(); break; case "remoteobject": serialized.value = { type: item.type, objectId: item.objectId }; break; case "sendchannel": serialized.value = item.uuid; break; case "regexp": serialized.value = { pattern: item.source, flags: item.flags }; break; case "date": serialized.value = Date.prototype.toJSON.call(item); break; case "error": serialized.value = { type: item.constructor.name, name: item.name, message: item.message, lineNumber: item.lineNumber, columnNumber: item.columnNumber, fileName: item.fileName, stack: item.stack, }; break; case "array": case "set": serialized.value = []; for (let child of item) { queue.push({item: child, target: serialized}); } break; case "object": serialized.value = {}; for (let [targetName, child] of Object.entries(item)) { queue.push({item: child, target: serialized, targetName}); } break; case "map": serialized.value = []; for (let [childKey, childValue] of item.entries()) { queue.push({item: childKey, target: serialized, targetName: "key"}); queue.push({item: childValue, target: serialized, targetName: "value"}); } break; default: throw new TypeError(`Can't serialize value of type ${type}; consider using RemoteObject.from() to wrap the object`); }; } if (serialized.objectId === undefined) { objectsSeen.set(item, serialized); } if (target === undefined) { if (outValue !== null) { throw new Error("Tried to create multiple output values"); } outValue = serialized; } else { switch (target.type) { case "array": case "set": target.value.push(serialized); break; case "object": target.value[targetName] = serialized; break; case "map": // We always serialize key and value as adjacent items in the queue, // so when we get the key push a new output array and then the value will // be added on the next iteration. if (targetName === "key") { target.value.push([]); } target.value[target.value.length - 1].push(serialized); break; default: throw new Error(`Unknown collection target type ${target.type}`); } } } return outValue; } /** * Deserialize an object from a JSON-compatible representation. * * For details on the serialized representation see serialize(). * * @param {Object} obj - The value to be deserialized. * @returns {Any} - The deserialized value. */ function deserialize(obj) { let deserialized = null; let queue = [{item: obj, target: null}]; let objectMap = new Map(); /* Instead of making this recursive, use a queue holding the objects to be * deserialized. Each item in the queue has the following properties: * * item - The input item to be deserialised. * * target - For members of a collection, a wrapper around the * output collection. This has a ``type`` field which is the * name of the collection type, and a ``value`` field which is * the actual output collection. For primitives, this is null. * * targetName - For object members, the property name on the * output object. For maps, "key" if the item is a key in the output map, * or "value" if it's a value in the output map. */ while (queue.length > 0) { const {item, target, targetName} = queue.shift(); const {type, value, objectId} = item; let result; let newTarget; if (objectId !== undefined && value === undefined) { result = objectMap.get(objectId); } else { switch(type) { case "undefined": result = undefined; break; case "null": result = null; break; case "string": case "boolean": result = value; break; case "number": if (typeof value === "string") { switch(value) { case "NaN": result = NaN; break; case "-0": result = -0; break; case "+Infinity": result = Number.POSITIVE_INFINITY; break; case "-Infinity": result = Number.NEGATIVE_INFINITY; break; default: throw new Error(`Unexpected number value "${value}"`); } } else { result = value; } break; case "bigint": result = BigInt(value); break; case "function": result = new Function("...args", `return (${value}).apply(null, args)`); break; case "remoteobject": let remote = new RemoteObject(value.type, value.objectId); let local = remote.toLocal(); if (local !== null) { result = local; } else { result = remote; } break; case "sendchannel": result = new SendChannel(value); break; case "regexp": result = new RegExp(value.pattern, value.flags); break; case "date": result = new Date(value); break; case "error": // The item.value.type property is the name of the error constructor. // If we have a constructor with the same name in the current realm, // construct an instance of that type, otherwise use a generic Error // type. if (item.value.type in self && typeof self[item.value.type] === "function") { result = new self[item.value.type](item.value.message); } else { result = new Error(item.value.message); } result.name = item.value.name; result.lineNumber = item.value.lineNumber; result.columnNumber = item.value.columnNumber; result.fileName = item.value.fileName; result.stack = item.value.stack; break; case "array": result = []; newTarget = {type, value: result}; for (let child of value) { queue.push({item: child, target: newTarget}); } break; case "set": result = new Set(); newTarget = {type, value: result}; for (let child of value) { queue.push({item: child, target: newTarget}); } break; case "object": result = {}; newTarget = {type, value: result}; for (let [targetName, child] of Object.entries(value)) { queue.push({item: child, target: newTarget, targetName}); } break; case "map": result = new Map(); newTarget = {type, value: result}; for (let [key, child] of value) { queue.push({item: key, target: newTarget, targetName: "key"}); queue.push({item: child, target: newTarget, targetName: "value"}); } break; default: throw new TypeError(`Can't deserialize object of type ${type}`); } if (objectId !== undefined) { objectMap.set(objectId, result); } } if (target === null) { if (deserialized !== null) { throw new Error(`Tried to deserialized a non-root output value without a target` ` container object.`); } deserialized = result; } else { switch(target.type) { case "array": target.value.push(result); break; case "set": target.value.add(result); break; case "object": target.value[targetName] = result; break; case "map": // For maps the same target wrapper is shared between key and value. // After deserializing the key, set the `key` property on the target // until we come to the value. if (targetName === "key") { target.key = result; } else { target.value.set(target.key, result); } break; default: throw new Error(`Unknown target type ${target.type}`); } } } return deserialized; } })();