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

202 lines
7.1 KiB
Markdown

# 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.