@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
JavaScript
/*
* 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