Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

View File

@@ -0,0 +1,96 @@
# @suaveplan/web-workers - Enterprise browser-tier Web Worker / SharedWorker / worker-pool runtime: typed RPC with AbortSignal + transfer-list + backpressure + circuit breaker + streaming, FIFO + round-robin / least-busy schedulers, restart-on-crash supervision, EventBus lifecycle emission, OTel observability, typed WEB_WORKERS.* error registry, defineWorker() helper.
Enterprise browser-tier runtime for managing Web Workers, SharedWorkers, and
fixed-size worker pools with typed RPC, AbortSignal-aware cancellation,
transfer-list zero-copy handoff, pending-request backpressure, per-worker
circuit breakers, streaming RPC, FIFO + round-robin / least-busy scheduling,
restart-on-crash supervision, EventBus lifecycle emission, and a
`createWebWorkersObservability()` OpenTelemetry bundle. Typed errors live
under the `WEB_WORKERS.*` registry.
<!-- begin:auto-docs -->
## Documentation tree
- [base-manager](./src/base-manager/base-manager.md) — `base-manager.ts` declares `BaseWorkerManager<TTransport>`, the abstract class that hosts everything shared between the…
- [base-manager](./src/base-manager/base-manager.md) — `base-manager.ts` declares `BaseWorkerManager<TTransport>`, the abstract class that hosts everything shared between the…
- [helpers](./src/base-manager/helpers.md) — `helpers.ts` co-locates the small utilities the base manager and the message bus both depend on: a default wall-clock i…
- [circuit-breaker](./src/circuit-breaker/circuit-breaker.md) — `circuit-breaker.ts` exposes `CircuitBreaker`, a per-worker failure- isolating state machine the base manager uses to s…
- [circuit-breaker](./src/circuit-breaker/circuit-breaker.md) — `circuit-breaker.ts` exposes `CircuitBreaker`, a per-worker failure- isolating state machine the base manager uses to s…
- [errors](./src/errors/errors.md) — `errors.ts` declares the stable error-code registry, typed error class, and metadata table for `@suaveplan/web-workers`…
- [errors](./src/errors/errors.md) — `errors.ts` declares the stable error-code registry, typed error class, and metadata table for `@suaveplan/web-workers`…
- [helpers](./src/helpers/define-worker.md) — `define-worker.ts` exposes `defineWorker()`, a small helper that wraps the canonical `new Worker(url, { type })` patter…
- [define-worker](./src/helpers/define-worker.md) — `define-worker.ts` exposes `defineWorker()`, a small helper that wraps the canonical `new Worker(url, { type })` patter…
- [message-bus](./src/message-bus/message-bus.md) — `message-bus.ts` declares `MessageBus`, an in-process publish/subscribe + request/response message bus. It's the right…
- [message-bus](./src/message-bus/message-bus.md) — `message-bus.ts` declares `MessageBus`, an in-process publish/subscribe + request/response message bus. It's the right…
- [observability](./src/observability/observability.md) — `observability.ts` exposes `createWebWorkersObservability()`, the factory that builds the OpenTelemetry counter + histo…
- [observability](./src/observability/observability.md) — `observability.ts` exposes `createWebWorkersObservability()`, the factory that builds the OpenTelemetry counter + histo…
- [shared-worker-manager](./src/shared-worker-manager/shared-worker-manager.md) — `shared-worker-manager.ts` declares `SharedWorkerManager`, the concrete SharedWorker manager. It extends `BaseWorkerMan…
- [shared-worker-manager](./src/shared-worker-manager/shared-worker-manager.md) — `shared-worker-manager.ts` declares `SharedWorkerManager`, the concrete SharedWorker manager. It extends `BaseWorkerMan…
- [streaming](./src/streaming/stream.md) — `stream.ts` exposes `sendStream`, a streaming RPC primitive that returns an `AsyncIterable<TChunk>` driven by frames th…
- [stream](./src/streaming/stream.md) — `stream.ts` exposes `sendStream`, a streaming RPC primitive that returns an `AsyncIterable<TChunk>` driven by frames th…
- [worker-manager](./src/worker-manager/worker-manager.md) — `worker-manager.ts` declares `WorkerManager`, the concrete classic-`Worker` manager. It extends `BaseWorkerManager<Work…
- [worker-manager](./src/worker-manager/worker-manager.md) — `worker-manager.ts` declares `WorkerManager`, the concrete classic-`Worker` manager. It extends `BaseWorkerManager<Work…
- [worker-pool](./src/worker-pool/worker-pool.md) — `worker-pool.ts` declares `WorkerPool`, the fixed-size pool that fans incoming tasks across multiple `Worker` slots wit…
- [internals](./src/worker-pool/internals.md) — `internals.ts` holds the small internal types and helpers `worker-pool.ts` needs but that don't make sense to surface i…
- [scheduler](./src/worker-pool/scheduler.md) — `scheduler.ts` exposes `pickSlot`, the pure function `WorkerPool` calls to decide which slot receives the next submit.…
- [worker-pool](./src/worker-pool/worker-pool.md) — `worker-pool.ts` declares `WorkerPool`, the fixed-size pool that fans incoming tasks across multiple `Worker` slots wit…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/web-workers
```
## Quick start
```ts
import { defineWorker, WorkerManager } from "@suaveplan/web-workers";
const factory = defineWorker({
url: new URL("./my-worker.ts", import.meta.url),
type: "module",
});
const manager = new WorkerManager({ factory });
manager.create({ name: "hash", url: "ignored-by-factory" });
const digest = await manager.sendRequest<string, string>(
"hash",
"sha256",
"hello world"
);
manager.terminateAll();
```
## Modules
- [`./src/errors/errors.md`](./src/errors/errors.md) — `WEB_WORKERS_CODES`,
`WebWorkersError`, metadata table, `asWebWorkersError`.
- [`./src/types/web-workers.types.md`](./src/types/web-workers.types.md) —
zero-runtime public type surface.
- [`./src/observability/observability.md`](./src/observability/observability.md)
— `createWebWorkersObservability()` OpenTelemetry counters + histograms.
- [`./src/base-manager/base-manager.md`](./src/base-manager/base-manager.md)
— shared lifecycle / RPC state for the two concrete managers.
- [`./src/base-manager/helpers.md`](./src/base-manager/helpers.md) — clock,
id-generator, transfer-list validation, abort-signal wiring helpers.
- [`./src/worker-manager/worker-manager.md`](./src/worker-manager/worker-manager.md)
— classic `Worker` manager.
- [`./src/shared-worker-manager/shared-worker-manager.md`](./src/shared-worker-manager/shared-worker-manager.md)
— `SharedWorker` manager.
- [`./src/worker-pool/worker-pool.md`](./src/worker-pool/worker-pool.md) —
fixed-size pool with FIFO queue + restart-on-crash + health probe.
- [`./src/worker-pool/scheduler.md`](./src/worker-pool/scheduler.md) —
round-robin / least-busy slot picker.
- [`./src/worker-pool/internals.md`](./src/worker-pool/internals.md) —
internal slot / queued-task types for the pool.
- [`./src/message-bus/message-bus.md`](./src/message-bus/message-bus.md) —
in-process pub/sub + request/response bus.
- [`./src/streaming/stream.md`](./src/streaming/stream.md) — `sendStream`
async-iterable streaming RPC.
- [`./src/circuit-breaker/circuit-breaker.md`](./src/circuit-breaker/circuit-breaker.md)
— per-worker failure-isolating breaker.
- [`./src/helpers/define-worker.md`](./src/helpers/define-worker.md) —
canonical Vite/Webpack worker-bundle factory.

View File

@@ -0,0 +1,140 @@
# `base-manager`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`base-manager.ts` declares `BaseWorkerManager<TTransport>`, the abstract
class that hosts everything shared between the classic-Worker manager
(`WorkerManager`) and the SharedWorker manager (`SharedWorkerManager`).
Both concrete classes extend this base and only override
`createTransport`, `terminateTransport`, and `postToTransport` — the rest
of the lifecycle, RPC state, AbortSignal wiring, transfer-list validation,
backpressure ceiling, circuit-breaker integration, OTel emission, and
`EventBus` lifecycle events all live here.
Centralising this logic is what makes the contract testable in isolation
and identical across transports: the same `terminate-rejects-pending`
semantics, the same backpressure code, the same OTel counter increments
fire regardless of whether the underlying transport is a `Worker` or a
SharedWorker port.
## Features
- Per-name worker registry with `idle / busy / error / terminated` status.
- Pending-request map with timeout + AbortSignal + backpressure ceiling
(`maxPendingRequests`).
- Per-worker circuit breaker integration (`config.circuitBreaker`).
- Transfer-list validation (rejects non-array `transfer` with `INVALID_TRANSFER`).
- `EventBus<WorkerManagerEventMap>` emission on every lifecycle event.
- OTel counter increments on every send / receive / create / terminate.
- RPC latency histogram observation on every fulfilled request.
- Multi-handler `onError` registry.
## Usage: Basic Example
`BaseWorkerManager` is abstract — consumers always work with `WorkerManager`
or `SharedWorkerManager` directly. The shared API surface that
`BaseWorkerManager` exposes is therefore consumed via the subclasses:
```ts
import { WorkerManager } from "@suaveplan/web-workers";
const manager = new WorkerManager({ factory });
manager.create({ name: "hash", url: WORKER_URL });
const digest = await manager.sendRequest<string, string>(
"hash",
"sha256",
"hello"
);
```
## Usage: Advanced Example
Subclassing for a custom transport (e.g. a `MessageChannel`-backed in-process
worker):
```ts
import {
BaseWorkerManager,
type BaseManagerOptions,
type WorkerConfig,
type WorkerMessage,
} from "@suaveplan/web-workers";
interface ChannelTransport {
readonly port: MessagePort;
}
class MessageChannelManager extends BaseWorkerManager<ChannelTransport> {
constructor(opts: BaseManagerOptions) {
super(opts);
}
protected override createTransport(config: WorkerConfig): ChannelTransport {
const { port1, port2 } = new MessageChannel();
port1.onmessage = (event) => {
this.handleMessage(
config.name,
event.data as WorkerMessage
);
};
port1.start();
// hand `port2` to the worker side somehow (e.g. via postMessage)
return { port: port1 };
}
protected override terminateTransport(t: ChannelTransport): void {
t.port.close();
}
protected override postToTransport(
t: ChannelTransport,
message: unknown,
transfer: Transferable[] | undefined
): void {
if (transfer !== undefined && transfer.length > 0) {
t.port.postMessage(message, transfer);
} else {
t.port.postMessage(message);
}
}
}
```
## API Reference
- `BaseWorkerManager<TTransport>` — abstract class.
- `events: EventBus<WorkerManagerEventMap>` — lifecycle event bus.
- `create(config: WorkerConfig)` — throws `DUPLICATE_WORKER`.
- `terminate(name)` — throws `WORKER_NOT_FOUND`; rejects pending with
`WORKER_TERMINATED`.
- `terminateAll()` — iterates `terminate(name)` for each.
- `has(name): boolean`.
- `getStatus(name): WorkerStatus` — throws `WORKER_NOT_FOUND`.
- `getWorkerNames(): string[]`.
- `onError(handler: (name, error) => void): void`.
- `postMessage<T>(name, type, payload, options?)` — fire-and-forget;
validates transfer.
- `sendRequest<TReq, TRes>(name, type, payload, options?)` — request /
response with timeout, signal, transfer, backpressure, circuit breaker.
- Protected abstract methods: `createTransport`, `terminateTransport`,
`postToTransport`.
- Protected helpers: `handleMessage`, `handleError`, `getEntry`.
- `ManagedEntry<TTransport>` — exported internal entry type for subclass
visibility.
## Implementation Notes
The constructor is `protected` — only subclasses can instantiate. The
internal `PendingEntry` interface stores both the `timer` handle and the
`cleanupSignal` closure so both can be torn down in any of the three exit
paths (success, abort, terminate, timeout). The `entry.pending.delete(id)`
call happens unconditionally in both the timer + abort paths so a single
race where the abort fires after the timer body started running still
leaves the pending map in a consistent state. `dispatchRequest` is split
out of `sendRequest` to keep both methods under the 50-line soft limit and
to give `sendRequest` a clean place to do the synchronous pre-flight
(backpressure check, pre-aborted signal, transfer validation, breaker
probe). The status field flips `busy → idle` only when the pending count
hits zero, so concurrent in-flight requests don't prematurely reset the
status.

View File

@@ -0,0 +1,76 @@
# `helpers`
## Purpose
`helpers.ts` co-locates the small utilities the base manager and the
message bus both depend on: a default wall-clock implementation, a default
id generator backed by `crypto.getRandomValues`, the transfer-list validator
that raises `WEB_WORKERS.INVALID_TRANSFER`, and a one-shot abort-signal
wiring helper that returns a cleanup closure.
Extracting them avoids duplication between `BaseWorkerManager` and
`MessageBus` (both need clock, id-generator, and abort wiring) and keeps
the base manager's main file under the 300-line ceiling.
## Features
- `defaultClock` — wall-clock `Date.now()` Clock implementation.
- `defaultIdGenerator` — cryptographically random 16-hex-character ids.
- `validateTransfer(transfer)` — passes through `undefined` and arrays,
throws `WebWorkersError(INVALID_TRANSFER)` for anything else (string,
object, number).
- `wireAbortSignal(signal, onAbort)` — registers a one-shot abort listener
and returns a cleanup function that removes it.
## Usage: Basic Example
```ts
import {
defaultIdGenerator,
validateTransfer,
} from "@suaveplan/web-workers/base-manager";
const id = defaultIdGenerator();
const transfer = validateTransfer(opts?.transfer);
```
## Usage: Advanced Example
```ts
import { wireAbortSignal } from "@suaveplan/web-workers/base-manager";
function withAbort<T>(
signal: AbortSignal | undefined,
onTimeout: () => void,
promise: Promise<T>
): Promise<T> {
const cleanup = wireAbortSignal(signal, onTimeout);
return promise.finally(() => {
cleanup?.();
});
}
```
## API Reference
- `defaultClock: Clock` — `() => Date.now()`.
- `defaultIdGenerator: IdGenerator` — `() => string` returning 16
lowercase-hex chars (64 bits of entropy).
- `validateTransfer(transfer: Transferable[] | undefined): Transferable[] | undefined`
— pass-through on undefined / array; throws on non-array.
- `wireAbortSignal(signal: AbortSignal | undefined, onAbort: () => void): (() => void) | undefined`
— returns a cleanup closure or `undefined` when `signal` is undefined.
## Implementation Notes
`defaultIdGenerator` uses `crypto.getRandomValues(new Uint8Array(8))` and
hex-encodes the bytes. 64 bits of entropy is sufficient for per-process
RPC correlation (`birthday-paradox` collision probability is < 1e-9 for
fewer than 10⁵ outstanding requests). `wireAbortSignal` uses
`addEventListener('abort', listener, { once: true })` so the listener
removes itself after firing — the returned cleanup is therefore safe to
call any number of times. `validateTransfer` is intentionally permissive
on the array's contents (it doesn't try to prove every element is actually
`Transferable`); structured-clone will throw at `postMessage` time if a
non-transferable slips in. Surfacing that error is the transport's job,
not the validator's.

View File

@@ -0,0 +1,94 @@
# `circuit-breaker`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`circuit-breaker.ts` exposes `CircuitBreaker`, a per-worker failure-
isolating state machine the base manager uses to short-circuit
`sendRequest` calls to workers that have failed `failureThreshold`
consecutive times. The breaker transitions `closed → open` after the
failure budget is exhausted, `open → half-open` once `resetMs` has
elapsed, and either `closed` (on success) or back to `open` (on failure)
from half-open.
Adding a breaker bounds the blast radius of a flaky worker: instead of
every request waiting for the full timeout, the breaker rejects them
synchronously with `WEB_WORKERS.CIRCUIT_OPEN` and lets the cool-down do
its job. The base manager calls `beforeRequest` / `recordSuccess` /
`recordFailure` at the right inflection points so consumers get the
behaviour for free by adding `circuitBreaker: { ... }` to their
`WorkerConfig`.
## Features
- Three-state state machine: `closed`, `open`, `half-open`.
- Validates configuration (`failureThreshold > 0`, `resetMs >= 0`).
- `beforeRequest` throws `WebWorkersError(CIRCUIT_OPEN)` when open and
the cool-down hasn't elapsed; transitions to half-open when it has.
- `recordSuccess` closes a half-open breaker and resets the consecutive
failure counter.
- `recordFailure` re-opens a half-open breaker immediately, or opens a
closed breaker once the threshold is reached.
- Injectable `Clock` for deterministic tests.
## Usage: Basic Example
```ts
import { WorkerManager } from "@suaveplan/web-workers";
const manager = new WorkerManager({ factory });
manager.create({
name: "flaky",
url: WORKER_URL,
circuitBreaker: { failureThreshold: 3, resetMs: 30_000 },
});
```
## Usage: Advanced Example
```ts
import { CircuitBreaker } from "@suaveplan/web-workers";
class GuardedTransport {
private readonly breaker = new CircuitBreaker(
{ failureThreshold: 5, resetMs: 60_000 },
() => Date.now()
);
async call(op: () => Promise<unknown>): Promise<unknown> {
this.breaker.beforeRequest();
try {
const result = await op();
this.breaker.recordSuccess();
return result;
} catch (err) {
this.breaker.recordFailure();
throw err;
}
}
}
```
## API Reference
- `class CircuitBreaker`.
- `constructor(config: CircuitBreakerConfig, clock: Clock)`.
- `get current: CircuitState`.
- `beforeRequest(): void` — throws `WebWorkersError(CIRCUIT_OPEN)` when
open and cool-down not elapsed.
- `recordSuccess(): void`.
- `recordFailure(): void`.
- `type CircuitState = "closed" | "open" | "half-open"`.
## Implementation Notes
The breaker is single-instance per worker — the base manager creates it
in `create()` and holds it on the `ManagedEntry`. Failures only count
when consecutive, so a successful request mid-failure-window resets the
counter. The half-open state allows exactly one probe request through;
the next call to `recordFailure` re-opens the breaker AND resets the
cool-down timer so the next probe is `resetMs` away. `Clock` is injected
(rather than reading `Date.now` directly) so tests can advance time
deterministically without `vi.useFakeTimers()` machinery.

View File

@@ -0,0 +1,94 @@
# `errors`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`errors.ts` declares the stable error-code registry, typed error class, and
metadata table for `@suaveplan/web-workers`. Every failure path inside the
package raises a `WebWorkersError` carrying a `code` from the
`WEB_WORKERS_CODES` enum so consumers can classify failures programmatically
without parsing message strings.
The module is foundational: managers, the pool, the bus, the streaming RPC,
and the circuit breaker all import from here. Keeping the error contract in
one file is what lets us add new codes without forcing every consumer to
rewrite their catch blocks — they only need to widen their union over
`WebWorkersErrorCode`.
## Features
- 13 stable codes under the `WEB_WORKERS.*` namespace (defined via
`defineCodes`).
- `WebWorkersError` extends `SuaveplanError` for cause-chain traversal,
redaction, OTel span-event adapter, and Zod-validated wire format.
- Frozen `WEB_WORKERS_ERROR_METADATA` table mapping each code to
`{ httpStatus, severity, retryable, description }`.
- `asWebWorkersError(code, cause, fallback)` helper for `catch` sites where
the thrown value's type isn't statically known.
## Usage: Basic Example
```ts
import { WEB_WORKERS_CODES, WebWorkersError } from "@suaveplan/web-workers";
if (queue.length >= maxQueueDepth) {
throw new WebWorkersError(
WEB_WORKERS_CODES.QUEUE_FULL,
"WorkerPool queue is full"
);
}
```
## Usage: Advanced Example
```ts
import {
asWebWorkersError,
WEB_WORKERS_CODES,
WebWorkersError,
} from "@suaveplan/web-workers";
try {
await transport.flush();
} catch (cause) {
const err = asWebWorkersError(
WEB_WORKERS_CODES.WORKER_ERROR,
cause,
"Underlying transport flush failed"
);
if (err.metadata.retryable) {
await retry(transport);
} else {
throw err;
}
}
```
## API Reference
- `WEB_WORKERS_CODES` — frozen object whose values are the canonical string
codes. Types: `{ readonly DUPLICATE_WORKER: "WEB_WORKERS.DUPLICATE_WORKER"; ... }`.
- `WebWorkersErrorCode` — union of every code value.
- `WEB_WORKERS_ERROR_METADATA: Readonly<Record<WebWorkersErrorCode, WebWorkersCodeMetadata>>`.
- `WebWorkersCodeMetadata` — `{ httpStatus, severity, retryable, description }`.
- `class WebWorkersError extends SuaveplanError<WebWorkersErrorCode>` — base
error class. Has a `metadata` getter returning the table row.
- `asWebWorkersError(code, cause: unknown, fallback: string): WebWorkersError`
— lifts an unknown thrown value into a typed error.
## Implementation Notes
The metadata table is `Object.freeze`d at module load so consumers can rely
on it as an immutable lookup. The `description` field is intentionally
written in past tense + active voice (e.g. *"sendRequest timed out before
the worker responded"*) — that's the canonical phrasing the error logger
ships to consumers. Severity is `info` only for caller-initiated abort
(`REQUEST_ABORTED`, `STREAM_ABORTED`); everything else is `warning` or
`error`. `retryable: true` is set for transient conditions (timeout,
backpressure, restart) and `false` for invariant violations (duplicate
worker, missing handler, invalid transfer). Adding a new code requires
adding both a `defineCodes` entry and a `WEB_WORKERS_ERROR_METADATA` row;
TypeScript enforces the latter via `Record<WebWorkersErrorCode, ...>`.

View File

@@ -0,0 +1,88 @@
# `define-worker`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`define-worker.ts` exposes `defineWorker()`, a small helper that wraps the
canonical `new Worker(url, { type })` pattern in a reusable
`WorkerFactory`. The base manager accepts a factory rather than a
constructor so tests can inject in-process fakes; `defineWorker` is the
production factory that consumers wire into `WorkerManager` / `WorkerPool`.
Centralising this pattern keeps the Vite / Webpack `new URL('./worker.ts', import.meta.url)`
idiom in one place and removes the temptation to inline `new Worker(...)`
at call sites, which would defeat the factory-injection seam.
## Features
- Returns a `WorkerFactory` bound to a default URL and `type`.
- Per-invocation `type` override.
- Optional `workerConstructor` override for tests (substitute a
`MessageChannel`-backed fake, a Bun `Worker`, etc.).
- Clear error when `globalThis.Worker` is unavailable (Node.js without
the worker-threads polyfill, custom runtimes).
## Usage: Basic Example
```ts
import { defineWorker, WorkerManager } from "@suaveplan/web-workers";
const factory = defineWorker({
url: new URL("./encode.worker.ts", import.meta.url),
type: "module",
});
const manager = new WorkerManager({ factory });
manager.create({ name: "encode", url: "ignored-by-factory" });
```
## Usage: Advanced Example
```ts
import {
defineWorker,
type WorkerConstructorLike,
} from "@suaveplan/web-workers";
class TestWorker {
constructor(url: string | URL) {
this.url = url;
}
url: string | URL;
postMessage() {}
terminate() {}
addEventListener() {}
}
const factory = defineWorker({
url: "spec://test",
workerConstructor: TestWorker as unknown as WorkerConstructorLike,
});
const w = factory("spec://test");
```
## API Reference
- `defineWorker(opts: DefineWorkerOptions): WorkerFactory`.
- `interface DefineWorkerOptions { url; type?; workerConstructor? }`.
- `type WorkerConstructorLike = new (url, options?) => WorkerLike`.
## Implementation Notes
The factory accepts a `urlOverride` parameter so consumers can pass a
per-create URL to the same factory (the manager passes `config.url` here).
When the consumer constructs the factory with a default URL and the
manager passes a different URL, the per-invocation URL wins — that's what
lets a single factory drive multiple workers loaded from different
scripts. `workerConstructor` is an escape hatch for non-browser
environments and tests; the production path uses `globalThis.Worker`.
When that's undefined the helper throws an explicit
`Error("globalThis.Worker is undefined")` rather than the cryptic
`TypeError: undefined is not a constructor` that JavaScript would
otherwise produce — that diagnostic was specifically worth surfacing
because the failure mode is common in Node-based test runs without
appropriate polyfills.

View File

@@ -0,0 +1,107 @@
# `message-bus`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`message-bus.ts` declares `MessageBus`, an in-process publish/subscribe +
request/response message bus. It's the right tool when you want to
decouple modules without actually crossing a worker boundary — every
listener and handler runs synchronously on the main thread.
The bus complements the worker managers: managers handle cross-thread
RPC over `postMessage`, while the bus handles same-thread routing.
Modules that produce events (e.g. an UI store) publish to a topic, and
modules that consume them (e.g. an analytics pipeline) subscribe.
## Features
- Topic-based pub/sub: `subscribe(topic, handler)`, `subscribeOnce`,
`publish<T>(topic, payload)`, `broadcast<T>(payload)`.
- Request/response: `registerHandler(topic, handler)`, `request(topic, payload, options?)`
with `timeout` and `signal`.
- Returns delivery counts from `publish` / `broadcast` so callers know how
many subscribers received the message.
- `subscriberCount`, `getTopics` (only topics with live subscribers),
`clear` (drop everything + emit `bus:cleared`).
- `EventBus<MessageBusEventMap>` lifecycle event channel.
## Usage: Basic Example
```ts
import { MessageBus } from "@suaveplan/web-workers";
const bus = new MessageBus();
bus.subscribe<UserEvent>("user-login", (event) =>
analytics.track("login", event)
);
bus.publish("user-login", { userId, at: Date.now() });
```
## Usage: Advanced Example
```ts
import { MessageBus, WEB_WORKERS_CODES } from "@suaveplan/web-workers";
const bus = new MessageBus();
// Request handler with async work + abort support.
bus.registerHandler<SearchQuery, SearchResult>(
"search.execute",
async (query) => {
const docs = await searchIndex.query(query);
return { hits: docs.length, results: docs };
}
);
const ctrl = new AbortController();
try {
const result = await bus.request<SearchQuery, SearchResult>(
"search.execute",
{ text: "hello", filter: { lang: "en" } },
{ signal: ctrl.signal, timeout: 5_000 }
);
renderResults(result);
} catch (err) {
if (
err instanceof Error &&
(err as { code?: string }).code === WEB_WORKERS_CODES.REQUEST_TIMEOUT
) {
renderTimeout();
}
}
```
## API Reference
- `class MessageBus`.
- `constructor(options?: MessageBusOptions)`.
- `subscribe<T>(topic, handler): () => void`.
- `subscribeOnce<T>(topic, handler): () => void`.
- `publish<T>(topic, payload): number` (delivery count).
- `broadcast<T>(payload): number`.
- `registerHandler<TReq, TRes>(topic, handler)`.
- `request<TReq, TRes>(topic, payload, options?: BusRequestOptions): Promise<TRes>`.
- throws `WEB_WORKERS.HANDLER_NOT_FOUND`,
`WEB_WORKERS.REQUEST_TIMEOUT`, `WEB_WORKERS.REQUEST_ABORTED`.
- `subscriberCount(topic): number`, `getTopics(): string[]`,
`clear(): void`.
- `readonly events: EventBus<MessageBusEventMap>`.
- `type MessageHandler<T> = (payload: T, message: WorkerMessage<T>) => void`.
- `type RequestHandler<TReq, TRes> = (payload: TReq) => TRes | Promise<TRes>`.
- `interface BusRequestOptions { timeout?: number; signal?: AbortSignal }`.
## Implementation Notes
The bus uses two separate maps: `subscriptions` (per-topic array of
once/persistent handlers) and `requestHandlers` (single handler per topic).
This mirrors the upstream API so consumers can pub/sub and request/respond
without coupling the two channels. `subscribeOnce` is implemented by
marking the entry `once: true` and filtering it out on the publish loop —
the unsubscribe closure returned by `subscribeOnce` removes the entry
proactively if the consumer wants to cancel before the first publish.
`request` is `async` to keep the type contract clean, but the handler is
invoked synchronously inside the promise body so consumers can rely on
the handler running before the next microtask.

View File

@@ -0,0 +1,100 @@
# `observability`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`observability.ts` exposes `createWebWorkersObservability()`, the factory
that builds the OpenTelemetry counter + histogram bundle every manager and
pool consumes. The bundle is injected at construction time, which means a
single OTel meter/tracer pair drives every managed worker, every pool slot,
and every RPC in a process.
The package defaults to the no-op providers from `@opentelemetry/api` so
consumers who don't wire telemetry pay zero cost. When telemetry is wired,
metrics flow through the consumer's `MeterProvider` without further code
changes on the manager side.
## Features
- Six counters: messages sent / received, workers created / terminated,
restarts, errors.
- Three histograms: RPC latency (ms), queue depth, pool active workers.
- Single-pass construction: callers receive a `{ meter, tracer, counters, histograms }`
bundle and pass it to manager / pool constructors via the
`observability` option.
- Pass-through behaviour: a fully-formed bundle on
`WebWorkersObservabilityOptions.observability` short-circuits and returns
the same reference so consumers can share a single bundle across multiple
manager instances.
## Usage: Basic Example
```ts
import {
createWebWorkersObservability,
WorkerManager,
} from "@suaveplan/web-workers";
const obs = createWebWorkersObservability();
const manager = new WorkerManager({
factory: defineWorker({ url: WORKER_URL }),
observability: obs,
});
```
## Usage: Advanced Example
```ts
import { metrics } from "@opentelemetry/api";
import {
createWebWorkersObservability,
WorkerPool,
} from "@suaveplan/web-workers";
const meter = metrics.getMeter("my-app/render");
const obs = createWebWorkersObservability({ meter });
const renderPool = new WorkerPool(
{ url: RENDER_URL, size: 4, scheduler: "least-busy" },
{
factory: defineWorker({ url: RENDER_URL }),
observability: obs,
}
);
const audioPool = new WorkerPool(
{ url: AUDIO_URL, size: 2 },
{
factory: defineWorker({ url: AUDIO_URL }),
// share the same bundle so cross-pool metrics aggregate on a single
// meter
observability: obs,
}
);
```
## API Reference
- `createWebWorkersObservability(opts?: WebWorkersObservabilityOptions): WebWorkersObservability`
— builds a bundle on the supplied (or no-op) meter / tracer.
- `WebWorkersObservabilityOptions = { meter?, tracer?, observability? }`
— every field optional; `observability` short-circuits.
- `WebWorkersObservability = { meter, tracer, counters, histograms }`.
- `WebWorkersCounters` — `messagesSentTotal`, `messagesReceivedTotal`,
`workersCreatedTotal`, `workersTerminatedTotal`, `restartsTotal`,
`errorsTotal`.
- `WebWorkersHistograms` — `rpcLatencyMs`, `queueDepth`, `poolActiveWorkers`.
## Implementation Notes
Instruments are created via `createCounter` / `createHistogram` from
`@suaveplan/opentelemetry`, which guarantees consistent naming + unit
conventions across Suaveplan. Counter names use the
`web_workers_*_total` convention (Prometheus-compatible). Histograms use
ms-valued buckets for latency and unit-less counts for depth gauges. The
`tracer` field is exposed for consumers who want to wrap spans around
manager methods — none of the package's own code currently creates spans
since RPC calls would create one span per request which is rarely the
right granularity (consumers should span the higher-level operation).

View File

@@ -0,0 +1,115 @@
# `shared-worker-manager`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`shared-worker-manager.ts` declares `SharedWorkerManager`, the concrete
SharedWorker manager. It extends `BaseWorkerManager` with a transport
bundle (`{ worker, port }`) — `worker` is the `SharedWorkerLike` itself
(used for the cross-port `error` event) and `port` is the
`SharedWorkerPortLike` (`MessagePort`-shaped) used for `message` /
`messageerror` events and outbound `postMessage` calls.
The shared API surface (`create` / `terminate` / `postMessage` /
`sendRequest` / RPC options / lifecycle events) is identical to
`WorkerManager` — only the transport lifecycle differs. SharedWorkers
can't be `terminate()`d by a client; the closest equivalent is closing
the port, which lets the SharedWorker shut down when the last port closes.
## Features
- Injectable `SharedWorkerFactory` for tests.
- Calls `port.start()` exactly once on create to begin message delivery.
- Wires `message` and `messageerror` on the port, plus `error` on the
worker itself.
- Calls `port.close()` on terminate.
- Inherits the full RPC surface (timeout, signal, transfer, backpressure,
circuit breaker, OTel, EventBus).
## Usage: Basic Example
```ts
import { SharedWorkerManager } from "@suaveplan/web-workers";
const factory = (url: string | URL, opts?: { name?: string }) =>
new SharedWorker(url, { type: "module", name: opts?.name });
const manager = new SharedWorkerManager({ factory });
manager.create({ name: "global-state", url: SHARED_WORKER_URL });
const state = await manager.sendRequest<void, AppState>(
"global-state",
"get-state",
undefined
);
```
## Usage: Advanced Example
```ts
import {
createWebWorkersObservability,
SharedWorkerManager,
} from "@suaveplan/web-workers";
const obs = createWebWorkersObservability();
const manager = new SharedWorkerManager({
factory: (url, opts) =>
new SharedWorker(url, {
type: "module",
name: opts?.name,
}),
observability: obs,
});
manager.create({
name: "tab-coordination",
url: SHARED_URL,
timeout: 5_000,
maxPendingRequests: 32,
});
manager.events.on("message:received", ({ name, message }) => {
if (message.type === "tab-closed") forwardToUI(message.payload);
});
const ctrl = new AbortController();
window.addEventListener("beforeunload", () => ctrl.abort());
const ack = await manager.sendRequest<TabClaim, ClaimAck>(
"tab-coordination",
"claim-leader",
{ tabId },
{ signal: ctrl.signal }
);
```
## API Reference
- `interface SharedWorkerManagerOptions extends BaseManagerOptions`
— adds the required `factory: SharedWorkerFactory`.
- `class SharedWorkerManager extends BaseWorkerManager<SharedTransport>`
where `SharedTransport = { worker, port }`. Public surface identical to
`WorkerManager`.
- Protected overrides:
- `createTransport(config) → { worker, port }` — calls the factory,
wires the listeners, calls `port.start()`.
- `terminateTransport({ port }) → port.close()`.
- `postToTransport(transport, message, transfer)` — same transfer
handling as `WorkerManager`.
## Implementation Notes
SharedWorkers don't expose a synchronous `terminate()` — `port.close()` is
the closest available control. The SharedWorker process itself shuts down
when all client ports are closed (browser-implementation specific). The
`error` listener registers on the `SharedWorker` instance, not the port,
because that's where the spec sends `MessagePort`-unrelated errors (e.g.
script-load failures). `messageerror` lives on the port because it's
emitted when the structured-clone fails on the receiving side, which is
per-port. The transport bundle `{ worker, port }` exists because the base
manager needs a single opaque handle, but `createTransport` needs to call
methods on both the worker (for `error`) and the port (for `start`,
`postMessage`, `close`, `message`, `messageerror`).

View File

@@ -0,0 +1,107 @@
# `stream`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`stream.ts` exposes `sendStream`, a streaming RPC primitive that returns
an `AsyncIterable<TChunk>` driven by frames the worker pushes back to the
main thread. It complements the `sendRequest` request/response API by
letting workers stream partial results — useful for progressive renders,
chunked compression, log tails, or any long-running operation that
produces incremental output.
The protocol is intentionally minimal: the consumer posts an initial
envelope `{ streamId, payload }` to the worker; the worker pushes
`{ id: streamId, payload: { chunk } }` frames, and signals completion
with `{ id: streamId, payload: { done: true } }`. Aborting the consumer's
signal posts a `stream:cancel` frame to the worker and throws
`WEB_WORKERS.STREAM_ABORTED`.
## Features
- Async-iterable interface: `for await (const chunk of stream) ...`.
- `AbortSignal` cancellation: aborts both the iterator and posts a cancel
frame to the worker.
- Pre-aborted signal short-circuits with `WEB_WORKERS.STREAM_ABORTED` and
no initial message is posted.
- Frame correlation by stream id: foreign-worker frames and foreign-id
frames are ignored, so multiple concurrent streams over the same
manager coexist cleanly.
- Optional `idFactory` for deterministic ids in tests.
- Optional `transfer` list for zero-copy initial payload handoff.
## Usage: Basic Example
```ts
import { sendStream, WorkerManager } from "@suaveplan/web-workers";
const manager = new WorkerManager({ factory });
manager.create({ name: "tail", url: WORKER_URL });
for await (const line of sendStream<string, string>(
manager,
"tail",
"tail-log",
"/var/log/app.log"
)) {
appendLine(line);
}
```
## Usage: Advanced Example
```ts
import { sendStream, WEB_WORKERS_CODES } from "@suaveplan/web-workers";
const ctrl = new AbortController();
const buf = new ArrayBuffer(8 * 1024 * 1024);
const stream = sendStream<ArrayBuffer, ArrayBuffer>(
manager,
"compress",
"lz4-stream",
buf,
{ signal: ctrl.signal, transfer: [buf] }
);
const chunks: ArrayBuffer[] = [];
try {
for await (const chunk of stream) {
chunks.push(chunk);
if (cancelRequested) ctrl.abort();
}
} catch (err) {
if (
err instanceof Error &&
(err as { code?: string }).code === WEB_WORKERS_CODES.STREAM_ABORTED
) {
// expected when ctrl.abort() fires
} else {
throw err;
}
}
```
## API Reference
- `sendStream<TReq, TChunk>(manager, name, type, payload, options?): AsyncIterable<TChunk>`.
- `interface StreamCapableManager { events; postMessage<T>(name, type, payload, options?) }`.
- `interface StreamRequestEnvelope<TReq> { streamId: string; payload: TReq }`.
- `type StreamIdFactory = () => string`.
- `interface SendStreamOptions { signal?: AbortSignal; transfer?: Transferable[]; idFactory?: StreamIdFactory }`.
## Implementation Notes
The iterator uses an internal queue + `resolveNext` continuation pattern:
every received frame pushes onto the queue and calls `resolveNext` if the
consumer was waiting; the consumer's next-iteration loop drains the queue
and then awaits on a fresh promise. This avoids buffering the whole stream
in memory and gives the consumer natural backpressure (the consumer pulls).
The initial payload is wrapped in a `StreamRequestEnvelope` so the worker
sees both the `streamId` and the original payload — without that wrapper
the worker would have no way to correlate frame ids to incoming requests.
The cancel frame `stream:cancel` is sent via the same `postMessage` channel
so the worker side can stop producing without needing a separate control
channel.

View File

@@ -0,0 +1,115 @@
# `web-workers.types`
## Purpose
`web-workers.types.ts` is the zero-runtime type surface for the package. It
declares the structural shapes managers, pools, the bus, and the streaming
RPC depend on: `WorkerLike` / `SharedWorkerLike` / `SharedWorkerPortLike`
DOM-subset interfaces, the `WorkerFactory` / `SharedWorkerFactory` injection
seams, `WorkerConfig`, `WorkerPoolConfig`, RPC option bundles, and the two
`EventBus<TMap>` event maps emitted by managers and the bus.
Keeping every shared type in one file means consumers can import the type
contract via `@suaveplan/web-workers/types` without pulling in the manager
or pool implementations — useful for shared protocol definitions between a
host page and a worker entrypoint.
## Features
- DOM-subset interfaces (`WorkerLike`, `SharedWorkerLike`,
`SharedWorkerPortLike`) so tests can inject in-process fakes without
touching `globalThis.Worker`.
- Factory function types (`WorkerFactory`, `SharedWorkerFactory`) used by
every manager and pool.
- Configuration types (`WorkerConfig`, `WorkerPoolConfig`,
`CircuitBreakerConfig`).
- Option bundles for `postMessage` / `sendRequest` / `sendStream` /
`WorkerPool.submit` with `signal`, `timeout`, and `transfer` fields.
- Message and frame types (`WorkerMessage`, `StreamFrame`).
- Single-payload event maps (`WorkerManagerEventMap`, `MessageBusEventMap`)
matching the `EventBus<TMap>` contract.
## Usage: Basic Example
```ts
import type {
WorkerConfig,
WorkerFactory,
} from "@suaveplan/web-workers";
const factory: WorkerFactory = (url, opts) =>
new Worker(url, { type: opts?.type ?? "module" });
const config: WorkerConfig = {
name: "encode",
url: new URL("./encode.worker.ts", import.meta.url),
timeout: 5_000,
};
```
## Usage: Advanced Example
```ts
import type {
SendRequestOptions,
WorkerManagerEventMap,
} from "@suaveplan/web-workers/types";
import type { EventBus } from "@suaveplan/events";
interface ChannelLike {
readonly events: EventBus<WorkerManagerEventMap>;
send<T>(
name: string,
type: string,
payload: T,
opts: SendRequestOptions
): Promise<unknown>;
}
function withCancellation(
channel: ChannelLike,
name: string,
payload: unknown,
timeoutMs: number,
signal: AbortSignal
): Promise<unknown> {
return channel.send(name, "compute", payload, {
timeout: timeoutMs,
signal,
});
}
```
## API Reference
- `WorkerLike`, `SharedWorkerLike`, `SharedWorkerPortLike` — structural
subsets of the DOM types.
- `WorkerFactory = (url, opts?) => WorkerLike`,
`SharedWorkerFactory = (url, opts?) => SharedWorkerLike`.
- `WorkerStatus = "idle" | "busy" | "error" | "terminated"`.
- `CircuitBreakerConfig = { failureThreshold: number; resetMs: number }`.
- `WorkerConfig` — `{ name, url, type?, timeout?, maxPendingRequests?, circuitBreaker? }`.
- `WorkerPoolConfig` — see source; carries pool size, scheduler, queue
budget, restart budget, health-check probe.
- `PostMessageOptions = { transfer?: Transferable[] }`,
`SendRequestOptions = PostMessageOptions & { timeout?, signal? }`,
`SendStreamOptions = PostMessageOptions & { signal? }`,
`SubmitOptions = { timeout?, signal?, transfer? }`.
- `WorkerMessage<T>` — `{ type, payload, id, timestamp, sequence? }`.
- `StreamFrame<TChunk>` — `{ id, type, payload: { chunk } | { done: true }, timestamp, sequence? }`.
- `WorkerManagerEventMap` — covers `worker:created`, `worker:terminated`,
`worker:terminated:all`, `worker:error`, `worker:restarted`, `message:sent`,
`message:received`, `request:timeout`, `request:fulfilled`.
- `MessageBusEventMap` — `message:published`, `subscription:added`,
`subscription:removed`, `bus:cleared`.
## Implementation Notes
The DOM-subset interfaces deliberately drop everything the package doesn't
use (`onmessage`, `dispatchEvent`, etc.) so any in-process fake stays cheap.
Event maps carry a `[key: string]: unknown` index signature so they satisfy
the `EventMap = Record<string, unknown>` constraint of
`@suaveplan/events`'s `EventBus<TMap>`. Every event payload is a single
object — that's a deliberate change from the upstream variadic-args event
map, since the `EventBus.on` listener signature is
`(payload: TMap[K]) => void` (single payload, not spread).

View File

@@ -0,0 +1,117 @@
# `worker-manager`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`worker-manager.ts` declares `WorkerManager`, the concrete classic-`Worker`
manager. It extends `BaseWorkerManager<WorkerLike>` and implements the
three transport hooks — `createTransport` (calls the injected
`WorkerFactory` and wires `message` / `error` / `messageerror` listeners),
`terminateTransport` (calls `worker.terminate()`), and `postToTransport`
(calls `postMessage` with the optional transfer list).
Every cross-cutting concern (pending-request map, AbortSignal, transfer
validation, backpressure, circuit breaker, OTel, EventBus emission) lives
in the base class — `WorkerManager` is therefore a thin transport shim that
keeps the lifecycle for `Worker` correct.
## Features
- Injectable `WorkerFactory` (test fakes substitute trivially).
- Wires `message`, `error`, and `messageerror` events to the base manager's
`handleMessage` / `handleError`.
- Falls back to `"Worker error"` / `"Worker messageerror"` when the event
carries no message (some browsers strip it for cross-origin workers).
- Inherits everything from `BaseWorkerManager` including `postMessage`,
`sendRequest`, `terminate`, `terminateAll`, lifecycle events, OTel.
## Usage: Basic Example
```ts
import { defineWorker, WorkerManager } from "@suaveplan/web-workers";
const factory = defineWorker({
url: new URL("./encode.worker.ts", import.meta.url),
type: "module",
});
const manager = new WorkerManager({ factory });
manager.create({ name: "encode", url: "ignored-by-factory" });
const out = await manager.sendRequest<string, ArrayBuffer>(
"encode",
"encode-frame",
"hello"
);
manager.terminateAll();
```
## Usage: Advanced Example
```ts
import {
defineWorker,
WorkerManager,
createWebWorkersObservability,
} from "@suaveplan/web-workers";
const obs = createWebWorkersObservability();
const factory = defineWorker({ url: WORKER_URL });
const manager = new WorkerManager({ factory, observability: obs });
manager.events.on("worker:error", ({ name, error }) => {
console.warn(`worker ${name} crashed`, error);
});
const ctrl = new AbortController();
manager.create({
name: "compute",
url: WORKER_URL,
timeout: 10_000,
maxPendingRequests: 64,
circuitBreaker: { failureThreshold: 3, resetMs: 30_000 },
});
const buffer = new ArrayBuffer(4 * 1024 * 1024);
const result = await manager.sendRequest<ArrayBuffer, ArrayBuffer>(
"compute",
"transform",
buffer,
{
transfer: [buffer],
signal: ctrl.signal,
timeout: 5_000,
}
);
```
## API Reference
- `interface WorkerManagerOptions extends BaseManagerOptions`
— adds the required `factory: WorkerFactory`.
- `class WorkerManager extends BaseWorkerManager<WorkerLike>`.
- `constructor(options: WorkerManagerOptions)`.
- Inherits the full base API (`create`, `terminate`, `terminateAll`,
`postMessage`, `sendRequest`, `has`, `getStatus`, `getWorkerNames`,
`onError`, `events`).
- Protected overrides (not part of the public API):
- `createTransport(config) → WorkerLike` — calls the factory, wires
listeners.
- `terminateTransport(worker)` — `worker.terminate()`.
- `postToTransport(worker, message, transfer)` — sends with or without
the transfer list.
## Implementation Notes
The `error` and `messageerror` listeners both lift their event's `message`
field through a `|| "fallback"` so a missing message string still produces
a meaningful `Error`. The `||` (over `??`) catches empty strings, which
some browsers emit for cross-origin worker scripts (the spec leaves the
exact value implementation-defined). `postToTransport` short-circuits when
`transfer` is undefined or empty so the underlying `postMessage` is called
with one argument — passing `undefined` or `[]` as the second arg works in
most browsers but happy-dom rejects `[]` with `Failed to execute postMessage`,
so the split is required for tests to run cleanly under the happy-dom
environment.

View File

@@ -0,0 +1,76 @@
# `internals`
## Purpose
`internals.ts` holds the small internal types and helpers `worker-pool.ts`
needs but that don't make sense to surface in the public API: the
`PoolSlot` bookkeeping shape, the `QueuedTask` entry used while tasks
wait for a free worker, and the `slotConfig` helper that materialises a
`WorkerConfig` for a slot from the `WorkerPoolConfig`.
Splitting these out keeps the main pool file under the 300-line ceiling
and makes the internal shapes independently importable from a sibling
helper file or future selector.
## Features
- `PoolSlot` — per-slot mutable record: `name`, `restarts`, `pendingCount`.
- `QueuedTask` — pending submit waiting for a worker: `type`, `payload`,
`options`, `resolve`, `reject`.
- `slotConfig(poolConfig, name, defaultTimeoutMs)` — builds a complete
`WorkerConfig` while respecting `exactOptionalPropertyTypes` (no
`undefined`-valued keys leak through).
## Usage: Basic Example
```ts
import { slotConfig } from "@suaveplan/web-workers";
const cfg = slotConfig(
{ url: WORKER_URL, size: 4, taskTimeout: 10_000 },
"pool-0-abc",
30_000
);
// cfg === { name: "pool-0-abc", url: WORKER_URL, timeout: 10_000 }
```
## Usage: Advanced Example
```ts
import { slotConfig } from "@suaveplan/web-workers";
const cfg = slotConfig(
{
url: WORKER_URL,
size: 2,
type: "module",
maxPendingRequests: 64,
circuitBreaker: { failureThreshold: 3, resetMs: 30_000 },
},
"pool-1-xyz",
30_000
);
// cfg.type === "module"
// cfg.maxPendingRequests === 64
// cfg.circuitBreaker is the same object reference
```
## API Reference
- `interface PoolSlot { readonly name: string; restarts: number; pendingCount: number }`.
- `interface QueuedTask { type, payload, options, resolve, reject }`.
- `slotConfig(poolConfig: WorkerPoolConfig, name: string, defaultTimeoutMs: number): WorkerConfig`.
## Implementation Notes
`slotConfig` builds the target object incrementally rather than via
conditional-spread syntax (`...(x !== undefined ? { x } : {})`) because
the spread version flagged uncovered branches in istanbul that couldn't
be exercised once the spread expanded to an empty object. The incremental
`if (x !== undefined) cfg.x = x` form has the same semantics under
`exactOptionalPropertyTypes` (the key is absent rather than
`undefined`-valued) but every branch is reachable from a single test that
exercises the optional config field. The cast `-readonly [K in keyof WorkerConfig]: WorkerConfig[K]`
is used to allow incremental assignment of `readonly` fields on the
local variable, then the value is widened back to `WorkerConfig` at
return.

View File

@@ -0,0 +1,76 @@
# `scheduler`
## Purpose
`scheduler.ts` exposes `pickSlot`, the pure function `WorkerPool` calls to
decide which slot receives the next submit. The function takes a
projection of the pool's slots (only the data the scheduler needs:
`name` + `pendingCount`) and the strategy name, and returns the chosen
slot plus the next round-robin index.
Isolating the scheduling decision in a pure function makes it independently
testable, swappable (a future scheduler can be added without touching the
pool), and free of side effects.
## Features
- Two scheduling strategies: `round-robin` and `least-busy`.
- `SchedulerSlot` projection — lightweight view of the pool's slots so the
scheduler doesn't depend on `WorkerPool`'s internals.
- Returns `undefined` when the slot list is empty, letting the caller
decide what to do (typically reject with `RESTART_EXHAUSTED`).
- Round-robin returns the next index so the caller can persist it.
## Usage: Basic Example
```ts
import { pickSlot } from "@suaveplan/web-workers";
const slots = [
{ name: "w0", pendingCount: 0 },
{ name: "w1", pendingCount: 0 },
];
const pick = pickSlot("round-robin", slots, 0);
// pick.slot.name === "w0", pick.nextIndex === 1
```
## Usage: Advanced Example
```ts
import {
pickSlot,
type SchedulerSlot,
type SchedulerStrategy,
} from "@suaveplan/web-workers";
class CustomPool {
private slots: Array<{ name: string; pendingCount: number }> = [];
private nextRR = 0;
pick(strategy: SchedulerStrategy): SchedulerSlot | undefined {
const projection = this.slots.map((s) => ({
name: s.name,
pendingCount: s.pendingCount,
}));
const result = pickSlot(strategy, projection, this.nextRR);
if (result === undefined) return undefined;
this.nextRR = result.nextIndex;
return result.slot;
}
}
```
## API Reference
- `type SchedulerStrategy = "round-robin" | "least-busy"`.
- `interface SchedulerSlot { name: string; pendingCount: number }`.
- `pickSlot(strategy, slots, nextRoundRobinIndex): { slot, nextIndex } | undefined`.
## Implementation Notes
`round-robin` uses `nextRoundRobinIndex % slots.length` so the caller can
keep a monotonically-increasing counter without worrying about wraparound.
`least-busy` linear-scans the slot list; ties go to the lowest-index slot
(deterministic). The pure-function shape means the same slot projection
always returns the same result — no hidden state, no randomness. For
large pools (> 100 slots) a heap-based least-busy could be faster, but
the typical pool size (4–16 slots) makes the linear scan negligible.

View File

@@ -0,0 +1,120 @@
# `worker-pool`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`worker-pool.ts` declares `WorkerPool`, the fixed-size pool that fans
incoming tasks across multiple `Worker` slots with FIFO queueing,
round-robin or least-busy scheduling, restart-on-crash supervision,
optional health probing, and queue-depth backpressure. It delegates
per-slot RPC state to an internal `WorkerManager` so every slot inherits
the manager's AbortSignal / transfer-list / backpressure / circuit-breaker
semantics.
The pool is the right tool when you want N parallel workers running the
same code (e.g. image compression, hash computation, parsing) and need
the scheduler to balance work across them. For a single named worker
managed in isolation, use `WorkerManager` directly.
## Features
- Configurable size; spawns workers eagerly at construction.
- Two scheduling strategies: `round-robin` (default, fair, deterministic)
and `least-busy` (better under skewed task durations).
- FIFO task queue when `maxConcurrent` is reached.
- Queue overflow rejects with `WEB_WORKERS.QUEUE_FULL`.
- Restart-on-crash: a crashed worker is terminated, the slot's restart
counter increments, a replacement is created. Exceeding `maxRestarts`
removes the slot and rejects pending tasks with `RESTART_EXHAUSTED`.
- Optional health probe (`healthCheck` returning `false` triggers a
restart) on a configurable interval.
- OTel `web_workers_queue_depth` + `web_workers_pool_active_workers`
histograms observed on every submit.
- Forwarded `events: EventBus<WorkerManagerEventMap>` from the internal
manager (so consumers can listen to `worker:restarted`, `worker:error`,
`request:fulfilled`, etc.).
## Usage: Basic Example
```ts
import { defineWorker, WorkerPool } from "@suaveplan/web-workers";
const pool = new WorkerPool(
{ url: WORKER_URL, size: 4 },
{ factory: defineWorker({ url: WORKER_URL }) }
);
const results = await Promise.all(
inputs.map((input) => pool.submit<Input, Output>("transform", input))
);
pool.terminate();
```
## Usage: Advanced Example
```ts
import {
createWebWorkersObservability,
defineWorker,
WorkerPool,
} from "@suaveplan/web-workers";
const pool = new WorkerPool(
{
url: ENCODE_URL,
size: 8,
scheduler: "least-busy",
taskTimeout: 30_000,
maxConcurrent: 8,
maxQueueDepth: 256,
maxRestarts: 3,
healthCheck: (name) => probeWorkerLiveness(name),
healthCheckIntervalMs: 60_000,
circuitBreaker: { failureThreshold: 5, resetMs: 30_000 },
},
{
factory: defineWorker({ url: ENCODE_URL }),
observability: createWebWorkersObservability(),
}
);
pool.events.on("worker:restarted", ({ name, attempt }) => {
console.warn(`pool slot ${name} restarted (attempt ${attempt})`);
});
const ctrl = new AbortController();
const buffer = new ArrayBuffer(BIG);
const encoded = await pool.submit<ArrayBuffer, ArrayBuffer>(
"encode",
buffer,
{ signal: ctrl.signal, transfer: [buffer] }
);
```
## API Reference
- `interface WorkerPoolOptions = { factory, clock?, idGenerator?, observability? }`.
- `class WorkerPool`.
- `constructor(config: WorkerPoolConfig, opts: WorkerPoolOptions)`.
- `submit<TReq, TRes>(type, payload, options?: SubmitOptions): Promise<TRes>`.
- `size(): number`, `activeCount(): number`, `queueDepth(): number`.
- `terminate(): void`.
- `readonly events: EventBus<WorkerManagerEventMap>`.
## Implementation Notes
The pool holds a private `WorkerManager` and a parallel `slots` array. The
`slots` array tracks restart counts and pending counts that
the manager doesn't expose directly. When a worker crashes, the manager's
`onError` handler routes the event to the pool's `handleSlotError`, which
either restarts or removes the slot. Restart uses the same `name` so
in-flight events from before the crash are still routed (the new instance
replaces the old slot identity). The internal `terminateIfPresent` helper
catches `WORKER_NOT_FOUND` from the manager so concurrent restart races
don't crash the supervisor. `dispatch` builds a `reqOpts` object via
conditional property assignment (not spread) so it plays cleanly with
`exactOptionalPropertyTypes`. `drainQueue` runs after every `finally` to
admit queued tasks as workers free up.