1097 lines
38 KiB
JavaScript
1097 lines
38 KiB
JavaScript
(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
|
|
* <https://w3c.github.io/webdriver-bidi/#data-types-protocolValue>`_.
|
|
*
|
|
* 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;
|
|
}
|
|
})();
|