I need a WebWorker that handles several types of tasks asynchronously. A naive implementation using promises on top of the standard messaging pattern supported by WebWorkers looks something like this:
// worker.ts
self.addEventListener('message', ({task: 'foo', data: FooData}) => {
const fooResult = process(fooData);
self.postMessage(fooResult);
});
self.addEventListener('message', ({task: 'bar', data: BarData}) => {
const barData = process(barData);
self.postMessage(barResult);
});
const worker = new Worker('path/to/my/worker.js');
function doFoo(data: FooData): Promise<FooResult> {
const promise = new Promise((resolve, reject) => {
worker.onmessage = ({data}: MessageEvent) => resolve(data)
worker.onerror = ({error}: ErrorEvent) => reject(error)
};
worker.sendMessage({task: 'foo', data: data});
return promise
}
function doBar(...) { // similar // }
This approach fails because messages are not "multiplexed" correctly -- both handlers fire on both Foo and Bar tasks, and so doFoo may get doBar's data. One option is to use a single message event handler on both sides with a switch statement to dispatch the messages to the right (sub)-handler, notionally something like this:
function handleMessage({data}: MessageEvent<any>) {
if (matchesFooMessage(data)) {
doFoo(data);
} else if (matchesBarMessage(data)) {
doBar(data);
} else {
throw new Error(`Unrecognized message '${data}'`);
}
}
But I prefer the identifier pattern suggested in this answer:
export type Task<Type extends string = string, Value = any> = {
type: Type;
value: Value;
};
export type TaskMessageData<T extends Task = Task> = T & { id: string };
export type TaskMessageEvent<T extends Task = Task> =
MessageEvent<TaskMessageData<T>>;
export type TransferOptions = Pick<StructuredSerializeOptions, 'transfer'>;
export class TaskWorker {
worker: Worker;
constructor (moduleSpecifier: string, options?: Omit<WorkerOptions, 'type'>) {
this.worker = new Worker(moduleSpecifier, {...options ?? {}, type: 'module'});
this.worker.addEventListener('message', (
{data: {id, value}}: TaskMessageEvent,
) => void this.worker.dispatchEvent(new CustomEvent(id, {detail: value})));
}
process <Result = any, T extends Task = Task>(
{transfer, type, value}: T & TransferOptions,
): Promise<Result> {
return new Promise<Result>(resolve => {
const id = globalThis.crypto.randomUUID();
this.worker.addEventListener(
id,
(ev) => resolve((ev as unknown as CustomEvent<Result>).detail),
{once: true},
);
this.worker.postMessage(
{id, type, value},
transfer ? {transfer} : undefined,
);
});
}
}
export type OrPromise<T> = T | Promise<T>;
export type TaskFnResult<T = any> = { value: T } & TransferOptions;
export type TaskFn<Value = any, Result = any> =
(value: Value) => OrPromise<TaskFnResult<Result>>;
const taskFnMap: Partial<Record<string, TaskFn>> = {};
export function registerTask (type: string, fn: TaskFn): void {
taskFnMap[type] = fn;
}
export async function handleTaskMessage (
{data: {id, type, value: taskValue}}: TaskMessageEvent,
): Promise<void> {
const fn = taskFnMap[type];
if (typeof fn !== 'function') {
throw new Error(`No task registered for the type "${type}"`);
}
const {transfer, value} = await fn(taskValue);
globalThis.postMessage(
{id, value},
transfer ? {transfer} : undefined,
);
}
// worker.ts
import {handleTaskMessage, registerTask} from '../TaskWorker'
registerTask('foo', (barData: BarData) => {
const fooResult = ...
return {value: fooResult, transfer: []};
});
registerTask('bar', (barData: BarData) => {
const barResult = ...
return {value: barResult, transfer: []};
});
self.onmessage = handleTaskMessage;
However, in this pattern, the registered tasks can't share any state. That's usually what we want, but I need to be able to share some state between tasks. What's the best way to add modify this pattern to allow that?