createWorkerScope() lets one message-driven worker module run in three branches: a real browser dedicated worker, a Node worker-thread bridge, or an in-process synthetic fallback. The library keeps that overlap narrow on purpose, so shared worker code can be reused without claiming full worker-runtime emulation.
That matters when the same worker entry module needs to run in a browser worker, a Node worker thread, and a main-thread fallback or test path with one message-handling implementation.
pnpm add @escapace/web-worker-tools| Environment | type |
scope |
Return value |
|---|---|---|---|
| Browser dedicated worker | 'dedicated-worker' |
DedicatedWorkerGlobalScope |
undefined |
| Browser main thread | 'in-process' |
SyntheticWorkerScope |
SyntheticWorker |
| Node main thread | 'in-process' |
SyntheticWorkerScope |
SyntheticWorker |
Node worker_threads |
'worker-thread' |
SyntheticWorkerScope |
undefined |
Only the browser dedicated-worker branch exposes the real DedicatedWorkerGlobalScope.
In the Node worker-thread branch, createWorkerScope() keeps a SyntheticWorkerScope inside the thread and auto-bridges ordinary messages to parentPort. In the in-process branch, the synthetic pair stays entirely local and createWorkerScope() returns the paired SyntheticWorker endpoint.
If the Node worker-thread capability check is unavailable, createWorkerScope() falls back to the in-process branch.
The supported shape inside one Node worker thread is one worker entry module and one createWorkerScope() call. Repeated setup inside the same thread is unsupported.
The portable contract is intentionally small. The synthetic path does not virtualize worker-only globals onto globalThis, does not expose the full WorkerGlobalScope surface, does not include messageerror in the shared contract, and does not promise spec-exact task ordering across every runtime.
The largest semantic difference between branches is payload handling.
| Runtime | Objects | Transfers | ArrayBuffer detaches |
Non-cloneables |
|---|---|---|---|---|
| Browser worker | cloned | honored | yes | fail with clone error |
| Node thread | cloned | honored | yes | fail with clone error |
In-process (structuredClone: false) |
passed by reference | ignored | no | allowed |
In-process (structuredClone: true) |
cloned | honored | yes | fail with clone error |
The default in-process trade-off preserves local object identity and accepts non-cloneable values. structuredClone only affects the in-process branch. It has no effect in the browser dedicated-worker branch, and it does not change clone or transfer behavior in the Node worker-thread branch, where those semantics already come from the native thread boundary.
If stronger worker parity is needed in the in-process branch, opt in explicitly:
import { createWorkerScope } from '@escapace/web-worker-tools'
const worker = createWorkerScope(
({ scope }) => {
scope.onmessage = (event) => {
scope.postMessage(event.data)
}
},
{
structuredClone: true,
},
)With structuredClone: true, sent payloads go through structuredClone() in the in-process branch. Objects are snapshotted instead of shared by reference, transfer lists are honored, and non-cloneable payloads fail at send time with DataCloneError.
Shared code can rely on these behaviors across the supported branches:
- ordinary message delivery stays asynchronous
- ordinary messages arrive as real
MessageEventobjects addEventListener()andremoveEventListener()are availableonmessageandonerrorproperty handlers work- listener behavior follows
EventTargetrules for deduplication,handleEvent()objects,{ once: true }, and capture-sensitive removal terminate()andclose()act as lifecycle cutoffs
The portable event surface is exactly message, error, onmessage, and onerror. messageerror stays outside the shared contract.
Branch timing differs where the runtimes differ:
- browser dedicated workers use the native browser worker runtime directly
- Node worker threads deliver ordinary inbound and outbound messages across
parentPortwithout re-entering the syntheticMessageChannelscheduler - the in-process branch uses the synthetic pair's
MessageChannelscheduler, so timing is worker-like but only approximate relative to a full browser event loop
Receiver-side exceptions are not thrown back synchronously from postMessage().
Scope-side message handler failures are translated into worker-like error delivery. In the in-process branch, those synthetic worker errors are scheduled. In the worker-thread branch, the internal worker-style error dispatch happens before the adapter rethrows into Node's native worker-thread error channel, so parent-side observation still happens through the native Worker 'error' event.
That remapping is narrow. Failures from other synthetic event types continue to follow the host EventTarget error path instead of worker-style error delivery.
Browser dedicated workers keep the native browser split between worker-side close() and parent-side Worker.terminate().
In the in-process branch, terminate() on the returned SyntheticWorker and close() on the synthetic scope both close the pair.
In the Node worker-thread branch, scope.close() is a cooperative worker-side shutdown path. It closes the synthetic pair and calls parentPort.close(), which is enough for the normal message-driven case. It does not cancel arbitrary timers, intervals, handles, or other ref-holding resources created inside the thread. It is not equivalent to parent-side Worker.terminate(), which remains the forced shutdown path for a Node worker thread.
After terminate() or close() in the synthetic path, already queued synthetic messages and already queued synthetic worker-error delivery are dropped.
The examples use one shared module and different runtime wiring around it.
// echo-worker.ts
import { createWorkerScope } from '@escapace/web-worker-tools'
export const worker = createWorkerScope(({ scope }) => {
scope.addEventListener('message', (event) => {
scope.postMessage({
reply: event.data,
})
})
})// main.ts
const worker = new Worker(new URL('./echo-worker.ts', import.meta.url), {
type: 'module',
})
worker.addEventListener('message', (event) => {
console.log(event.data)
})
worker.postMessage({
value: 1,
})In this case, createWorkerScope() runs inside a dedicated worker. The callback receives the real DedicatedWorkerGlobalScope, and the exported worker value from echo-worker.ts is undefined.
Using the same echo-worker.ts module:
import { worker } from './echo-worker.js'
if (worker === undefined) {
throw new Error('Expected a synthetic worker outside a dedicated worker scope')
}
worker.addEventListener('message', (event) => {
console.log(event.data)
})
worker.postMessage({
value: 1,
})In this case, the module is imported outside a dedicated worker. createWorkerScope() returns a SyntheticWorker, and the same message handler runs in process.
Node worker threads still use the synthetic scope because they do not expose DedicatedWorkerGlobalScope. When createWorkerScope() runs inside a Node worker thread, it auto-bridges that synthetic scope to parentPort, so worker entry modules do not need manual wiring.
// thread-worker.ts
import { createWorkerScope } from '@escapace/web-worker-tools'
export const worker = createWorkerScope(({ scope }) => {
scope.addEventListener('message', (event) => {
scope.postMessage({
reply: event.data,
})
})
})// main.ts
import { Worker } from 'node:worker_threads'
const worker = new Worker(new URL('./thread-worker.ts', import.meta.url))
worker.on('message', (message) => {
console.log(message)
})
worker.postMessage({
value: 1,
})Inside the worker thread, the exported worker value is undefined because the scope is auto-bridged to parentPort. Forced shutdown still uses the parent-side native Worker.terminate() API.
function createWorkerScope ↗
Initializes a worker module against the active runtime branch.
export declare function createWorkerScope(
createWorker: (scope: CreateWorkerScopeContext) => void,
options?: CreateWorkerScopeOptions,
): SyntheticWorker | undefined| Parameter | Type | Description |
|---|---|---|
createWorker |
(scope: CreateWorkerScopeContext) => void |
Receives the runtime branch and scope selected for the module. |
options |
CreateWorkerScopeOptions |
Controls payload handling in the in-process branch. |
undefined in the browser dedicated-worker and Node worker-thread branches; otherwise, the paired SyntheticWorker for the in-process branch.
createWorkerScope() selects one of three branches:
- Browser dedicated worker: passes the real
DedicatedWorkerGlobalScopewithtype: 'dedicated-worker'and returnsundefined. - Node worker thread: passes a
SyntheticWorkerScopewithtype: 'worker-thread', auto-bridges ordinary messages toparentPort, and returnsundefined. - In-process fallback: passes a
SyntheticWorkerScopewithtype: 'in-process'and returns the pairedSyntheticWorker.
The shared contract is intentionally narrow. Shared code can rely on asynchronous ordinary message delivery, MessageEvent dispatch for ordinary messages, EventTarget listener behavior, onmessage, onerror, and lifecycle cutoffs through terminate() and close(). The portable event surface is exactly message, error, onmessage, and onerror.
In the in-process branch, payloads are passed by reference and transfer lists are ignored by default. Setting options.structuredClone to true enables structuredClone() and transfer-list behavior for that branch only. It has no effect in the browser dedicated-worker branch, and it does not change clone or transfer behavior in the Node worker-thread branch.
Scope-side message handler failures are translated into worker-like error delivery. Failures from other event types continue to follow the host EventTarget error path.
In the worker-thread branch, scope.close() is a cooperative shutdown path. The parent-side native Node Worker.terminate() API remains the forced-shutdown path.
terminate() and close() also drop already queued synthetic messages and already queued synthetic worker-error delivery.
function isDedicatedWorkerGlobalScope ↗
Checks whether the current global scope is a browser DedicatedWorkerGlobalScope.
isDedicatedWorkerGlobalScope: () => booleantrue when the current global scope is a browser dedicated worker; otherwise, false.
This only identifies the real browser dedicated-worker branch. It returns false in the in-process synthetic branch and in the Node worker-thread branch.
interface CreateWorkerScopeOptions ↗
Configures the in-process branch of createWorkerScope().
export interface CreateWorkerScopeOptionsThese options only affect the in-process branch. In a browser dedicated worker, createWorkerScope() uses the real DedicatedWorkerGlobalScope and ignores them. In a Node worker thread, clone and transfer behavior already comes from the native thread boundary, so these options do not change that behavior.
Enables structuredClone() and transfer-list semantics in the in-process branch.
structuredClone?: boolean;This only affects the in-process branch. When omitted or false, that branch passes payloads by reference and ignores transfer lists.
interface SyntheticWorker ↗
Represents one endpoint in a synthetic worker pair.
export interface SyntheticWorker<T extends SyntheticWorkerTarget = Worker> extends EventTarget| Parameter | Description |
|---|---|
T |
Native endpoint shape mirrored by this synthetic endpoint. |
The surface matches the portable overlap used by createWorkerScope(): EventTarget methods, postMessage(), onmessage, onerror, terminate(), and close().
type CreateWorkerScopeContext ↗
Describes the runtime branch selected by createWorkerScope().
export type CreateWorkerScopeContext =
| {
scope: DedicatedWorkerGlobalScope
type: 'dedicated-worker'
}
| {
scope: SyntheticWorkerScope
type: 'in-process'
}
| {
scope: SyntheticWorkerScope
type: 'worker-thread'
}This is a discriminated union keyed by type.
When type is 'dedicated-worker', scope is the real browser DedicatedWorkerGlobalScope.
When type is 'worker-thread' or 'in-process', scope is a SyntheticWorkerScope that keeps the same message-driven worker module usable outside a browser dedicated worker.
type SyntheticWorkerScope ↗
Represents the synthetic scope passed to createWorkerScope() outside a browser dedicated worker.
export type SyntheticWorkerScope = SyntheticWorker<DedicatedWorkerGlobalScope>type SyntheticWorkerTarget ↗
Represents the native endpoint shapes mirrored by synthetic worker endpoints.
export type SyntheticWorkerTarget = DedicatedWorkerGlobalScope | WorkerSyntheticWorkerScope uses the worker-global shape, and the paired SyntheticWorker defaults to the main-thread Worker shape.