8.6 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | |
|---|---|---|---|---|---|---|---|---|
| worker-adapter-rd | R&D on Worker adapter: Node vs Web Worker API differences | completed |
|
narrow | medium | component | implementation |
Description
The Worker adapter has an open question documented in docs/architecture/event-targets/worker.md: should we implement one adapter that targets Web Workers (browser + Deno + Bun), two separate adapters for Node worker_threads and Web Workers, or one adapter abstracting both?
The architecture doc recommends starting with a single adapter targeting Web Workers (browser + Deno + Bun all support this API). Node worker_threads support would be added later.
This R&D task should:
- Evaluate the API differences between Web Worker (
self.onmessage/self.postMessage) and Nodeworker_threads(parentPort.on('message')/parentPort.postMessage()) - Determine if a single adapter abstraction is feasible and worth the complexity
- Decide on the initial scope: Web Worker only, or both from the start
- Identify any polyfills or compatibility shims needed
Acceptance Criteria
- R&D notes documented on the API differences between Web Worker and Node worker_threads
- Decision made on scope: single Web Worker adapter, or dual adapter from the start
- If dual adapter, create separate task for the Node worker_threads variant
- If single adapter, identify what runtime detection or abstraction is needed
References
- docs/architecture/event-targets/worker.md (Open Questions section)
Notes
API Comparison: Web Worker vs Node worker_threads
| Concern | Web Worker (browser/Deno/Bun) | Node worker_threads |
|---|---|---|
| Host send | worker.postMessage(msg) |
worker.postMessage(msg) |
| Host receive | worker.onmessage = handler or worker.addEventListener('message', handler) |
worker.on('message', handler) |
| Thread send | self.postMessage(msg) |
parentPort.postMessage(msg) |
| Thread receive | self.onmessage = handler or self.addEventListener('message', handler) |
parentPort.on('message', handler) |
| Message data | event.data (MessageEvent) |
Direct value — no .data wrapper |
| Event model | Event-based (DOM-style) | EventEmitter-based (Node-style) |
| Transferables | postMessage(msg, [transfer]) |
postMessage(msg, [transferList]) — same concept, different arg name |
| Dedicated channels | MessageChannel (web standard) |
MessageChannel from worker_threads |
| Thread self-reference | self / globalThis |
parentPort (must be imported) |
| Lifecycle | self.close() |
process.exit() or parentPort.close() |
Key Differences That Matter for the Adapter
-
Message format: Web Workers wrap data in
MessageEvent.data, somsg.dataextracts the envelope. Nodeworker_threadsdelivers the payload directly with no.datawrapper. This is a fundamental structural difference — any abstraction must handle this at the message-reception layer. -
Event subscription model: Web Workers use
onmessage/addEventListener('message', ...)(DOM EventTarget pattern). Node usesparentPort.on('message', ...)(Node EventEmitter pattern). These are fundamentally different subscription APIs that cannot be unified without a shim/wrapper. -
Thread-side messaging primitive: Web Workers use
self.postMessage(msg)whereselfis the global scope. Node usesparentPort.postMessage(msg)whereparentPortmust be explicitly imported fromnode:worker_threads. These are different objects with different origins. -
Host-side subscription setup:
worker.onmessage = handler(property assignment) vsworker.on('message', handler)(EventEmitter). The object shape is completely different. -
TypeScript types: Web Worker types come from
lib.webworker.d.ts. Node worker types come from@types/node. They are mutually exclusive type definitions — a single file cannot import both without conditional types or// @ts-ignore.
Feasibility Assessment: Single Adapter with Runtime Detection
Verdict: Not recommended.
A single adapter abstracting both APIs would require:
- A runtime detection layer (
typeof process !== 'undefined' && process.versions?.node) - A port abstraction wrapping
worker.onmessage/worker.addEventListenervsworker.on('message', ...) - A thread-side abstraction wrapping
self.postMessagevsparentPort.postMessage - A message-unwrapping layer (
msg.datavsmsg) - Conditional TypeScript imports and types
- Test infrastructure that runs in both browser and Node contexts
This adds significant complexity (wrappers, detection, conditional code paths) for marginal benefit. The adapter's core job — wrapping a postMessage channel — is simple. The abstraction layer would be more code than the adapter itself.
Feasibility Assessment: Single Adapter Abstracting Both (Common Interface)
Verdict: Possible but not worth it.
You could define a MessagePort-like abstraction:
interface WorkerChannel {
send(msg: EventEnvelope): void;
onMessage(handler: (msg: EventEnvelope) => void): void;
close(): void;
}
Then provide two implementations (WebWorkerHostChannel, NodeWorkerHostChannel, etc.). However:
- This pushes the API surface from 2 factory functions to 4 (2 per runtime)
- The user must import the correct factory based on runtime, defeating the "single adapter" goal
- Testing requires both runtime environments
- The adapter code itself is ~50 lines per side; the abstraction adds more code than it saves
Decision: Single Adapter — Web Worker Only (Initial Scope)
Scope: event-target-worker targets Web Workers only (browser, Deno, Bun).
Rationale:
- Three runtimes, one API: Browser, Deno, and Bun all implement the Web Worker API. This covers the vast majority of use cases for a pub/sub transport.
- Node has a natural alternative: Node users already have
event-target-redisfor cross-process pub/sub. Worker threads in Node are primarily for CPU-bound tasks (not distributed messaging), and Redis is the better transport for inter-process coordination. - Simplicity wins: The adapter should be thin. Two factory functions, ~50 lines each. No abstraction layers.
- No runtime detection needed: Since we target only Web Workers, there's no need to detect the environment. The consumer simply uses the correct import for their platform.
- Path for Node later: If Node
worker_threadssupport is needed in the future, a separateevent-target-node-workeradapter can use the sameEventEnvelopeprotocol but withparentPortsemantics. This keeps each adapter simple and focused.
What the Web Worker Adapter Needs
No polyfills or shims are required. The Web Worker API is natively supported in all target runtimes:
- Browser:
Worker,self.postMessage,self.onmessage— standard since ES2015 - Deno: Full Web Worker API support
- Bun: Full Web Worker API support (with additional
MessagePortcompat)
The adapter will be two factory functions:
createWorkerHostEventTarget(worker: Worker)— wraps aWorkerinstance on the host sidecreateWorkerThreadEventTarget()— wrapsselfon the worker thread side
Both send EventEnvelope over postMessage, both extract from event.data on receive. No serialization config needed since EventEnvelope is JSON-serializable and structured-clone compatible.
Node worker_threads — Future Task
If Node worker_threads support is needed, a separate adapter event-target-node-worker should be created. Key differences that adapter would handle:
- Use
parentPortinstead ofself - Use
worker.on('message', ...)instead ofworker.onmessage - No
MessageEvent.datawrapper — receive payload directly - Import from
node:worker_threads - Add
@types/nodeas a dev dependency for type resolution
This is tracked separately (no new task needed at this time — can be created when demand exists).
Summary
Decision: Single adapter targeting Web Workers only — no runtime detection, no abstraction layer.
The API differences between Web Workers and Node worker_threads are structural (EventTarget vs EventEmitter, MessageEvent.data vs direct payload, self.postMessage vs parentPort.postMessage), not superficial. Attempting to unify them would add more complexity than the adapter code itself. The Web Worker API is consistently supported across browser, Deno, and Bun, giving the adapter broad reach without Node-specific code. Node users have Redis as a transport alternative. A separate event-target-node-worker adapter can be created in the future if demand exists.