Files
pubsub/tasks/011-worker-adapter-rd.md

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
review-core-and-redis
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:

  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

  • 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

  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:

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.