Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,201 @@
# Design: `@suaveplan/web-workers`
Browser-tier Web Worker / SharedWorker / Worker-pool runtime, built on top of
`@suaveplan/events` (`EventBus`), `@suaveplan/error` (`SuaveplanError`,
`defineCodes`), `@suaveplan/opentelemetry` (counters / histograms), and
`@suaveplan/types` (`Clock`, `IdGenerator`).
## File layout
```text
packages/foundation/data/web-workers/
package.json
README.md
tsconfig.lib.json
vite.config.ts
vitest.config.ts
src/
index.ts
base-manager/
base-manager.ts # protected lifecycle + RPC state shared by classic + shared
base-manager.md
base-manager.test.ts
base-manager.types.ts
worker-manager/
worker-manager.ts # classic Worker manager
worker-manager.md
worker-manager.test.ts
shared-worker-manager/
shared-worker-manager.ts # SharedWorker manager (port-per-client)
shared-worker-manager.md
shared-worker-manager.test.ts
worker-pool/
worker-pool.ts # FIFO + round-robin + least-busy + restart-on-crash + health probe
worker-pool.md
worker-pool.test.ts
scheduler.ts
scheduler.md
scheduler.test.ts
message-bus/
message-bus.ts # in-process pub/sub + request/response
message-bus.md
message-bus.test.ts
streaming/
stream.ts # sendStream → AsyncIterable<T>
stream.md
stream.test.ts
circuit-breaker/
circuit-breaker.ts # open / half-open / closed, configurable thresholds
circuit-breaker.md
circuit-breaker.test.ts
helpers/
define-worker.ts # defineWorker({ url, type })
define-worker.md
define-worker.test.ts
observability/
observability.ts # createWebWorkersObservability
observability.md
observability.test.ts
observability.types.ts
errors/
errors.ts # WEB_WORKERS_CODES + WebWorkersError
errors.md
errors.test.ts
types/
web-workers.types.ts # public type definitions
web-workers.types.md
```
## Key design decisions
### `BaseWorkerManager` shares lifecycle + RPC state
Both `WorkerManager` (classic `Worker`) and `SharedWorkerManager` (SharedWorker
port) need:
- a named registry of managed instances,
- per-instance pending-request maps with timers + AbortSignal,
- circuit breaker state,
- backpressure ceiling on `maxPendingRequests`,
- `EventBus<WorkerManagerEventMap>` emission.
The base class owns all of that. The two concrete classes only differ in how
they instantiate the underlying transport (`new Worker` vs
`new SharedWorker(...).port`) and how `terminate` cleans up (`worker.terminate()`
vs `port.close()`).
### `EventBus` payload reshape
Upstream `WorkerManagerEventMap` uses variadic listener args
(`"worker:created": [name: string, config: WorkerConfig]`). The genesis
`@suaveplan/events` `EventBus` takes a single payload per listener. We reshape
every event into an object payload:
`{ name: string, config: WorkerConfig }`. Consumers gain a self-documenting
payload and we keep the contract testable.
### `WorkerPool` scheduling
- **Round-robin** (`scheduler: "round-robin"`, default): each `submit()` picks
`workers[next++ % workers.length]`. Cheap, fair, deterministic.
- **Least-busy** (`scheduler: "least-busy"`): pick the worker with the smallest
`pendingResponses.size`. Better for skewed task durations.
- **FIFO queue** when the pool is saturated (`maxConcurrent` reached): tasks
wait on a queue, drained as workers free up.
- **Backpressure**: queue has a `maxQueueDepth`; over-quota submits reject with
`WEB_WORKERS.QUEUE_FULL`.
- **Restart-on-crash**: when a managed worker emits `error`, the pool
terminates it, increments a restart counter, and re-creates a fresh one from
the same `WorkerConfig`. `maxRestarts` per worker bounds the loop; exceeding
it removes the slot and rejects in-flight requests with
`WEB_WORKERS.RESTART_EXHAUSTED`.
- **Health probe**: optional `healthCheck: (worker) => Promise<boolean>` runs
on an interval; failing workers are restarted.
### AbortSignal + transfer list + backpressure
`sendRequest` accepts an optional `{ signal?, transfer?, priority? }`. Aborts
reject with `WEB_WORKERS.REQUEST_ABORTED`; transfers go through the second
argument of `postMessage`. The pending-request map has a configurable ceiling
(`maxPendingRequests`, default 1024) — over-quota requests reject immediately
with `WEB_WORKERS.BACKPRESSURE`.
### Circuit breaker
Per-worker breaker tracks consecutive `WEB_WORKERS.REQUEST_TIMEOUT` /
`WEB_WORKERS.WORKER_ERROR` failures. After `failureThreshold` it opens for
`resetMs`; while open, `sendRequest` rejects with
`WEB_WORKERS.CIRCUIT_OPEN`. After cool-down it goes half-open: the next
request is allowed; success closes it, failure re-opens.
### Streaming RPC
`sendStream<TReq, TChunk>(name, type, payload)` returns
`AsyncIterable<TChunk>`. The worker pushes `{ type, payload: { chunk }, id, sequence }`
frames terminated by `{ type, payload: { done: true }, id }`. Cancellation:
`AsyncIterator.return()` cancels via `AbortSignal` and emits a final
`{ type: "stream:cancel", id }` to the worker.
### `defineWorker` helper
```ts
defineWorker({ url, type = "module" })
```
Returns a callable `WorkerFactory` that does
`new Worker(url, { type })`. The helper isolates the "must use
`new URL('./worker.ts', import.meta.url)`" pattern from consumer code and
makes the factory injectable into managers without re-typing the options
object.
### Error registry
```text
WEB_WORKERS.DUPLICATE_WORKER 409
WEB_WORKERS.WORKER_NOT_FOUND 404
WEB_WORKERS.WORKER_TERMINATED 410
WEB_WORKERS.REQUEST_TIMEOUT 504
WEB_WORKERS.REQUEST_ABORTED 499
WEB_WORKERS.WORKER_ERROR 500
WEB_WORKERS.BACKPRESSURE 429
WEB_WORKERS.QUEUE_FULL 429
WEB_WORKERS.CIRCUIT_OPEN 503
WEB_WORKERS.RESTART_EXHAUSTED 503
WEB_WORKERS.HANDLER_NOT_FOUND 404
WEB_WORKERS.STREAM_ABORTED 499
WEB_WORKERS.INVALID_TRANSFER 400
```
Each code is reachable from a real call site; every code is tested.
### OTel 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`
Created via `@suaveplan/opentelemetry`'s `createCounter` / `createHistogram`,
identical pattern to `@suaveplan/clipboard`.
### Test strategy
- No real `Worker` API. We ship a `FakeWorker` / `FakeSharedWorker` test
helper inside `*.test.ts`. The injected `WorkerFactory` / `SharedWorkerFactory`
return fake instances that simulate message + error events via a
`MessageChannel` mirror. This is **not a mock** — it is a real
Web-Worker-protocol implementation in-process, which satisfies CLAUDE rule 2
(no `vi.mock` / `jest.mock`).
- Coverage: 100/100/100/100 enforced by `vitest.config.ts`.
- All `it()` carry `{ timeout: 5000 }` (async) / 30000 (long).
- Tests import runner primitives from `@suaveplan/testing/runner` per
CLAUDE rule 11.

