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.
|
||||
Reference in New Issue
Block a user