Files
suaveplan/openspec/archive/2026-05-25-enterprise-web-workers/design.md
2026-05-27 23:29:53 +01:00

7.1 KiB

Design: @suaveplan/web-workers

Browser-tier Web Worker / SharedWorker / Worker-pool runtime, built on top of @suaveplan/events (EventBus), @suaveplan/error (SuaveplanError, defineCodes), @suaveplan/opentelemetry (counters / histograms), and @suaveplan/types (Clock, IdGenerator).

File layout

packages/foundation/data/web-workers/
  package.json
  README.md
  tsconfig.lib.json
  vite.config.ts
  vitest.config.ts
  src/
    index.ts
    base-manager/
      base-manager.ts            # protected lifecycle + RPC state shared by classic + shared
      base-manager.md
      base-manager.test.ts
      base-manager.types.ts
    worker-manager/
      worker-manager.ts          # classic Worker manager
      worker-manager.md
      worker-manager.test.ts
    shared-worker-manager/
      shared-worker-manager.ts   # SharedWorker manager (port-per-client)
      shared-worker-manager.md
      shared-worker-manager.test.ts
    worker-pool/
      worker-pool.ts             # FIFO + round-robin + least-busy + restart-on-crash + health probe
      worker-pool.md
      worker-pool.test.ts
      scheduler.ts
      scheduler.md
      scheduler.test.ts
    message-bus/
      message-bus.ts             # in-process pub/sub + request/response
      message-bus.md
      message-bus.test.ts
    streaming/
      stream.ts                  # sendStream → AsyncIterable<T>
      stream.md
      stream.test.ts
    circuit-breaker/
      circuit-breaker.ts         # open / half-open / closed, configurable thresholds
      circuit-breaker.md
      circuit-breaker.test.ts
    helpers/
      define-worker.ts           # defineWorker({ url, type })
      define-worker.md
      define-worker.test.ts
    observability/
      observability.ts           # createWebWorkersObservability
      observability.md
      observability.test.ts
      observability.types.ts
    errors/
      errors.ts                  # WEB_WORKERS_CODES + WebWorkersError
      errors.md
      errors.test.ts
    types/
      web-workers.types.ts       # public type definitions
      web-workers.types.md

Key design decisions

BaseWorkerManager shares lifecycle + RPC state

Both WorkerManager (classic Worker) and SharedWorkerManager (SharedWorker port) need:

  • a named registry of managed instances,
  • per-instance pending-request maps with timers + AbortSignal,
  • circuit breaker state,
  • backpressure ceiling on maxPendingRequests,
  • EventBus<WorkerManagerEventMap> emission.

The base class owns all of that. The two concrete classes only differ in how they instantiate the underlying transport (new Worker vs new SharedWorker(...).port) and how terminate cleans up (worker.terminate() vs port.close()).

EventBus payload reshape

Upstream WorkerManagerEventMap uses variadic listener args ("worker:created": [name: string, config: WorkerConfig]). The genesis @suaveplan/events EventBus takes a single payload per listener. We reshape every event into an object payload: { name: string, config: WorkerConfig }. Consumers gain a self-documenting payload and we keep the contract testable.

WorkerPool scheduling

  • Round-robin (scheduler: "round-robin", default): each submit() picks workers[next++ % workers.length]. Cheap, fair, deterministic.
  • Least-busy (scheduler: "least-busy"): pick the worker with the smallest pendingResponses.size. Better for skewed task durations.
  • FIFO queue when the pool is saturated (maxConcurrent reached): tasks wait on a queue, drained as workers free up.
  • Backpressure: queue has a maxQueueDepth; over-quota submits reject with WEB_WORKERS.QUEUE_FULL.
  • Restart-on-crash: when a managed worker emits error, the pool terminates it, increments a restart counter, and re-creates a fresh one from the same WorkerConfig. maxRestarts per worker bounds the loop; exceeding it removes the slot and rejects in-flight requests with WEB_WORKERS.RESTART_EXHAUSTED.
  • Health probe: optional healthCheck: (worker) => Promise<boolean> runs on an interval; failing workers are restarted.

AbortSignal + transfer list + backpressure

sendRequest accepts an optional { signal?, transfer?, priority? }. Aborts reject with WEB_WORKERS.REQUEST_ABORTED; transfers go through the second argument of postMessage. The pending-request map has a configurable ceiling (maxPendingRequests, default 1024) — over-quota requests reject immediately with WEB_WORKERS.BACKPRESSURE.

Circuit breaker

Per-worker breaker tracks consecutive WEB_WORKERS.REQUEST_TIMEOUT / WEB_WORKERS.WORKER_ERROR failures. After failureThreshold it opens for resetMs; while open, sendRequest rejects with WEB_WORKERS.CIRCUIT_OPEN. After cool-down it goes half-open: the next request is allowed; success closes it, failure re-opens.

Streaming RPC

sendStream<TReq, TChunk>(name, type, payload) returns AsyncIterable<TChunk>. The worker pushes { type, payload: { chunk }, id, sequence } frames terminated by { type, payload: { done: true }, id }. Cancellation: AsyncIterator.return() cancels via AbortSignal and emits a final { type: "stream:cancel", id } to the worker.

defineWorker helper

defineWorker({ url, type = "module" })

Returns a callable WorkerFactory that does new Worker(url, { type }). The helper isolates the "must use new URL('./worker.ts', import.meta.url)" pattern from consumer code and makes the factory injectable into managers without re-typing the options object.

Error registry

WEB_WORKERS.DUPLICATE_WORKER       409
WEB_WORKERS.WORKER_NOT_FOUND       404
WEB_WORKERS.WORKER_TERMINATED      410
WEB_WORKERS.REQUEST_TIMEOUT        504
WEB_WORKERS.REQUEST_ABORTED        499
WEB_WORKERS.WORKER_ERROR           500
WEB_WORKERS.BACKPRESSURE           429
WEB_WORKERS.QUEUE_FULL             429
WEB_WORKERS.CIRCUIT_OPEN           503
WEB_WORKERS.RESTART_EXHAUSTED      503
WEB_WORKERS.HANDLER_NOT_FOUND      404
WEB_WORKERS.STREAM_ABORTED         499
WEB_WORKERS.INVALID_TRANSFER       400

Each code is reachable from a real call site; every code is tested.

OTel instruments

Counters:

  • web_workers_messages_sent_total
  • web_workers_messages_received_total
  • web_workers_workers_created_total
  • web_workers_workers_terminated_total
  • web_workers_restarts_total
  • web_workers_errors_total

Histograms:

  • web_workers_rpc_latency_ms
  • web_workers_queue_depth
  • web_workers_pool_active_workers

Created via @suaveplan/opentelemetry's createCounter / createHistogram, identical pattern to @suaveplan/clipboard.

Test strategy

  • No real Worker API. We ship a FakeWorker / FakeSharedWorker test helper inside *.test.ts. The injected WorkerFactory / SharedWorkerFactory return fake instances that simulate message + error events via a MessageChannel mirror. This is not a mock — it is a real Web-Worker-protocol implementation in-process, which satisfies CLAUDE rule 2 (no vi.mock / jest.mock).
  • Coverage: 100/100/100/100 enforced by vitest.config.ts.
  • All it() carry { timeout: 5000 } (async) / 30000 (long).
  • Tests import runner primitives from @suaveplan/testing/runner per CLAUDE rule 11.