UNPKG

@lightningjs/threadx

Version:

A web browser-based JavaScript library that helps manage the communcation of data between one or more web worker threads.

467 lines 17.9 kB
/* * Copyright 2023 Comcast Cable Communications Management, LLC * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * SPDX-License-Identifier: Apache-2.0 */ import { SharedObject } from './SharedObject.js'; import { stringifyTypeId } from './buffer-struct-utils.js'; import { assertTruthy, resolvedGlobal } from './utils.js'; function isMessage(messageType, message) { return (typeof message === 'object' && message !== null && 'threadXMessageType' in message && message.threadXMessageType === messageType); } function isWebWorker(selfObj) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access return typeof selfObj.DedicatedWorkerGlobalScope === 'function'; } export class ThreadX { static init(options) { if (resolvedGlobal.THREADX) { throw new Error('ThreadX.init(): ThreadX already initialized.'); } const threadX = new ThreadX(options); resolvedGlobal.THREADX = threadX; return threadX; } static destroy() { if (!resolvedGlobal.THREADX) { console.warn('ThreadX.destroy(): ThreadX is not initialized.'); return; } delete resolvedGlobal.THREADX; return; } /** * Get the Worker ID of the current worker * * @remarks * This is only valid after ThreadX.init() has been called. */ static get workerId() { if (!resolvedGlobal.THREADX) { throw new Error('ThreadX not initialized'); } return resolvedGlobal.THREADX.workerId; } /** * Get the Worker Name of the current thread * * @remarks * This is only valid after ThreadX.init() has been called. */ static get workerName() { if (!resolvedGlobal.THREADX) { throw new Error('ThreadX not initialized'); } return resolvedGlobal.THREADX.workerName; } static get instance() { if (!resolvedGlobal.THREADX) { throw new Error('ThreadX not initialized'); } return resolvedGlobal.THREADX; } workerId; workerName; sharedObjectFactory; onSharedObjectCreated; onBeforeObjectForgotten; /** * User-defined message handler */ onUserMessage; sharedObjects = new Map(); /** * WeakMap of SharedObjects to additional metadata */ sharedObjectData = new WeakMap(); workers = new Map(); workerReadyPromises = new Map(); pendingAsyncMsgs = new Map(); nextAsyncMsgId = 0; nextUniqueId = 0; /** * Suppress emitting events from SharedObjects * * @remarks * This is used to prevent infinite loops when emitting events from a SharedObject * that is shared with another worker. * * We set this to true when we receive a SharedObjectEmitMessage from another worker * and set it back to false after we have emitted the event on the SharedObject. */ suppressSharedObjectEmit = false; constructor(options) { this.workerId = options.workerId; this.workerName = options.workerName; this.nextUniqueId = options.workerId * 10000000000000 + 1; this.sharedObjectFactory = options.sharedObjectFactory; this.onSharedObjectCreated = options.onObjectShared; this.onBeforeObjectForgotten = options.onBeforeObjectForgotten; this.onUserMessage = options.onMessage; const mySelf = resolvedGlobal; if (isWebWorker(mySelf)) { this.registerWorker('parent', mySelf); this.sendMessage('parent', { threadXMessageType: 'ready', }); } } registerWorker(workerName, worker) { this.workers.set(workerName, worker); // Set up a promise that will resolve when the worker sends the // 'ready' message let readyResolve; let readyPromise; if (workerName === 'parent') { // parent worker is always ready readyPromise = Promise.resolve(); readyResolve = () => { // do nothing }; } else { readyPromise = new Promise((resolve) => { readyResolve = resolve; }); } this.workerReadyPromises.set(workerName, { promise: readyPromise, resolve: readyResolve, }); this.listenForWorkerMessages(workerName, worker); } closeWorker(workerName) { if (!this.workers.has(workerName)) { throw new Error(`Worker ${workerName} not registered.`); } this.closeWorkerAsync(workerName).catch(console.error); } async closeWorkerAsync(workerName, timeout = 5000) { const worker = this.workers.get(workerName); if (!worker) { throw new Error(`Worker ${workerName} not registered.`); } const result = await Promise.race([ new Promise((resolve) => { setTimeout(() => { resolve(false); }, timeout); }), this.sendMessageAsync(workerName, { threadXMessageType: 'close', }), ]); this.workers.delete(workerName); this.workerReadyPromises.delete(workerName); if (!result) { console.warn(`threadX.closeWorkerAsync(): Worker "${workerName}" did not respond to "close" message within ${timeout}ms. Forcing termination.`); worker.terminate?.(); return 'forced'; } return 'graceful'; } listenForWorkerMessages(workerName, worker) { worker.addEventListener('message', (event) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const { data } = event; // Process only if message is a ThreadX message const asyncMsgId = data.__asyncMsgId; this.onMessage(workerName, data) .then((response) => { if (asyncMsgId !== undefined) { worker.postMessage({ threadXMessageType: 'response', asyncMsgId: asyncMsgId, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment data: response, }); } }) .catch((error) => { if (asyncMsgId !== undefined) { worker.postMessage({ threadXMessageType: 'response', asyncMsgId: asyncMsgId, error: true, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment data: error, }); } }); }); } /** * Share a SharedObject with a worker * * @param workerName Worker to share with * @param sharedObject */ async shareObjects(workerName, sharedObjects) { for (const sharedObject of sharedObjects) { if (this.sharedObjects.get(sharedObject.id)) { // Currently we only support sharing objects with only a single worker // TODO: Support sharing objects with multiple workers? // - Do we really need to do this? console.warn(`ThreadX.shareObject(): SharedObject ${sharedObject.id} (TypeID: ${stringifyTypeId(sharedObject.typeId)}) is already shared.`); } else { this.sharedObjects.set(sharedObject.id, sharedObject); this.sharedObjectData.set(sharedObject, { workerName: workerName, shareConfirmed: false, emitQueue: null, }); } } await this.sendMessageAsync(workerName, { threadXMessageType: 'shareObjects', buffers: sharedObjects.map((so) => { return SharedObject.extractBuffer(so); }), }); for (const sharedObject of sharedObjects) { const soData = this.sharedObjectData.get(sharedObject); if (soData) { soData.shareConfirmed = true; const { emitQueue } = soData; if (emitQueue) { for (const event of emitQueue) { this.__sharedObjectEmit(sharedObject, event[0], event[1]); } soData.emitQueue = null; } } } // TODO: Handle case where worker fails to create shared object on its end // - We could issue you an error event back to the sharer } /** * Tell ThreadX to forget about SharedObjects * * @remarks * This causes ThreadX on the current worker and the worker that the object * is shared with to forget about the object. It is up to the worker code to * actually make sure that no other references to the SharedObjects exist so * that they can be garbage collected. * * A worker can implement the onObjectForgotten() callback to be notified * when a SharedObject is forgotten. * * @param sharedObject * @param options Options */ async forgetObjects(sharedObjects, options = {}) { /** * Map of worker name to array of SharedObjects * * @remarks * We group the shared objects by worker so that we can send a single message * to forget all of the objects shared with each worker. */ const objectsByWorker = new Map(); for (const sharedObject of sharedObjects) { if (!this.sharedObjects.has(sharedObject.id)) { // Currently we only support sharing objects with only a single worker if (!options.silent) { console.warn(`ThreadX.forgetObject(): SharedObject ${sharedObject.id} (TypeID: ${stringifyTypeId(sharedObject.typeId)}) is not shared.`); } } else { const soData = this.sharedObjectData.get(sharedObject); assertTruthy(soData); let objectsInWorker = objectsByWorker.get(soData.workerName); if (!objectsInWorker) { objectsInWorker = []; objectsByWorker.set(soData.workerName, objectsInWorker); } objectsInWorker.push(sharedObject); this.sharedObjects.delete(sharedObject.id); this.sharedObjectData.delete(sharedObject); } } const promises = []; for (const [workerName, objectsInWorker] of objectsByWorker) { promises.push( // eslint-disable-next-line @typescript-eslint/no-unsafe-argument this.sendMessageAsync(workerName, { threadXMessageType: 'forgetObjects', objectIds: objectsInWorker.map((so) => so.id), })); } await Promise.all(promises); } sendMessage(workerName, message, transfer) { const worker = this.workers.get(workerName); if (!worker) { throw new Error(`ThreadX.sendMessage(): Worker '${workerName}' not registered.`); } this.sendMessageAsync(workerName, message, transfer, { skipResponseWait: true, }).catch(console.error); } async sendMessageAsync(workerName, message, transfer, options = {}) { const worker = this.workers.get(workerName); if (!worker) { throw new Error(`ThreadX.execMessage(): Worker '${workerName}' not registered.`); } // Wait for the worker to be ready (if it isn't already) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion await this.workerReadyPromises.get(workerName).promise; if (options.skipResponseWait) { worker.postMessage(message, transfer); return; } const asyncMsgId = this.nextAsyncMsgId++; const promise = new Promise((resolve, reject) => { this.pendingAsyncMsgs.set(asyncMsgId, { resolve, reject, }); }); message.__asyncMsgId = asyncMsgId; worker.postMessage(message, transfer); return promise; } async onMessage(srcWorkerName, message) { if (isMessage('shareObjects', message)) { message.buffers.forEach((buffer) => { const sharedObject = this.sharedObjectFactory?.(buffer); if (!sharedObject) { throw new Error('ThreadX.onMesasge(): Failed to create shared object.'); } this.sharedObjects.set(sharedObject.id, sharedObject); this.sharedObjectData.set(sharedObject, { workerName: srcWorkerName, shareConfirmed: true, emitQueue: null, }); this.onSharedObjectCreated?.(sharedObject); }); } else if (isMessage('forgetObjects', message)) { message.objectIds.forEach((id) => { const sharedObject = this.sharedObjects.get(id); if (!sharedObject) { // If we can't find the SharedObject then it wasn't shared with this // worker. Just ignore the message. return; } this.onBeforeObjectForgotten?.(sharedObject); this.sharedObjects.delete(id); sharedObject.destroy(); }); } else if (isMessage('sharedObjectEmit', message)) { const sharedObject = this.sharedObjects.get(message.sharedObjectId); if (!sharedObject) { // If we can't find the SharedObject then it wasn't shared with this // worker. Just ignore the message. return; } // Prevent emitting the event back to the worker that sent it. this.suppressSharedObjectEmit = true; sharedObject.emit(message.eventName, message.data); this.suppressSharedObjectEmit = false; } else if (isMessage('response', message)) { const response = this.pendingAsyncMsgs.get(message.asyncMsgId); if (!response) { throw new Error(`ThreadX.onMessage(): Received response for unknown request (ID: ${message.asyncMsgId})`); } this.pendingAsyncMsgs.delete(message.asyncMsgId); if (message.error) { response.reject(message.data); } else { response.resolve(message.data); } } else if (isMessage('close', message)) { resolvedGlobal.close(); return true; } else if (isMessage('ready', message)) { // Resolve the worker ready promise this.workerReadyPromises.get(srcWorkerName)?.resolve(); return true; } else if (this.onUserMessage) { return await this.onUserMessage(message); } } getSharedObjectById(id) { return this.sharedObjects.get(id) || null; } /** * Generates an ID that is unique across all ThreadX workers. * * @remarks * The ID is based on the `workerId` set in the `ThreadXOptions` and an * incrementing counter. For the ID to actually be unique the `workerId` must * also be unique. * * @returns A unique ID */ generateUniqueId() { return this.nextUniqueId++; } /** * Emit an event from a SharedObject to all other workers * * @internalRemarks * For internal ThreadX use only. * * Since we aren't sure what workers are sharing a SharedObject we need to * emit the event to all workers. (TODO: Possible optimization?) * * @param sharedObject * @param eventName * @param data * @returns */ __sharedObjectEmit(sharedObject, eventName, data) { // If we are currently emitting an event from a SharedObject that originated // from another worker then we don't want to emit the event again. if (this.suppressSharedObjectEmit) { return; } const soData = this.sharedObjectData.get(sharedObject); if (!soData) { // Object isn't shared with any workers yet. Not even in process to do so. // Just ignore the emit. return; } if (!soData.shareConfirmed) { // Object is in the process of being shared with other workers. Queue the // emit until the share is confirmed. if (!soData.emitQueue) { soData.emitQueue = []; } soData.emitQueue.push([eventName, data]); return; } const worker = this.workers.get(soData.workerName); assertTruthy(worker, 'Worker not found'); worker.postMessage({ threadXMessageType: 'sharedObjectEmit', sharedObjectId: sharedObject.id, eventName, data, }); } } //# sourceMappingURL=ThreadX.js.map