Initial Commit
This commit is contained in:
@@ -0,0 +1,399 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: web-workers (web-workers)
|
||||
|
||||
**Package**: `@suaveplan/web-workers`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> 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"`
|
||||
Reference in New Issue
Block a user