# 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 ```text 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 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` 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` 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(name, type, payload)` returns `AsyncIterable`. 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 ```ts 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 ```text 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.