--- id: worker-adapter-rd name: "R&D on Worker adapter: Node vs Web Worker API differences" status: completed depends_on: [review-core-and-redis] scope: narrow risk: medium impact: component level: 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: 1. Evaluate the API differences between Web Worker (`self.onmessage`/`self.postMessage`) and Node `worker_threads` (`parentPort.on('message')`/`parentPort.postMessage()`) 2. Determine if a single adapter abstraction is feasible and worth the complexity 3. Decide on the initial scope: Web Worker only, or both from the start 4. Identify any polyfills or compatibility shims needed ## Acceptance Criteria - [x] R&D notes documented on the API differences between Web Worker and Node worker_threads - [x] Decision made on scope: single Web Worker adapter, or dual adapter from the start - [x] If dual adapter, create separate task for the Node worker_threads variant - [x] 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 1. **Message format**: Web Workers wrap data in `MessageEvent.data`, so `msg.data` extracts the envelope. Node `worker_threads` delivers the payload directly with no `.data` wrapper. This is a fundamental structural difference — any abstraction must handle this at the message-reception layer. 2. **Event subscription model**: Web Workers use `onmessage`/`addEventListener('message', ...)` (DOM EventTarget pattern). Node uses `parentPort.on('message', ...)` (Node EventEmitter pattern). These are fundamentally different subscription APIs that cannot be unified without a shim/wrapper. 3. **Thread-side messaging primitive**: Web Workers use `self.postMessage(msg)` where `self` is the global scope. Node uses `parentPort.postMessage(msg)` where `parentPort` must be explicitly imported from `node:worker_threads`. These are different objects with different origins. 4. **Host-side subscription setup**: `worker.onmessage = handler` (property assignment) vs `worker.on('message', handler)` (EventEmitter). The object shape is completely different. 5. **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.addEventListener` vs `worker.on('message', ...)` - A thread-side abstraction wrapping `self.postMessage` vs `parentPort.postMessage` - A message-unwrapping layer (`msg.data` vs `msg`) - 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: ```ts 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: 1. **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. 2. **Node has a natural alternative**: Node users already have `event-target-redis` for 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. 3. **Simplicity wins**: The adapter should be thin. Two factory functions, ~50 lines each. No abstraction layers. 4. **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. 5. **Path for Node later**: If Node `worker_threads` support is needed in the future, a separate `event-target-node-worker` adapter can use the same `EventEnvelope` protocol but with `parentPort` semantics. 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 `MessagePort` compat) The adapter will be two factory functions: - `createWorkerHostEventTarget(worker: Worker)` — wraps a `Worker` instance on the host side - `createWorkerThreadEventTarget()` — wraps `self` on 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 `parentPort` instead of `self` - Use `worker.on('message', ...)` instead of `worker.onmessage` - No `MessageEvent.data` wrapper — receive payload directly - Import from `node:worker_threads` - Add `@types/node` as 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.