Files
2026-05-27 23:29:53 +01:00

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 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"