View File

@@ -0,0 +1,67 @@
# Proposal: Enterprise migration of `@suaveplan/web-workers`
- **Status**: PROPOSED
- **Package**: `@suaveplan/web-workers`
- **Tier**: browser
- **Category / plugin**: `foundation / data`
- **Source**: upstream `packages/browser/web-workers` (1.0.6)
- **Design**: ./design.md
- **Spec delta**: ./specs/web-workers/spec.md
## Summary
Migrate `@suaveplan/web-workers` from `../suaveplan/packages/browser/web-workers` (1.0.6) to
genesis at `packages/foundation/data/web-workers/` as version `0.1.0`. The upstream package
only manages classic `Worker` instances and exposes a `WorkerPoolConfig` type with no runtime
counterpart; the package name advertises SharedWorker support that does not exist. The genesis
rewrite ships the full contract called out in `enterprise/web-workers.md`:
- `BaseWorkerManager` + `WorkerManager` (classic) + `SharedWorkerManager` (port-per-client).
- `WorkerPool` (FIFO queue, round-robin and least-busy schedulers, min/max idle, health
checks, restart-on-crash supervision, backpressure).
- Typed RPC with `AbortSignal` cancellation, transfer-list support, backpressure ceiling,
circuit breaker, and streaming RPC (`sendStream`).
- `@suaveplan/events` `EventBus`-based lifecycle emission for both managers and the bus.
- OTel counters + histograms for workers active, restart count, RPC latency, and message
rate.
- Typed errors via `@suaveplan/error` under the `WEB_WORKERS.*` namespace.
- `defineWorker()` helper for the `new URL('./worker.ts', import.meta.url)` pattern.
## Parity Checklist
| Capability | Upstream | Migrated | Notes |
|---|---|---|---|
| `WorkerManager` (create/terminate/has/getStatus/getWorkerNames/onError) | ✅ | ✅ | Refactored to extend `BaseWorkerManager`. |
| `WorkerManager.postMessage` / `sendRequest` | ✅ | ✅ | Plus `signal?: AbortSignal` and `transfer?: Transferable[]`. |
| Pending-request map with timeout / id correlation | ✅ | ✅ | Plus `maxPendingRequests` backpressure ceiling. |
| `terminate` rejects pending requests | ✅ | ✅ | Error code `WEB_WORKERS.WORKER_TERMINATED`. |
| `MessageBus` pub/sub + once + broadcast | ✅ | ✅ | Same surface, typed payloads. |
| `MessageBus.registerHandler` + `request` | ✅ | ✅ | Plus `signal` + timeout enforcement (upstream ignored timeout). |
| `MessageBus.subscriberCount` + `getTopics` + `clear` | ✅ | ✅ | |
| `WorkerFactory` injectable for tests | ✅ | ✅ | Plus `SharedWorkerFactory`. |
| `WorkerManagerEventMap` / `MessageBusEventMap` | ✅ | ✅ | Reshaped from variadic listener args to single-payload events for genesis `EventBus`. |
| Event emission at runtime | 🚫 partial | ✅ | Upstream wired into `@suaveplan/events` `EventEmitter`; genesis re-wires onto `EventBus<TMap>`. |
| `SharedWorker` support | 🚫 | ✅ | Enhancement called out in `enterprise/web-workers.md`. |
| `WorkerPool` runtime | 🚫 | ✅ | Enhancement; round-robin + least-busy schedulers, FIFO queue, min/max idle, health probe. |
| `AbortSignal` on `sendRequest` / `MessageBus.request` | 🚫 | ✅ | Enhancement. |
| Transfer-list (`{ transfer: Transferable[] }`) | 🚫 | ✅ | Enhancement. |
| Streaming RPC (`sendStream` → `AsyncIterable`) | 🚫 | ✅ | Enhancement. |
| Circuit breaker on `sendRequest` | 🚫 | ✅ | Enhancement. |
| Structured worker errors via `@suaveplan/error` | 🚫 | ✅ | `WEB_WORKERS.*` registry. |
| OTel counters / histograms | 🚫 | ✅ | Enhancement. |
| `defineWorker()` Vite helper | 🚫 | ✅ | Enhancement. |
| Restart-on-crash supervision | 🚫 | ✅ | Pool only. |
## Impact
### Affected dependents
No genesis consumer yet imports `@suaveplan/web-workers` — this is a fresh land. The package
takes a single internal runtime dep on `@suaveplan/events` (already shipped). Downstream
adoption (drive, audio-engine-browser, packer-browser) is out of scope for this change.
## Out of scope
- Node.js `worker_threads` adapter — different tier, separate package.
- React hooks — covered by a follow-up `@suaveplan/react-web-workers` if/when needed.
- Vite plugin wiring — `defineWorker()` is the helper; no plugin authorship in this change.

