17 KiB
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
WebWorkersErrorwith the matchingcodeis 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
WebWorkersErrorwithcode === WEB_WORKERS.DUPLICATE_WORKER
Scenario: terminate rejects pending requests
- WHEN
terminate(name)is called while asendRequestis in flight - THEN the pending promise rejects with
WEB_WORKERS.WORKER_TERMINATEDand the worker'sterminate()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
ArrayBuffervia 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_ABORTEDand the pending map no longer contains the id
Scenario: already-aborted signal
- WHEN the signal is aborted before
sendRequestis invoked - THEN the call rejects synchronously with
WEB_WORKERS.REQUEST_ABORTEDand 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
maxPendingRequestsoutstanding requests - THEN the next
sendRequestrejects withWEB_WORKERS.BACKPRESSUREand 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
failureThresholdconsecutive timeouts occur - THEN the next
sendRequestrejects synchronously withWEB_WORKERS.CIRCUIT_OPEN
Scenario: breaker recovers via half-open
- WHEN
resetMshas 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 aSharedWorkerManager - THEN the underlying port's
start()is invoked exactly once andmessagelisteners 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-busyand one worker is saturated with long-running tasks - THEN subsequent submits go to the idle worker
Scenario: queue overflow
- WHEN
maxConcurrentis reached andmaxQueueDepthqueued 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 + 1times - THEN the pool removes the slot, rejects pending submits with
WEB_WORKERS.RESTART_EXHAUSTED, and reducessize()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
healthCheckreturnsfalsefor workerw0 - THEN the pool restarts that worker (terminate + recreate) and emits one
worker:restartedevent
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"andpublish("topic-a", x)is called - THEN both handlers receive
xandpublishreturns 2
Scenario: subscribeOnce auto-removes
- WHEN a once-subscriber is added and one
publishoccurs - THEN the subscriber is removed and a second publish returns 0 for it
Scenario: registerHandler + request
- WHEN a handler is registered for
"compute"andrequest("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 andsignalaborts - 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_ABORTEDand astream:cancelframe 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:
WorkerManagerEventMapcarriesworker:created,worker:terminated,worker:terminated:all,worker:error,worker:restarted,message:sent,message:received,request:timeout,request:fulfilled.MessageBusEventMapcarriesmessage: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 successfulsendRequest - THEN
messages_sent_totalis incremented twice andrpc_latency_msrecords 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
Workerwith that URL and the suppliedtype
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
WorkerPoolwithout 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"