Files
suaveplan/openspec/specs/web-workers/spec.md
2026-05-27 23:29:53 +01:00

400 lines
17 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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<TReq, TRes>(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<TReq, TRes>(type, payload, options?) → Promise<TRes>`, `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<T>(topic, payload) → number`
(returns delivery count), `broadcast<T>(payload) → number`,
`registerHandler(topic, handler)`, `request<TReq, TRes>(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<TReq, TChunk>(name, type, payload, options?)` SHALL return an
`AsyncIterable<TChunk>`. 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"`