promise-worker-bi
Version:
Promise-based messaging for Web Workers and Shared Workers
258 lines (242 loc) • 8.39 kB
JavaScript
(function () {
;
let messageIDs = 0;
const MSGTYPE_QUERY = 0;
const MSGTYPE_RESPONSE = 1;
const MSGTYPE_HOST_ID = 2;
const MSGTYPE_HOST_CLOSE = 3;
const MSGTYPE_WORKER_ERROR = 4;
const MSGTYPES = [MSGTYPE_QUERY, MSGTYPE_RESPONSE, MSGTYPE_HOST_ID, MSGTYPE_HOST_CLOSE, MSGTYPE_WORKER_ERROR];
// Inlined from https://github.com/then/is-promise
const isPromise = obj => !!obj && (typeof obj === "object" || typeof obj === "function") && typeof obj.then === "function";
const toFakeError = error => {
const fakeError = {
name: error.name,
message: error.message
};
if (typeof error.stack === "string") {
fakeError.stack = error.stack;
}
// These are non-standard properties, I think only in some versions of Firefox
// @ts-expect-error
if (typeof error.fileName === "string") {
// @ts-expect-error
fakeError.fileName = error.fileName;
}
// @ts-expect-error
if (typeof error.columnNumber === "number") {
// @ts-expect-error
fakeError.columnNumber = error.columnNumber;
}
// @ts-expect-error
if (typeof error.lineNumber === "number") {
// @ts-expect-error
fakeError.lineNumber = error.lineNumber;
}
return fakeError;
};
// Object rather than FakeError for convenience
const fromFakeError = fakeError => {
const error = new Error();
return Object.assign(error, fakeError);
};
const logError = err => {
// Logging in the console makes debugging in the worker easier
console.error("Error in Worker:");
console.error(err); // Safari needs it on new line
};
class PWBBase {
constructor() {
// console.log('constructor', worker);
this._callbacks = new Map();
this._queryCallback = () => {};
// @ts-expect-error
this._onMessage = this._onMessage.bind(this);
}
register(cb) {
// console.log('register', cb);
this._queryCallback = cb;
}
// From worker, 2nd param could be hostID if sending to specific host. From UI, 2nd param could be an array of transferable objects
_postResponse(messageID, error, result, hostID) {
// console.log('_postResponse', messageID, error, result);
if (error) {
logError(error);
this._postMessage([MSGTYPE_RESPONSE, messageID, toFakeError(error)], hostID);
} else {
// Hackily identify when message contains transferable objects
if (typeof result === "object" && result !== null && Object.hasOwn(result, "message") && Object.hasOwn(result, "_PWB_TRANSFER")) {
this._postMessage([MSGTYPE_RESPONSE, messageID, null, result.message], hostID, result._PWB_TRANSFER);
} else {
this._postMessage([MSGTYPE_RESPONSE, messageID, null, result], hostID);
}
}
}
_handleQuery(messageID, query, hostID) {
// console.log('_handleQuery', messageID, query);
try {
const result = this._queryCallback(query, hostID);
if (!isPromise(result)) {
this._postResponse(messageID, null, result, hostID);
} else {
result.then(finalResult => {
this._postResponse(messageID, null, finalResult, hostID);
}, finalError => {
this._postResponse(messageID, finalError, hostID);
});
}
} catch (err) {
this._postResponse(messageID, err);
}
}
// Either return messageID and type if further processing is needed, or undefined otherwise
_onMessageCommon(e) {
// console.log('_onMessage', e.data);
const message = e.data;
if (!Array.isArray(message) || message.length < 3 || message.length > 4) {
return; // Ignore - this message is not for us
}
if (typeof message[0] !== "number" || MSGTYPES.indexOf(message[0]) < 0) {
throw new Error("Invalid messageID");
}
const type = message[0];
if (typeof message[1] !== "number") {
throw new Error("Invalid messageID");
}
const messageID = message[1];
if (type === MSGTYPE_QUERY) {
const query = message[2];
if (typeof message[3] !== "number" && message[3] !== undefined) {
throw new Error("Invalid hostID");
}
const hostID = message[3];
this._handleQuery(messageID, query, hostID);
return;
}
if (type === MSGTYPE_RESPONSE) {
if (message[2] !== null && typeof message[2] !== "object") {
throw new Error("Invalid error");
}
const error = message[2] === null ? null : fromFakeError(message[2]);
const result = message[3];
const callback = this._callbacks.get(messageID);
if (callback === undefined) {
// Ignore - user might have created multiple PromiseWorkers.
// This message is not for us.
return;
}
this._callbacks.delete(messageID);
callback(error, result);
return;
}
return {
message,
type
};
}
}
class PWBWorker extends PWBBase {
constructor() {
super();
// Only actually used for SharedWorker
this._hosts = new Map();
this._maxHostID = -1;
if (
// @ts-expect-error
typeof SharedWorkerGlobalScope !== "undefined" &&
// @ts-expect-error
self instanceof SharedWorkerGlobalScope) {
this._workerType = "SharedWorker";
self.addEventListener("connect", e => {
// @ts-expect-error
const port = e.ports[0];
port.addEventListener("message", e2 => this._onMessage(e2));
port.start();
this._maxHostID += 1;
const hostID = this._maxHostID;
this._hosts.set(hostID, {
port
});
// Send back hostID to this host, otherwise it has no way to know it
this._postMessage([MSGTYPE_HOST_ID, -1, hostID], hostID);
});
self.addEventListener("error", e => {
logError(e.error);
// Just send to first host, so as to not duplicate error tracking
const hostID = this._hosts.keys().next().value;
if (hostID !== undefined) {
this._postMessage([MSGTYPE_WORKER_ERROR, -1, toFakeError(e.error)], hostID);
}
});
} else {
this._workerType = "Worker";
self.addEventListener("message", this._onMessage);
// Since this is not a Shared Worker, hostID is always 0 so it's not strictly required to
// send this back, but it makes the API a bit more consistent if there is the same
// initialization handshake in both cases.
this._postMessage([MSGTYPE_HOST_ID, -1, 0], 0);
self.addEventListener("error", e => {
logError(e.error);
this._postMessage([MSGTYPE_WORKER_ERROR, -1, toFakeError(e.error)]);
});
}
}
_postMessage(obj, targetHostID, transfer) {
// console.log('_postMessage', obj, targetHostID);
if (this._workerType === "SharedWorker") {
// If targetHostID has been deleted, this will do nothing, which is fine I think
this._hosts.forEach(({
port
}, hostID) => {
if (targetHostID === undefined || targetHostID === hostID) {
// @ts-expect-error TypeScript thinks transfer can't be undefined
port.postMessage(obj, transfer);
}
});
} else if (this._workerType === "Worker") {
// @ts-expect-error TypeScript thinks self is window, which has a different postMessage call signature. In a worker, this is correct.
self.postMessage(obj, transfer);
} else {
throw new Error("WTF");
}
}
postMessage(userMessage, targetHostID, transfer) {
// console.log('postMessage', userMessage, targetHostID);
const actuallyPostMessage = (resolve, reject) => {
const messageID = messageIDs;
messageIDs += 1;
const messageToSend = [MSGTYPE_QUERY, messageID, userMessage];
this._callbacks.set(messageID, (error, result) => {
if (error) {
reject(error);
} else {
resolve(result);
}
});
this._postMessage(messageToSend, targetHostID, transfer);
};
return new Promise((resolve, reject) => {
actuallyPostMessage(resolve, reject);
});
}
_onMessage(e) {
const common = this._onMessageCommon(e);
if (!common) {
return;
}
const {
message,
type
} = common;
if (type === MSGTYPE_HOST_CLOSE) {
if (typeof message[2] !== "number") {
throw new Error("Invalid hostID");
}
const hostID = message[2];
this._hosts.delete(hostID);
}
}
}
const promiseWorker = new PWBWorker();
promiseWorker.register("mistake!");
})();