I built this thing, it's kind of insane. In the worker, you can call it almost like any other async function... including type safety.
I don't really have time to explain more, but maybe i will later.
import BfSharedWorker from "./main.ts" | |
const worker = new BfSharedWorker(); | |
const result = await worker.adder(1,2); | |
const typeErrorLol = await worker.adder("lol", 2); |
class SharedWorkerShim extends EventTarget implements SharedWorker { | |
port: MessagePort; | |
private backingWorker: Worker; | |
constructor(url: string | URL, options: string | WorkerOptions = {}) { | |
super(); | |
const { port1, port2 } = new MessageChannel(); | |
this.port = new Proxy(port1, { | |
get: (target, property) => { | |
if (property === "start") { | |
return () => { | |
target["start"](); // Call the original start method | |
// Dispatch a fake 'onconnect' event when start is called | |
setTimeout(() => { | |
this.backingWorker.postMessage({ | |
type: "onconnect", | |
ports: [port2], | |
}, [port2]); | |
}, 0); | |
}; | |
} | |
// @ts-expect-error we're doing pretty crazy stuff. | |
return target[property]; | |
}, | |
}); | |
if (typeof options === "string") { | |
options = { name: options }; | |
} | |
options.type = "module"; | |
this.backingWorker = new Worker(url, options); | |
} | |
onerror(ev: ErrorEvent) { | |
console.error(ev); | |
} | |
close() { | |
this.port.close(); | |
this.backingWorker.terminate(); | |
this.backingWorker.onmessage = null; | |
this.backingWorker.onerror = null; | |
} | |
} | |
if (typeof SharedWorker === "undefined") { | |
globalThis.SharedWorker = SharedWorkerShim; | |
} | |
type PublicMethodKeys<T> = { | |
[K in keyof T]: T[K] extends (...args: infer P) => infer R | |
? (P extends unknown[] ? (R extends unknown ? K : never) : never) | |
: never; | |
}[keyof T]; | |
// Maps each method of T to its argument types as a tuple. | |
type MethodArguments<T> = { | |
[K in keyof T]: T[K] extends (...args: infer P) => unknown ? P : never; | |
}; | |
// Maps each method of T to its return type. | |
type MethodReturnTypes<T> = { | |
[K in keyof T]: T[K] extends (...args: unknown[]) => infer R ? R : never; | |
}; | |
type WorkerMessage<T extends BfSharedWorker<T>> = { | |
type: PublicMethodKeys<T>; | |
args: MethodArguments<T>[PublicMethodKeys<T>]; | |
}; | |
export enum BfSharedWorkerTypes { | |
"MAIN_WORKER" = "MAIN_WORKER", | |
"MAIN_THREAD" = "MAIN_THREAD", | |
"IMPORTED_WORKER" = "IMPORTED_WORKER", | |
"IMPORTED_MAIN_THREAD" = "IMPORTED_MAIN_THREAD", | |
} | |
export default class BfSharedWorker<T extends BfSharedWorker<T>> { | |
[key: string | symbol]: unknown; | |
protected mainUrl = "/resources/workers/bf-shared-worker.ts"; // my server and my import map both resolve this correctly. | |
protected worker?: SharedWorker; | |
protected supportsRunningAsMainThread = false; | |
protected supportsRunningAsWorker = true; | |
protected workerName = this.constructor.name; | |
private get workerType() { | |
const isWorker = typeof globalThis.window === "undefined"; | |
const isMainRunningInMainThread = typeof globalThis.window !== "undefined"; | |
const isMain = import.meta.main; | |
const isImported = !import.meta.main; | |
if (isMain && isMainRunningInMainThread) { | |
return BfSharedWorkerTypes.MAIN_THREAD; | |
} | |
if (isMain && !isMainRunningInMainThread) { | |
return BfSharedWorkerTypes.MAIN_WORKER; | |
} | |
if (isImported && isWorker) { | |
return BfSharedWorkerTypes.IMPORTED_WORKER; | |
} | |
return BfSharedWorkerTypes.IMPORTED_MAIN_THREAD; | |
} | |
constructor(...args: Array<unknown>) { | |
console.log("Constructor", this.workerType); | |
switch (this.workerType) { | |
case BfSharedWorkerTypes.IMPORTED_MAIN_THREAD: | |
case BfSharedWorkerTypes.IMPORTED_WORKER: | |
return this.initAsImported(...args); | |
case BfSharedWorkerTypes.MAIN_THREAD: | |
return this.initAsMainThread(...args); | |
case BfSharedWorkerTypes.MAIN_WORKER: | |
return this.initAsMainWorker(...args); | |
} | |
} | |
private initAsMainThread(...args: Array<unknown>) { | |
if (!this.supportsRunningAsMainThread) { | |
throw new Error( | |
"This worker does not support running as main thread", | |
); | |
} | |
return this; | |
} | |
private initAsImported(...args: Array<unknown>) { | |
const url = new URL(import.meta.resolve(this.mainUrl)); | |
this.worker = new SharedWorker(url, this.workerName); | |
// Start the port for communication with the SharedWorker | |
console.log("going to start"); | |
this.worker.port.start(); | |
const thisSelf: T = this as unknown as T; // Capture the actual type, including subclasses | |
return new Proxy(thisSelf, { | |
get(target, prop, receiver) { | |
if (typeof prop !== "string") { | |
return Reflect.get(target, prop, receiver); | |
} | |
const origMethod = target[prop]; | |
if (typeof origMethod === "function" && prop in target) { | |
// Intercept method calls | |
return (...methodArgs: unknown[]) => { | |
// Redirect the call through sendMessage | |
// Type safety for arguments is limited | |
return thisSelf.sendMessage( | |
prop as PublicMethodKeys<T>, | |
methodArgs as MethodArguments<T>[PublicMethodKeys<T>], | |
); | |
}; | |
} | |
return Reflect.get(target, prop, receiver); | |
}, | |
}); | |
} | |
private initAsMainWorker(...args: Array<unknown>) { | |
console.log("initAsMainWorker", this.workerType); | |
globalThis.addEventListener("connect", (e) => { | |
this.onConnect((e as MessageEvent).ports as MessagePort[]); | |
}); | |
globalThis.addEventListener("message", (e) => { | |
if (e.data.type === "onconnect") { | |
this.onConnect(e.data.ports as MessagePort[]); | |
} else { | |
console.log("worker message", e); | |
} | |
}); | |
return this; | |
} | |
private onConnect(ports: MessagePort[]) { | |
console.log("onConnect"); | |
const port = ports[0]; | |
port.onmessage = this.receiveMessage.bind(this); | |
} | |
private isFunctionProperty<K extends keyof BfSharedWorker<T>>( | |
prop: K | |
): this is BfSharedWorker<T> & Record<K, (...args: unknown[]) => unknown> { | |
return typeof this[prop] === 'function'; | |
} | |
private async receiveMessage( | |
e: MessageEvent<{type: PublicMethodKeys<T>, args: MethodArguments<T>[PublicMethodKeys<T>], port: MessagePort}>, | |
) { | |
const { type, args, port } = e.data; | |
try { | |
if (type in this && this.isFunctionProperty(type)) { | |
// TypeScript should now recognize 'this[type]' as a function | |
const returnable = await this[type](...args); | |
port.postMessage(returnable); | |
} | |
throw new Error(`Method ${String(type)} not found`); | |
} catch (error) { | |
port.postMessage({ error }); | |
} | |
} | |
private sendMessage<K extends PublicMethodKeys<this>>( | |
type: K, | |
args: MethodArguments<this>[K], | |
transferrables: Transferable[] = [], | |
): Promise<MethodReturnTypes<this>[K]> { | |
// Implementation of sendMessage to handle the proxied method calls | |
return new Promise((resolve, reject) => { | |
const { port1, port2 } = new MessageChannel(); | |
const txfr = [ | |
port2, | |
...transferrables, | |
]; | |
port1.onmessage = (e) => { | |
if (e.data.error) { | |
reject(e.data.error); | |
} else { | |
resolve(e.data); | |
} | |
}; | |
if (this.worker) { | |
console.log("sending message", type, args); | |
return this.worker.port.postMessage({ port: port2, type, args }, txfr); | |
} | |
reject(new Error("Worker not initialized")); | |
}); | |
} | |
// these would normally be extended in a new worker | |
ping() { | |
console.log("ping"); | |
return "pong"; | |
} | |
adder(a: number, b: number) { | |
return a + b; | |
} | |
} | |
if (import.meta.main) { | |
const worker = new BfSharedWorker(); | |
} |