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:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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, ...>`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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`).
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user