@xylabs/threads
Version:
Web workers & worker threads as simple as a function call
235 lines (207 loc) • 8.63 kB
text/typescript
/* eslint-disable import-x/no-internal-modules */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-floating-promises */
import isSomeObservable from 'is-observable-2-1-0'
import type { Observable, Subscription } from 'observable-fns'
import { deserialize, serialize } from '../common.ts'
import type { TransferDescriptor } from '../transferable.ts'
import { isTransferDescriptor } from '../transferable.ts'
import type {
MasterJobCancelMessage,
MasterJobRunMessage,
SerializedError,
WorkerInitMessage,
WorkerJobErrorMessage,
WorkerJobResultMessage,
WorkerJobStartMessage,
WorkerUncaughtErrorMessage,
} from '../types/messages.ts'
import {
MasterMessageType,
WorkerMessageType,
} from '../types/messages.ts'
import type {
AbstractedWorkerAPI, WorkerFunction, WorkerModule,
} from '../types/worker.ts'
import type { WorkerGlobalScope } from './WorkerGlobalScope.ts'
const isErrorEvent = (value: Event): value is ErrorEvent => value && (value as ErrorEvent).error
export function createExpose(implementation: AbstractedWorkerAPI, self: WorkerGlobalScope) {
let exposeCalled = false
const activeSubscriptions = new Map<number, Subscription<any>>()
const isMasterJobCancelMessage = (thing: any): thing is MasterJobCancelMessage => thing && thing.type === MasterMessageType.cancel
const isMasterJobRunMessage = (thing: any): thing is MasterJobRunMessage => thing && thing.type === MasterMessageType.run
/**
* There are issues with `is-observable` not recognizing zen-observable's instances.
* We are using `observable-fns`, but it's based on zen-observable, too.
*/
const isObservable = (thing: any): thing is Observable<any> => isSomeObservable(thing) || isZenObservable(thing)
function isZenObservable(thing: any): thing is Observable<any> {
return thing && typeof thing === 'object' && typeof thing.subscribe === 'function'
}
function deconstructTransfer(thing: any) {
return isTransferDescriptor(thing) ? { payload: thing.send, transferables: thing.transferables } : { payload: thing, transferables: undefined }
}
function postFunctionInitMessage() {
const initMessage: WorkerInitMessage = {
exposed: { type: 'function' },
type: WorkerMessageType.init,
}
implementation.postMessageToMaster(initMessage)
}
function postModuleInitMessage(methodNames: string[]) {
const initMessage: WorkerInitMessage = {
exposed: {
methods: methodNames,
type: 'module',
},
type: WorkerMessageType.init,
}
implementation.postMessageToMaster(initMessage)
}
function postJobErrorMessage(uid: number, rawError: Error | TransferDescriptor<Error>) {
const { payload: error, transferables } = deconstructTransfer(rawError)
const errorMessage: WorkerJobErrorMessage = {
error: serialize(error) as any as SerializedError,
type: WorkerMessageType.error,
uid,
}
implementation.postMessageToMaster(errorMessage, transferables)
}
function postJobResultMessage(uid: number, completed: boolean, resultValue?: any) {
const { payload, transferables } = deconstructTransfer(resultValue)
const resultMessage: WorkerJobResultMessage = {
complete: completed ? true : undefined,
payload,
type: WorkerMessageType.result,
uid,
}
implementation.postMessageToMaster(resultMessage, transferables)
}
function postJobStartMessage(uid: number, resultType: WorkerJobStartMessage['resultType']) {
const startMessage: WorkerJobStartMessage = {
resultType,
type: WorkerMessageType.running,
uid,
}
implementation.postMessageToMaster(startMessage)
}
function postUncaughtErrorMessage(error: Error) {
try {
const errorMessage: WorkerUncaughtErrorMessage = {
error: serialize(error) as any as SerializedError,
type: WorkerMessageType.uncaughtError,
}
implementation.postMessageToMaster(errorMessage)
} catch (subError) {
// tslint:disable-next-line no-console
console.error(
'Not reporting uncaught error back to master thread as it ' + 'occured while reporting an uncaught error already.' + '\nLatest error:',
subError,
'\nOriginal error:',
error,
)
}
}
async function runFunction(jobUID: number, fn: WorkerFunction, args: any[]) {
let syncResult: any
try {
syncResult = fn(...args)
} catch (ex) {
const error = ex as Error
return postJobErrorMessage(jobUID, error)
}
const resultType = isObservable(syncResult) ? 'observable' : 'promise'
postJobStartMessage(jobUID, resultType)
if (isObservable(syncResult)) {
const subscription = syncResult.subscribe(
value => postJobResultMessage(jobUID, false, serialize(value)),
(error) => {
postJobErrorMessage(jobUID, serialize(error) as any)
activeSubscriptions.delete(jobUID)
},
() => {
postJobResultMessage(jobUID, true)
activeSubscriptions.delete(jobUID)
},
)
activeSubscriptions.set(jobUID, subscription)
} else {
try {
const result = await syncResult
postJobResultMessage(jobUID, true, serialize(result))
} catch (error) {
postJobErrorMessage(jobUID, serialize(error) as any)
}
}
}
/**
* Expose a function or a module (an object whose values are functions)
* to the main thread. Must be called exactly once in every worker thread
* to signal its API to the main thread.
*
* @param exposed Function or object whose values are functions
*/
const expose = (exposed: WorkerFunction | WorkerModule<any>) => {
if (!implementation.isWorkerRuntime()) {
throw new Error('expose() called in the master thread.')
}
if (exposeCalled) {
throw new Error('expose() called more than once. This is not possible. Pass an object to expose() if you want to expose multiple functions.')
}
exposeCalled = true
if (typeof exposed === 'function') {
implementation.subscribeToMasterMessages((messageData: unknown) => {
if (isMasterJobRunMessage(messageData) && !messageData.method) {
runFunction(messageData.uid, exposed, messageData.args.map(deserialize))
}
})
postFunctionInitMessage()
} else if (typeof exposed === 'object' && exposed) {
implementation.subscribeToMasterMessages((messageData: unknown) => {
if (isMasterJobRunMessage(messageData) && messageData.method) {
runFunction(messageData.uid, exposed[messageData.method], messageData.args.map(deserialize))
}
})
const methodNames = Object.keys(exposed).filter(key => typeof exposed[key] === 'function')
postModuleInitMessage(methodNames)
} else {
throw new Error(`Invalid argument passed to expose(). Expected a function or an object, got: ${exposed}`)
}
implementation.subscribeToMasterMessages((messageData: unknown) => {
if (isMasterJobCancelMessage(messageData)) {
const jobUID = messageData.uid
const subscription = activeSubscriptions.get(jobUID)
if (subscription) {
subscription.unsubscribe()
activeSubscriptions.delete(jobUID)
}
}
})
}
if (typeof globalThis !== 'undefined' && typeof self.addEventListener === 'function' && implementation.isWorkerRuntime()) {
self.addEventListener('error', (event) => {
// Post with some delay, so the master had some time to subscribe to messages
setTimeout(() => postUncaughtErrorMessage(isErrorEvent(event) ? event.error : event), 250)
})
self.addEventListener('unhandledrejection', (event) => {
const error = (event as any).reason
if (error && typeof (error as any).message === 'string') {
// Post with some delay, so the master had some time to subscribe to messages
setTimeout(() => postUncaughtErrorMessage(error), 250)
}
})
}
if (typeof process !== 'undefined' && typeof process.on === 'function' && implementation.isWorkerRuntime()) {
process.on('uncaughtException', (error) => {
// Post with some delay, so the master had some time to subscribe to messages
setTimeout(() => postUncaughtErrorMessage(error), 250)
})
process.on('unhandledRejection', (error) => {
if (error && typeof (error as any).message === 'string') {
// Post with some delay, so the master had some time to subscribe to messages
setTimeout(() => postUncaughtErrorMessage(error as any), 250)
}
})
}
return expose
}