# Capability Spec Delta: web-workers (web-workers) **Package**: `@suaveplan/web-workers` **Status**: SHIPPED > Every requirement below carries a `**Verified by:**` line at authoring time. ## ADDED Requirements ### Requirement: Stable error-code registry The package SHALL expose `WEB_WORKERS_CODES` via `defineCodes("WEB_WORKERS", [...])` with exactly these member codes: `DUPLICATE_WORKER`, `WORKER_NOT_FOUND`, `WORKER_TERMINATED`, `REQUEST_TIMEOUT`, `REQUEST_ABORTED`, `WORKER_ERROR`, `BACKPRESSURE`, `QUEUE_FULL`, `CIRCUIT_OPEN`, `RESTART_EXHAUSTED`, `HANDLER_NOT_FOUND`, `STREAM_ABORTED`, and `INVALID_TRANSFER`. The package SHALL export a `WebWorkersError extends SuaveplanError` class and a frozen `WEB_WORKERS_ERROR_METADATA` table mapping every code to `{ httpStatus, severity, retryable, description }`. #### Scenario: every code is reachable - **WHEN** the consumer triggers each documented failure path - **THEN** a `WebWorkersError` with the matching `code` is raised and the metadata table contains an entry for it **Verified by:** `src/errors/errors.test.ts::"every code has metadata and is reachable from a call site"` --- ### Requirement: Injectable WorkerFactory + SharedWorkerFactory The package SHALL accept an injectable `WorkerFactory` (`(url, opts) => WorkerLike`) into `WorkerManager` and an injectable `SharedWorkerFactory` (`(url, opts) => SharedWorkerLike`) into `SharedWorkerManager`. No internal call site SHALL hard-code `new Worker(...)` or `new SharedWorker(...)` — both go through the factory. #### Scenario: tests substitute a fake transport - **WHEN** a test passes a fake factory returning an in-process worker implementation - **THEN** the manager creates, messages, and terminates workers exclusively through that fake without touching `globalThis.Worker` **Verified by:** `src/worker-manager/worker-manager.test.ts::"uses injected factory exclusively"` AND `src/shared-worker-manager/shared-worker-manager.test.ts::"uses injected SharedWorker factory"` --- ### Requirement: WorkerManager lifecycle + status tracking `WorkerManager` SHALL expose `create(config)`, `terminate(name)`, `terminateAll()`, `has(name)`, `getStatus(name)`, `getWorkerNames()`, and `onError(handler)`. The status of each managed worker SHALL transition through `idle → busy → idle` on a successful request, to `error` on an `error` event, and to `terminated` (then removed from the registry) on `terminate`. `create` SHALL throw `WEB_WORKERS.DUPLICATE_WORKER` for a re-used name; every accessor SHALL throw `WEB_WORKERS.WORKER_NOT_FOUND` for an unknown name. #### Scenario: duplicate creation rejects - **WHEN** `create({ name: "x", url })` is called twice - **THEN** the second call throws `WebWorkersError` with `code === WEB_WORKERS.DUPLICATE_WORKER` #### Scenario: terminate rejects pending requests - **WHEN** `terminate(name)` is called while a `sendRequest` is in flight - **THEN** the pending promise rejects with `WEB_WORKERS.WORKER_TERMINATED` and the worker's `terminate()` is invoked exactly once **Verified by:** `src/worker-manager/worker-manager.test.ts::"duplicate name rejects"` AND `src/worker-manager/worker-manager.test.ts::"terminate rejects in-flight requests"` --- ### Requirement: postMessage with transfer list `postMessage(name, type, payload, options?)` SHALL accept an optional `{ transfer?: Transferable[] }` and pass `transfer` as the second argument to `workerLike.postMessage`. The method SHALL validate the transfer list (`undefined`, empty, or an array of `Transferable`-shaped values) and throw `WEB_WORKERS.INVALID_TRANSFER` for non-array values. #### Scenario: transferable handoff - **WHEN** `postMessage("w", "blob", buffer, { transfer: [buffer] })` is called - **THEN** the worker observes the `ArrayBuffer` via the structured-clone transfer list (second argument), not via a copy **Verified by:** `src/worker-manager/worker-manager.test.ts::"passes transfer list to worker"` AND `src/worker-manager/worker-manager.test.ts::"invalid transfer list rejects"` --- ### Requirement: sendRequest with AbortSignal and timeout `sendRequest(name, type, payload, options?)` SHALL accept `{ timeout?: number, signal?: AbortSignal, transfer?: Transferable[] }`. The default timeout SHALL come from `WorkerConfig.timeout` and default to 30000 ms. On timeout the promise SHALL reject with `WEB_WORKERS.REQUEST_TIMEOUT`. When the `signal` aborts before the response arrives, the promise SHALL reject with `WEB_WORKERS.REQUEST_ABORTED`, clean up the pending entry, and clear the timer. An already-aborted signal SHALL reject synchronously. #### Scenario: timeout fires - **WHEN** the worker never responds and the configured timeout elapses - **THEN** the promise rejects with `WEB_WORKERS.REQUEST_TIMEOUT` #### Scenario: signal aborts mid-flight - **WHEN** the caller aborts the signal after sending the request - **THEN** the promise rejects with `WEB_WORKERS.REQUEST_ABORTED` and the pending map no longer contains the id #### Scenario: already-aborted signal - **WHEN** the signal is aborted before `sendRequest` is invoked - **THEN** the call rejects synchronously with `WEB_WORKERS.REQUEST_ABORTED` and no message is posted to the worker **Verified by:** `src/worker-manager/worker-manager.test.ts::"sendRequest timeout"` AND `src/worker-manager/worker-manager.test.ts::"sendRequest abort mid-flight"` AND `src/worker-manager/worker-manager.test.ts::"sendRequest pre-aborted signal"` --- ### Requirement: Backpressure ceiling on pending requests `WorkerConfig` SHALL accept `maxPendingRequests` (default 1024). When the pending-request map for a worker has reached the ceiling, `sendRequest` SHALL reject with `WEB_WORKERS.BACKPRESSURE` before posting the message. #### Scenario: pending map saturates - **WHEN** a worker has `maxPendingRequests` outstanding requests - **THEN** the next `sendRequest` rejects with `WEB_WORKERS.BACKPRESSURE` and the outgoing message is not posted **Verified by:** `src/worker-manager/worker-manager.test.ts::"backpressure ceiling"` --- ### Requirement: Per-worker circuit breaker `WorkerConfig` SHALL accept `circuitBreaker?: { failureThreshold: number; resetMs: number }`. When configured, consecutive `REQUEST_TIMEOUT` or `WORKER_ERROR` failures SHALL open the breaker; `sendRequest` SHALL reject with `WEB_WORKERS.CIRCUIT_OPEN` while open. After `resetMs` the breaker SHALL transition to half-open; the next request is allowed, success closes the breaker, failure re-opens it. #### Scenario: breaker opens after threshold - **WHEN** `failureThreshold` consecutive timeouts occur - **THEN** the next `sendRequest` rejects synchronously with `WEB_WORKERS.CIRCUIT_OPEN` #### Scenario: breaker recovers via half-open - **WHEN** `resetMs` has elapsed and the next request succeeds - **THEN** the breaker closes and subsequent requests are admitted **Verified by:** `src/circuit-breaker/circuit-breaker.test.ts::"opens after threshold and rejects"` AND `src/circuit-breaker/circuit-breaker.test.ts::"half-open success closes breaker"` --- ### Requirement: SharedWorker manager `SharedWorkerManager` SHALL mirror the `WorkerManager` API surface but use a `SharedWorkerFactory` returning `{ port: MessagePort-like }`. It SHALL call `port.start()` on create, register `message` and `messageerror` listeners on the port, and call `port.close()` on `terminate`. #### Scenario: port lifecycle - **WHEN** `create({ name, url })` is called on a `SharedWorkerManager` - **THEN** the underlying port's `start()` is invoked exactly once and `message` listeners are wired #### Scenario: terminate closes port - **WHEN** `terminate(name)` is called - **THEN** the port's `close()` is invoked and the worker is removed from the registry **Verified by:** `src/shared-worker-manager/shared-worker-manager.test.ts::"starts port on create"` AND `src/shared-worker-manager/shared-worker-manager.test.ts::"closes port on terminate"` --- ### Requirement: WorkerPool with FIFO queue + schedulers + restart supervision `WorkerPool` SHALL accept `WorkerPoolConfig` (`url`, `size`, `type?`, `taskTimeout?`, `scheduler?: "round-robin" | "least-busy"`, `maxConcurrent?`, `maxQueueDepth?`, `maxRestarts?`, `healthCheck?`, `healthCheckIntervalMs?`). It SHALL expose `submit(type, payload, options?) → Promise`, `terminate()`, `size()`, `activeCount()`, `queueDepth()`. Submits beyond `maxConcurrent` SHALL queue (FIFO); submits beyond `maxQueueDepth` SHALL reject with `WEB_WORKERS.QUEUE_FULL`. When a worker emits an `error` the pool SHALL terminate it and create a replacement from the same config, incrementing a restart counter; exceeding `maxRestarts` for a slot SHALL emit `WEB_WORKERS.RESTART_EXHAUSTED` and remove the slot. #### Scenario: round-robin dispatch - **WHEN** a pool of size 3 receives 6 submits with no contention - **THEN** each underlying worker receives exactly 2 messages in round-robin order #### Scenario: least-busy dispatch - **WHEN** scheduler is `least-busy` and one worker is saturated with long-running tasks - **THEN** subsequent submits go to the idle worker #### Scenario: queue overflow - **WHEN** `maxConcurrent` is reached and `maxQueueDepth` queued submits already wait - **THEN** the next submit rejects with `WEB_WORKERS.QUEUE_FULL` #### Scenario: restart on crash - **WHEN** a managed worker emits `error` - **THEN** the pool terminates it, creates a replacement via the same factory, and increments the restart counter; in-flight requests on that worker reject with `WEB_WORKERS.WORKER_ERROR` #### Scenario: restart exhausted - **WHEN** a worker crashes `maxRestarts + 1` times - **THEN** the pool removes the slot, rejects pending submits with `WEB_WORKERS.RESTART_EXHAUSTED`, and reduces `size()` by 1 **Verified by:** `src/worker-pool/worker-pool.test.ts::"round-robin dispatch"` AND `src/worker-pool/worker-pool.test.ts::"least-busy dispatch"` AND `src/worker-pool/worker-pool.test.ts::"queue overflow rejects"` AND `src/worker-pool/worker-pool.test.ts::"restart on crash"` AND `src/worker-pool/worker-pool.test.ts::"restart exhausted"` --- ### Requirement: Optional health-check probe When `healthCheck` is supplied, the pool SHALL invoke it on an interval (`healthCheckIntervalMs`, default 30000) for each worker; workers returning `false` SHALL be restarted under the same `maxRestarts` budget. #### Scenario: failing health check triggers restart - **WHEN** `healthCheck` returns `false` for worker `w0` - **THEN** the pool restarts that worker (terminate + recreate) and emits one `worker:restarted` event **Verified by:** `src/worker-pool/worker-pool.test.ts::"health check failure triggers restart"` --- ### Requirement: MessageBus pub/sub + request/response `MessageBus` SHALL expose `subscribe(topic, handler) → Unsubscribe`, `subscribeOnce(topic, handler) → Unsubscribe`, `publish(topic, payload) → number` (returns delivery count), `broadcast(payload) → number`, `registerHandler(topic, handler)`, `request(topic, payload, options?)` with `{ timeout?, signal? }`, `subscriberCount(topic)`, `getTopics()`, and `clear()`. `request` SHALL reject with `WEB_WORKERS.HANDLER_NOT_FOUND` when no handler is registered, `WEB_WORKERS.REQUEST_TIMEOUT` on elapsed timeout, and `WEB_WORKERS.REQUEST_ABORTED` on signal abort. The default timeout SHALL be 30000 ms. #### Scenario: subscribe + publish - **WHEN** two subscribers are registered for `"topic-a"` and `publish("topic-a", x)` is called - **THEN** both handlers receive `x` and `publish` returns 2 #### Scenario: subscribeOnce auto-removes - **WHEN** a once-subscriber is added and one `publish` occurs - **THEN** the subscriber is removed and a second publish returns 0 for it #### Scenario: registerHandler + request - **WHEN** a handler is registered for `"compute"` and `request("compute", 2)` is called - **THEN** the handler runs and the promise resolves with its result #### Scenario: request without handler - **WHEN** `request("nope", x)` is called and no handler exists - **THEN** the promise rejects with `WEB_WORKERS.HANDLER_NOT_FOUND` #### Scenario: request honours abort - **WHEN** `request("slow", x, { signal })` is called and `signal` aborts - **THEN** the promise rejects with `WEB_WORKERS.REQUEST_ABORTED` **Verified by:** `src/message-bus/message-bus.test.ts::"subscribe + publish delivers to all"` AND `src/message-bus/message-bus.test.ts::"subscribeOnce auto-removes"` AND `src/message-bus/message-bus.test.ts::"registerHandler + request"` AND `src/message-bus/message-bus.test.ts::"request without handler rejects"` AND `src/message-bus/message-bus.test.ts::"request honours abort signal"` AND `src/message-bus/message-bus.test.ts::"request honours timeout"` --- ### Requirement: Streaming RPC `sendStream(name, type, payload, options?)` SHALL return an `AsyncIterable`. The worker pushes `{ type, payload: { chunk }, id, sequence }` frames terminated by `{ type, payload: { done: true }, id }`. Aborting the supplied signal SHALL stop iteration with `WEB_WORKERS.STREAM_ABORTED` and emit a cancel frame (`{ type: "stream:cancel", id }`) to the worker. #### Scenario: stream completion - **WHEN** the worker pushes three chunk frames followed by `{ done: true }` - **THEN** the consumer iterates exactly those three chunks and the iterator returns #### Scenario: stream abort - **WHEN** the consumer aborts the signal after one chunk - **THEN** the iterator throws `WEB_WORKERS.STREAM_ABORTED` and a `stream:cancel` frame is posted to the worker **Verified by:** `src/streaming/stream.test.ts::"yields chunks then completes"` AND `src/streaming/stream.test.ts::"abort cancels stream"` --- ### Requirement: EventBus emission for lifecycle events `WorkerManager`, `SharedWorkerManager`, and `MessageBus` SHALL each expose a `readonly events: EventBus<...>` that emits typed lifecycle events: - `WorkerManagerEventMap` carries `worker:created`, `worker:terminated`, `worker:terminated:all`, `worker:error`, `worker:restarted`, `message:sent`, `message:received`, `request:timeout`, `request:fulfilled`. - `MessageBusEventMap` carries `message:published`, `subscription:added`, `subscription:removed`, `bus:cleared`. Every payload SHALL be a single object (no variadic listener args). #### Scenario: emission on lifecycle - **WHEN** a worker is created, messaged, and terminated - **THEN** `events.on("worker:created" | "message:sent" | "worker:terminated", ...)` fire exactly once each with the expected payload shape **Verified by:** `src/worker-manager/worker-manager.test.ts::"emits lifecycle events on EventBus"` AND `src/message-bus/message-bus.test.ts::"emits subscription/publish/clear events"` --- ### Requirement: OpenTelemetry observability bundle The package SHALL expose `createWebWorkersObservability(opts?)` returning `{ meter, tracer, counters, histograms }` with at minimum these 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` Managers and the pool SHALL increment the relevant counters and observe the relevant histograms on every public method. #### Scenario: instruments wired - **WHEN** a worker manager processes one `postMessage` + one successful `sendRequest` - **THEN** `messages_sent_total` is incremented twice and `rpc_latency_ms` records one sample **Verified by:** `src/observability/observability.test.ts::"creates counters and histograms"` AND `src/worker-manager/worker-manager.test.ts::"records OTel counters and latency"` --- ### Requirement: `defineWorker` helper The package SHALL export `defineWorker({ url, type? })` returning a `WorkerFactory`. The factory SHALL call `new Worker(url, { type: type ?? "module" })` when invoked. The helper isolates the canonical Vite/Webpack worker-bundle pattern from consumer code. #### Scenario: helper returns factory - **WHEN** `defineWorker({ url, type: "module" })` is called - **THEN** the returned function, when invoked, instantiates a `Worker` with that URL and the supplied `type` **Verified by:** `src/helpers/define-worker.test.ts::"returns a worker factory"` --- ### Requirement: Subpath exports + tier purity `package.json` SHALL expose subpath exports for every feature directory (`./errors/errors`, `./types`, `./manager`, `./shared-manager`, `./pool`, `./message-bus`, `./streaming`, `./circuit-breaker`, `./helpers/define-worker`, `./observability`). The package SHALL set `"browser"` and SHALL NOT import any Node-only API at module top level (no `node:fs`, `node:worker_threads`, etc.). #### Scenario: every feature is independently importable - **WHEN** a consumer imports `"@suaveplan/web-workers/pool"` - **THEN** the import resolves to `WorkerPool` without pulling in the manager or bus modules **Verified by:** `src/index.test.ts::"every advertised subpath resolves"` --- ### Requirement: Zero stubs, zero placeholders No source file SHALL contain `TODO`, `FIXME`, `@deprecated`, `ts-ignore`, `ts-expect-error`, `.skip`, `.only`, `any`, or `console.log`. No exported symbol SHALL throw `"not implemented"` or return `undefined as never`. #### Scenario: scan returns empty - **WHEN** `grep -rn '@deprecated|TODO|FIXME|ts-ignore|ts-expect-error|\.skip|\.only|\bany\b|console\.log' src/` is run - **THEN** the output is empty **Verified by:** `src/index.test.ts::"src contains no banned patterns"`