Initial Commit
This commit is contained in:
201
openspec/archive/2026-05-25-enterprise-web-workers/design.md
Normal file
201
openspec/archive/2026-05-25-enterprise-web-workers/design.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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"`
|
||||
41
openspec/archive/2026-05-25-enterprise-web-workers/tasks.md
Normal file
41
openspec/archive/2026-05-25-enterprise-web-workers/tasks.md
Normal 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).
|
||||
Reference in New Issue
Block a user