docs: complete worker adapter R&D with scope decision

This commit is contained in:
2026-05-08 07:03:25 +00:00
parent 60a51948f1
commit f13d20a652

View File

@@ -1,7 +1,7 @@
---
id: worker-adapter-rd
name: "R&D on Worker adapter: Node vs Web Worker API differences"
status: pending
status: completed
depends_on: [review-core-and-redis]
scope: narrow
risk: medium
@@ -23,10 +23,10 @@ This R&D task should:
## 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
- [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
@@ -34,8 +34,103 @@ This R&D task should:
## Notes
> To be filled by implementation agent
### 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
> To be filled on completion
**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.