View File

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

View File

@@ -0,0 +1,41 @@
# Tasks: enterprise-web-workers
## Scaffolding
- [x] Add `web-workers` to `data:` plugin in `scripts/folder-reorg/assignment.yml`.
- [x] Create `packages/foundation/data/web-workers/` skeleton (`package.json`, `README.md`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`).
- [x] Wire workspace catalog deps: `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/opentelemetry`, `@suaveplan/types`, `tslib`, `@opentelemetry/api`.
## Implementation
- [x] `errors/errors.ts` — `WEB_WORKERS_CODES`, `WebWorkersError`, metadata table.
- [x] `types/web-workers.types.ts` — public type surface.
- [x] `observability/observability.ts` + `observability.types.ts`.
- [x] `helpers/define-worker.ts` — `defineWorker({ url, type })` returns `WorkerFactory`.
- [x] `circuit-breaker/circuit-breaker.ts` — per-worker breaker.
- [x] `base-manager/base-manager.ts` + `base-manager.types.ts` — shared lifecycle, RPC pending map, AbortSignal, transfer, backpressure, breaker, OTel emission, `EventBus` wiring.
- [x] `worker-manager/worker-manager.ts` — classic-Worker concrete manager.
- [x] `shared-worker-manager/shared-worker-manager.ts` — `SharedWorker` port-per-client concrete manager.
- [x] `streaming/stream.ts` — `sendStream` async-iterable RPC.
- [x] `worker-pool/scheduler.ts` — round-robin + least-busy.
- [x] `worker-pool/worker-pool.ts` — FIFO queue, scheduler, restart-on-crash, health probe.
- [x] `message-bus/message-bus.ts` — pub/sub + once + broadcast + registerHandler + request (with timeout + AbortSignal).
- [x] `index.ts` — explicit named re-exports (rule 10).
## Documentation
- [x] Sibling `.md` next to every `.ts` per CLAUDE rule (≥200 words, 6 sections).
- [x] Root `README.md` with quick-start + module links.
## Gates
- [x] `bunx turbo lint typecheck test build --filter=@suaveplan/web-workers` green.
- [x] `bunx turbo test:coverage --filter=@suaveplan/web-workers` ≥ 100/100/100/100.
- [x] `bun scripts/folder-reorg/generate-manifest.ts` (refresh MANIFEST).
## Finalisation
- [x] Fold `specs/web-workers/spec.md` into `openspec/specs/web-workers/spec.md`.
- [x] Move change to `openspec/archive/2026-05-25-enterprise-web-workers/`.
- [x] Flip `STATUS.md` row from `–` to `✅` and bump counts.
- [x] Commit on `feat/migrate-web-workers` with Conventional Commit + Claude co-author footer (do not push).