Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 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,122 @@
{
"name": "@suaveplan/web-workers",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"browser": "./dist/index.js",
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/errors.d.ts",
"default": "./dist/errors/errors.js"
}
},
"./types": {
"import": {
"types": "./dist/types/web-workers.types.d.ts",
"default": "./dist/types/web-workers.types.js"
}
},
"./observability": {
"import": {
"types": "./dist/observability/observability.d.ts",
"default": "./dist/observability/observability.js"
}
},
"./base-manager": {
"import": {
"types": "./dist/base-manager/base-manager.d.ts",
"default": "./dist/base-manager/base-manager.js"
}
},
"./manager": {
"import": {
"types": "./dist/worker-manager/worker-manager.d.ts",
"default": "./dist/worker-manager/worker-manager.js"
}
},
"./shared-manager": {
"import": {
"types": "./dist/shared-worker-manager/shared-worker-manager.d.ts",
"default": "./dist/shared-worker-manager/shared-worker-manager.js"
}
},
"./pool": {
"import": {
"types": "./dist/worker-pool/worker-pool.d.ts",
"default": "./dist/worker-pool/worker-pool.js"
}
},
"./message-bus": {
"import": {
"types": "./dist/message-bus/message-bus.d.ts",
"default": "./dist/message-bus/message-bus.js"
}
},
"./streaming": {
"import": {
"types": "./dist/streaming/stream.d.ts",
"default": "./dist/streaming/stream.js"
}
},
"./circuit-breaker": {
"import": {
"types": "./dist/circuit-breaker/circuit-breaker.d.ts",
"default": "./dist/circuit-breaker/circuit-breaker.js"
}
},
"./helpers/define-worker": {
"import": {
"types": "./dist/helpers/define-worker.d.ts",
"default": "./dist/helpers/define-worker.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/opentelemetry": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"happy-dom": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
}
}

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,63 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
defaultClock,
defaultIdGenerator,
validateTransfer,
wireAbortSignal,
} from "./helpers.js";
describe("base-manager helpers", () => {
it("defaultClock returns a number", { timeout: 5000 }, () => {
expect(typeof defaultClock()).toBe("number");
});
it("defaultIdGenerator returns 16-hex string", { timeout: 5000 }, () => {
const id = defaultIdGenerator();
expect(id).toMatch(/^[0-9a-f]{16}$/);
});
it("validateTransfer passes undefined", { timeout: 5000 }, () => {
expect(validateTransfer(undefined)).toBeUndefined();
});
it("validateTransfer passes array", { timeout: 5000 }, () => {
const buf = new ArrayBuffer(1);
expect(validateTransfer([buf])).toEqual([buf]);
});
it("validateTransfer rejects non-array", { timeout: 5000 }, () => {
expect(() =>
validateTransfer("bad" as unknown as Transferable[])
).toThrow();
});
it("wireAbortSignal undefined returns undefined", {
timeout: 5000,
}, () => {
expect(wireAbortSignal(undefined, () => {})).toBeUndefined();
});
it("wireAbortSignal fires on abort", { timeout: 5000 }, () => {
let fired = false;
const ctrl = new AbortController();
const cleanup = wireAbortSignal(ctrl.signal, () => {
fired = true;
});
ctrl.abort();
expect(fired).toBe(true);
cleanup?.();
});
it("wireAbortSignal cleanup removes listener", {
timeout: 5000,
}, () => {
let fired = false;
const ctrl = new AbortController();
const cleanup = wireAbortSignal(ctrl.signal, () => {
fired = true;
});
cleanup?.();
ctrl.abort();
expect(fired).toBe(false);
});
});

View File

@@ -0,0 +1,292 @@
import { EventBus } from "@suaveplan/events";
import type { Clock, IdGenerator } from "@suaveplan/types";
import { CircuitBreaker } from "../circuit-breaker/circuit-breaker.js";
import { WEB_WORKERS_CODES, WebWorkersError } from "../errors/errors.js";
import { createWebWorkersObservability } from "../observability/observability.js";
import type { WebWorkersObservability } from "../observability/observability.types.js";
import type {
PostMessageOptions,
SendRequestOptions,
WorkerConfig,
WorkerManagerEventMap,
WorkerMessage,
WorkerStatus,
} from "../types/web-workers.types.js";
import {
type BaseManagerOptions,
DEFAULT_MAX_PENDING_REQUESTS,
DEFAULT_TIMEOUT_MS,
} from "./base-manager.types.js";
import {
defaultClock,
defaultIdGenerator,
validateTransfer,
wireAbortSignal,
} from "./helpers.js";
/** Internal lookup entry for a managed worker. */
export interface ManagedEntry<TTransport> {
readonly transport: TTransport;
readonly config: WorkerConfig;
status: WorkerStatus;
readonly pending: Map<string, PendingEntry>;
readonly breaker: CircuitBreaker | undefined;
}
interface PendingEntry {
readonly type: string;
readonly startedAt: number;
readonly timer: ReturnType<typeof setTimeout>;
readonly resolve: (value: unknown) => void;
readonly reject: (error: Error) => void;
readonly cleanupSignal: (() => void) | undefined;
}
/** Shared lifecycle + RPC state for the concrete managers. */
export abstract class BaseWorkerManager<TTransport> {
protected readonly workers = new Map<string, ManagedEntry<TTransport>>();
protected readonly errorHandlers: Array<
(name: string, error: Error) => void
> = [];
protected readonly clock: Clock;
protected readonly idGenerator: IdGenerator;
protected readonly obs: WebWorkersObservability;
/** Event emitter for worker lifecycle events. */
public readonly events = new EventBus<WorkerManagerEventMap>();
protected constructor(options: BaseManagerOptions) {
this.clock = options.clock ?? defaultClock;
this.idGenerator = options.idGenerator ?? defaultIdGenerator;
this.obs = options.observability ?? createWebWorkersObservability();
}
protected abstract createTransport(config: WorkerConfig): TTransport;
protected abstract terminateTransport(transport: TTransport): void;
protected abstract postToTransport(
transport: TTransport,
message: unknown,
transfer: Transferable[] | undefined
): void;
public create(config: WorkerConfig): void {
if (this.workers.has(config.name)) {
throw new WebWorkersError(
WEB_WORKERS_CODES.DUPLICATE_WORKER,
`Worker "${config.name}" already exists`
);
}
const transport = this.createTransport(config);
const breaker =
config.circuitBreaker !== undefined
? new CircuitBreaker(config.circuitBreaker, this.clock)
: undefined;
this.workers.set(config.name, {
transport,
config,
status: "idle",
pending: new Map(),
breaker,
});
this.obs.counters.workersCreatedTotal.add(1);
this.events.emit("worker:created", { name: config.name, config });
}
public terminate(name: string): void {
const entry = this.getEntry(name);
this.terminateTransport(entry.transport);
for (const [, pending] of entry.pending) {
clearTimeout(pending.timer);
pending.cleanupSignal?.();
pending.reject(
new WebWorkersError(
WEB_WORKERS_CODES.WORKER_TERMINATED,
`Worker "${name}" terminated while request was pending`
)
);
}
entry.pending.clear();
entry.status = "terminated";
this.workers.delete(name);
this.obs.counters.workersTerminatedTotal.add(1);
this.events.emit("worker:terminated", { name });
}
public terminateAll(): void {
const names = [...this.workers.keys()];
for (const name of names) this.terminate(name);
this.events.emit("worker:terminated:all", { count: names.length });
}
public postMessage<T>(
name: string,
type: string,
payload: T,
options?: PostMessageOptions
): void {
const transfer = validateTransfer(options?.transfer);
const entry = this.getEntry(name);
const message: WorkerMessage<T> = {
type,
payload,
id: this.idGenerator(),
timestamp: this.clock(),
};
this.postToTransport(entry.transport, message, transfer);
this.obs.counters.messagesSentTotal.add(1);
this.events.emit("message:sent", { name, type });
}
public sendRequest<TReq, TRes>(
name: string,
type: string,
payload: TReq,
options?: SendRequestOptions
): Promise<TRes> {
const transfer = validateTransfer(options?.transfer);
const entry = this.getEntry(name);
entry.breaker?.beforeRequest();
const maxPending =
entry.config.maxPendingRequests ?? DEFAULT_MAX_PENDING_REQUESTS;
if (entry.pending.size >= maxPending) {
throw new WebWorkersError(
WEB_WORKERS_CODES.BACKPRESSURE,
`Worker "${name}" pending-request map is full (${maxPending})`
);
}
const signal = options?.signal;
if (signal?.aborted === true) {
return Promise.reject(
new WebWorkersError(
WEB_WORKERS_CODES.REQUEST_ABORTED,
`sendRequest "${type}" aborted before dispatch`
)
);
}
return this.dispatchRequest<TReq, TRes>(
entry,
type,
payload,
options?.timeout,
signal,
transfer
);
}
private dispatchRequest<TReq, TRes>(
entry: ManagedEntry<TTransport>,
type: string,
payload: TReq,
timeoutOverride: number | undefined,
signal: AbortSignal | undefined,
transfer: Transferable[] | undefined
): Promise<TRes> {
const id = this.idGenerator();
const timeoutMs =
timeoutOverride ?? entry.config.timeout ?? DEFAULT_TIMEOUT_MS;
const startedAt = this.clock();
const name = entry.config.name;
return new Promise<TRes>((resolve, reject) => {
const timer = setTimeout(() => {
entry.pending.delete(id);
entry.breaker?.recordFailure();
this.events.emit("request:timeout", { name, type, id });
reject(
new WebWorkersError(
WEB_WORKERS_CODES.REQUEST_TIMEOUT,
`Request "${type}" timed out after ${timeoutMs}ms`
)
);
}, timeoutMs);
const cleanupSignal = wireAbortSignal(signal, () => {
entry.pending.delete(id);
clearTimeout(timer);
reject(
new WebWorkersError(
WEB_WORKERS_CODES.REQUEST_ABORTED,
`sendRequest "${type}" aborted`
)
);
});
entry.pending.set(id, {
type,
startedAt,
timer,
resolve: resolve as (v: unknown) => void,
reject,
cleanupSignal,
});
entry.status = "busy";
const message: WorkerMessage<TReq> = {
type,
payload,
id,
timestamp: startedAt,
};
this.postToTransport(entry.transport, message, transfer);
this.obs.counters.messagesSentTotal.add(1);
this.events.emit("message:sent", { name, type });
});
}
public has(name: string): boolean {
return this.workers.has(name);
}
public getStatus(name: string): WorkerStatus {
return this.getEntry(name).status;
}
public getWorkerNames(): string[] {
return [...this.workers.keys()];
}
public onError(handler: (name: string, error: Error) => void): void {
this.errorHandlers.push(handler);
}
protected getEntry(name: string): ManagedEntry<TTransport> {
const entry = this.workers.get(name);
if (entry === undefined) {
throw new WebWorkersError(
WEB_WORKERS_CODES.WORKER_NOT_FOUND,
`Worker "${name}" not found`
);
}
return entry;
}
protected handleMessage(name: string, data: WorkerMessage): void {
const entry = this.workers.get(name);
if (entry === undefined) return;
this.obs.counters.messagesReceivedTotal.add(1);
this.events.emit("message:received", { name, message: data });
const pending = entry.pending.get(data.id);
if (pending === undefined) return;
clearTimeout(pending.timer);
pending.cleanupSignal?.();
entry.pending.delete(data.id);
entry.status = entry.pending.size === 0 ? "idle" : "busy";
const latency = this.clock() - pending.startedAt;
this.obs.histograms.rpcLatencyMs.record(latency);
entry.breaker?.recordSuccess();
this.events.emit("request:fulfilled", {
name,
type: pending.type,
id: data.id,
latencyMs: latency,
});
pending.resolve(data.payload);
}
protected handleError(name: string, error: Error): void {
const entry = this.workers.get(name);
if (entry !== undefined) {
entry.status = "error";
entry.breaker?.recordFailure();
}
this.obs.counters.errorsTotal.add(1);
this.events.emit("worker:error", { name, error });
for (const handler of this.errorHandlers) handler(name, error);
}
}

View File

@@ -0,0 +1,15 @@
import type { Clock, IdGenerator } from "@suaveplan/types";
import type { WebWorkersObservability } from "../observability/observability.types.js";
/** Options shared by `WorkerManager` and `SharedWorkerManager`. */
export interface BaseManagerOptions {
readonly clock?: Clock;
readonly idGenerator?: IdGenerator;
readonly observability?: WebWorkersObservability;
}
/** Default ceiling on the per-worker pending-request map. */
export const DEFAULT_MAX_PENDING_REQUESTS = 1024;
/** Default RPC timeout in milliseconds. */
export const DEFAULT_TIMEOUT_MS = 30_000;

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,41 @@
import type { Clock, IdGenerator } from "@suaveplan/types";
import { WEB_WORKERS_CODES, WebWorkersError } from "../errors/errors.js";
/** Default wall-clock implementation when none is injected. */
export const defaultClock: Clock = () => Date.now();
/** Default id generator using `crypto.getRandomValues` for 64 bits of entropy. */
export const defaultIdGenerator: IdGenerator = () => {
const array = new Uint8Array(8);
crypto.getRandomValues(array);
return Array.from(array, (b) => b.toString(16).padStart(2, "0")).join("");
};
/** Validate a transfer list and throw {@link WebWorkersError} on a non-array. */
export function validateTransfer(
transfer: Transferable[] | undefined
): Transferable[] | undefined {
if (transfer === undefined) return undefined;
if (!Array.isArray(transfer)) {
throw new WebWorkersError(
WEB_WORKERS_CODES.INVALID_TRANSFER,
"transfer must be an array of Transferable values"
);
}
return transfer;
}
/**
* Wire a one-shot abort listener on the supplied signal. Returns a cleanup
* function that removes the listener, or `undefined` when no signal is
* supplied.
*/
export function wireAbortSignal(
signal: AbortSignal | undefined,
onAbort: () => void
): (() => void) | undefined {
if (signal === undefined) return undefined;
const listener = (): void => onAbort();
signal.addEventListener("abort", listener, { once: true });
return () => signal.removeEventListener("abort", listener);
}

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 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WEB_WORKERS_CODES, type WebWorkersError } from "../errors/errors.js";
import { CircuitBreaker } from "./circuit-breaker.js";
function makeClock() {
let now = 0;
return {
clock: () => now,
advance: (ms: number) => {
now += ms;
},
};
}
describe("CircuitBreaker", () => {
it("validates failureThreshold", { timeout: 5000 }, () => {
const { clock } = makeClock();
expect(
() =>
new CircuitBreaker({ failureThreshold: 0, resetMs: 100 }, clock)
).toThrow();
});
it("validates resetMs", { timeout: 5000 }, () => {
const { clock } = makeClock();
expect(
() =>
new CircuitBreaker({ failureThreshold: 2, resetMs: -1 }, clock)
).toThrow();
});
it("opens after threshold and rejects", { timeout: 5000 }, () => {
const { clock } = makeClock();
const cb = new CircuitBreaker(
{ failureThreshold: 2, resetMs: 1000 },
clock
);
cb.beforeRequest();
cb.recordFailure();
cb.recordFailure();
expect(cb.current).toBe("open");
try {
cb.beforeRequest();
throw new Error("expected throw");
} catch (e) {
expect((e as WebWorkersError).code).toBe(
WEB_WORKERS_CODES.CIRCUIT_OPEN
);
}
});
it("half-open success closes breaker", { timeout: 5000 }, () => {
const { clock, advance } = makeClock();
const cb = new CircuitBreaker(
{ failureThreshold: 1, resetMs: 100 },
clock
);
cb.recordFailure();
expect(cb.current).toBe("open");
advance(150);
cb.beforeRequest();
expect(cb.current).toBe("half-open");
cb.recordSuccess();
expect(cb.current).toBe("closed");
});
it("half-open failure reopens breaker", { timeout: 5000 }, () => {
const { clock, advance } = makeClock();
const cb = new CircuitBreaker(
{ failureThreshold: 1, resetMs: 100 },
clock
);
cb.recordFailure();
advance(150);
cb.beforeRequest();
cb.recordFailure();
expect(cb.current).toBe("open");
});
it("recordSuccess resets consecutive count", {
timeout: 5000,
}, () => {
const { clock } = makeClock();
const cb = new CircuitBreaker(
{ failureThreshold: 3, resetMs: 1000 },
clock
);
cb.recordFailure();
cb.recordSuccess();
cb.recordFailure();
cb.recordFailure();
expect(cb.current).toBe("closed");
});
});

View File

@@ -0,0 +1,86 @@
import type { Clock } from "@suaveplan/types";
import { WEB_WORKERS_CODES, WebWorkersError } from "../errors/errors.js";
import type { CircuitBreakerConfig } from "../types/web-workers.types.js";
/** Internal state machine state for a {@link CircuitBreaker}. */
export type CircuitState = "closed" | "open" | "half-open";
/**
* Per-worker failure-isolating circuit breaker. Transitions:
*
* - `closed` → `open` after `failureThreshold` consecutive failures.
* - `open` → `half-open` after `resetMs` since opening.
* - `half-open` → `closed` on a single success.
* - `half-open` → `open` on a single failure (resets the cool-down timer).
*
* Exposed as a small class rather than a function bundle so the base worker
* manager can hold one instance per worker name and call `beforeRequest()` /
* `recordSuccess()` / `recordFailure()` synchronously without sharing state
* across workers.
*/
export class CircuitBreaker {
private readonly config: CircuitBreakerConfig;
private readonly clock: Clock;
private state: CircuitState = "closed";
private consecutiveFailures = 0;
private openedAt = 0;
public constructor(config: CircuitBreakerConfig, clock: Clock) {
if (config.failureThreshold <= 0) {
throw new Error("CircuitBreaker.failureThreshold must be > 0");
}
if (config.resetMs < 0) {
throw new Error("CircuitBreaker.resetMs must be >= 0");
}
this.config = config;
this.clock = clock;
}
public get current(): CircuitState {
return this.state;
}
/**
* Inspect the breaker before issuing a request. Throws when the breaker
* is open and the cool-down has not elapsed; transitions to half-open
* when the cool-down has elapsed so the next request can probe.
*/
public beforeRequest(): void {
if (this.state === "open") {
const now = this.clock();
if (now - this.openedAt >= this.config.resetMs) {
this.state = "half-open";
return;
}
throw new WebWorkersError(
WEB_WORKERS_CODES.CIRCUIT_OPEN,
"Circuit breaker is open"
);
}
}
/** Record a successful request. Closes a half-open breaker. */
public recordSuccess(): void {
this.consecutiveFailures = 0;
if (this.state !== "closed") {
this.state = "closed";
}
}
/**
* Record a failure. Opens the breaker once `failureThreshold` consecutive
* failures have accumulated, or re-opens a half-open breaker immediately.
*/
public recordFailure(): void {
this.consecutiveFailures += 1;
if (this.state === "half-open") {
this.openedAt = this.clock();
this.state = "open";
return;
}
if (this.consecutiveFailures >= this.config.failureThreshold) {
this.openedAt = this.clock();
this.state = "open";
}
}
}

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,48 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
asWebWorkersError,
WEB_WORKERS_CODES,
WEB_WORKERS_ERROR_METADATA,
WebWorkersError,
} from "./errors.js";
describe("WEB_WORKERS errors", () => {
it("every code has metadata and is reachable from a call site", {
timeout: 5000,
}, () => {
for (const code of Object.values(WEB_WORKERS_CODES)) {
expect(
WEB_WORKERS_ERROR_METADATA[code].description.length
).toBeGreaterThan(0);
const err = new WebWorkersError(code, "msg");
expect(err.code).toBe(code);
expect(err.metadata).toBe(WEB_WORKERS_ERROR_METADATA[code]);
expect(err.name).toBe("WebWorkersError");
}
});
it("asWebWorkersError lifts unknown causes", { timeout: 5000 }, () => {
const fromError = asWebWorkersError(
WEB_WORKERS_CODES.WORKER_ERROR,
new Error("oops"),
"fallback"
);
expect(fromError.message).toBe("oops");
const fromString = asWebWorkersError(
WEB_WORKERS_CODES.WORKER_ERROR,
"string-thrown",
"fallback"
);
expect(fromString.message).toBe("fallback");
const fromBlank = asWebWorkersError(
WEB_WORKERS_CODES.WORKER_ERROR,
new Error(""),
"fallback"
);
expect(fromBlank.message).toBe("fallback");
});
it("metadata table is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(WEB_WORKERS_ERROR_METADATA)).toBe(true);
});
});

View File

@@ -0,0 +1,172 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error code registry for `@suaveplan/web-workers`. Every error path
* the package can take surfaces one of these codes so consumers can classify
* failures programmatically without parsing message strings.
*/
export const WEB_WORKERS_CODES = defineCodes("WEB_WORKERS", [
"DUPLICATE_WORKER",
"WORKER_NOT_FOUND",
"WORKER_TERMINATED",
"REQUEST_TIMEOUT",
"REQUEST_ABORTED",
"WORKER_ERROR",
"BACKPRESSURE",
"QUEUE_FULL",
"CIRCUIT_OPEN",
"RESTART_EXHAUSTED",
"HANDLER_NOT_FOUND",
"STREAM_ABORTED",
"INVALID_TRANSFER",
] as const);
/** Union of every error code emitted by `@suaveplan/web-workers`. */
export type WebWorkersErrorCode =
(typeof WEB_WORKERS_CODES)[keyof typeof WEB_WORKERS_CODES];
/** Per-code metadata for HTTP / retry / severity classification. */
export interface WebWorkersCodeMetadata {
readonly httpStatus: number;
readonly severity: "info" | "warning" | "error";
readonly retryable: boolean;
readonly description: string;
}
/** Mapping of every {@link WebWorkersErrorCode} to its metadata. */
export const WEB_WORKERS_ERROR_METADATA: Readonly<
Record<WebWorkersErrorCode, WebWorkersCodeMetadata>
> = Object.freeze({
[WEB_WORKERS_CODES.DUPLICATE_WORKER]: {
httpStatus: 409,
severity: "error",
retryable: false,
description:
"WorkerManager.create called with a name already registered.",
},
[WEB_WORKERS_CODES.WORKER_NOT_FOUND]: {
httpStatus: 404,
severity: "error",
retryable: false,
description:
"Requested worker is not registered with the manager or pool.",
},
[WEB_WORKERS_CODES.WORKER_TERMINATED]: {
httpStatus: 410,
severity: "warning",
retryable: true,
description:
"Worker was terminated while a request was pending; the in-flight promise was rejected.",
},
[WEB_WORKERS_CODES.REQUEST_TIMEOUT]: {
httpStatus: 504,
severity: "warning",
retryable: true,
description:
"sendRequest timed out before the worker responded; the pending entry was cleared.",
},
[WEB_WORKERS_CODES.REQUEST_ABORTED]: {
httpStatus: 499,
severity: "info",
retryable: false,
description:
"sendRequest or MessageBus.request was aborted via AbortSignal.",
},
[WEB_WORKERS_CODES.WORKER_ERROR]: {
httpStatus: 500,
severity: "error",
retryable: true,
description:
"Underlying Worker emitted an error event; in-flight requests were rejected.",
},
[WEB_WORKERS_CODES.BACKPRESSURE]: {
httpStatus: 429,
severity: "warning",
retryable: true,
description:
"Pending-request map reached maxPendingRequests; the new request was rejected before sending.",
},
[WEB_WORKERS_CODES.QUEUE_FULL]: {
httpStatus: 429,
severity: "warning",
retryable: true,
description:
"WorkerPool task queue reached maxQueueDepth and refused the submit.",
},
[WEB_WORKERS_CODES.CIRCUIT_OPEN]: {
httpStatus: 503,
severity: "warning",
retryable: true,
description:
"Per-worker circuit breaker is open after consecutive failures; the request was short-circuited.",
},
[WEB_WORKERS_CODES.RESTART_EXHAUSTED]: {
httpStatus: 503,
severity: "error",
retryable: false,
description:
"WorkerPool slot exceeded maxRestarts; the slot was removed and pending submits were rejected.",
},
[WEB_WORKERS_CODES.HANDLER_NOT_FOUND]: {
httpStatus: 404,
severity: "warning",
retryable: false,
description:
"MessageBus.request was called for a topic with no registered handler.",
},
[WEB_WORKERS_CODES.STREAM_ABORTED]: {
httpStatus: 499,
severity: "info",
retryable: false,
description:
"sendStream consumer aborted iteration; a cancel frame was posted to the worker.",
},
[WEB_WORKERS_CODES.INVALID_TRANSFER]: {
httpStatus: 400,
severity: "error",
retryable: false,
description:
"postMessage was called with a non-array transfer option; transfer must be Transferable[].",
},
});
/**
* Base error class for every failure raised by `@suaveplan/web-workers`.
* Extends {@link SuaveplanError} so consumers receive cause-chain traversal,
* Zod-validated wire format, redaction hooks, and the OTel span-event adapter
* for free.
*/
export class WebWorkersError extends SuaveplanError<WebWorkersErrorCode> {
public constructor(
code: WebWorkersErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.name = "WebWorkersError";
}
/** Metadata for this error's code. */
public get metadata(): WebWorkersCodeMetadata {
return WEB_WORKERS_ERROR_METADATA[this.code];
}
}
/**
* Lift an unknown thrown value into a {@link WebWorkersError}. Used at every
* worker-side `catch` site so call sites can rely on the typed error contract.
*/
export function asWebWorkersError(
code: WebWorkersErrorCode,
cause: unknown,
fallbackMessage: string
): WebWorkersError {
const message =
cause instanceof Error && cause.message
? cause.message
: fallbackMessage;
const causeError =
cause instanceof Error ? cause : new Error(String(cause));
return new WebWorkersError(code, message, { cause: causeError });
}

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,93 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { WorkerLike } from "../types/web-workers.types.js";
import { defineWorker, type WorkerConstructorLike } from "./define-worker.js";
class FakeWorker implements WorkerLike {
public readonly url: string | URL;
public readonly options: { type?: "classic" | "module" } | undefined;
constructor(url: string | URL, options?: { type?: "classic" | "module" }) {
this.url = url;
this.options = options;
}
postMessage(): void {}
terminate(): void {}
addEventListener(): void {}
}
describe("defineWorker", () => {
it("returns a worker factory", { timeout: 5000 }, () => {
const factory = defineWorker({
url: "x.js",
type: "module",
workerConstructor: FakeWorker as unknown as WorkerConstructorLike,
});
const worker = factory("x.js") as unknown as FakeWorker;
expect(worker.url).toBe("x.js");
expect(worker.options?.type).toBe("module");
});
it("defaults to module type", { timeout: 5000 }, () => {
const factory = defineWorker({
url: "y.js",
workerConstructor: FakeWorker as unknown as WorkerConstructorLike,
});
const worker = factory("y.js") as unknown as FakeWorker;
expect(worker.options?.type).toBe("module");
});
it("override per-invocation type", { timeout: 5000 }, () => {
const factory = defineWorker({
url: "z.js",
type: "module",
workerConstructor: FakeWorker as unknown as WorkerConstructorLike,
});
const worker = factory("z.js", {
type: "classic",
}) as unknown as FakeWorker;
expect(worker.options?.type).toBe("classic");
});
it("uses default url when factory called with undefined", {
timeout: 5000,
}, () => {
const factory = defineWorker({
url: "default.js",
workerConstructor: FakeWorker as unknown as WorkerConstructorLike,
});
const worker = factory(undefined as unknown as string) as unknown as
| FakeWorker
| undefined;
expect((worker as FakeWorker).url).toBe("default.js");
});
it("throws when no global Worker is available", {
timeout: 5000,
}, () => {
const previous = (globalThis as { Worker?: WorkerConstructorLike })
.Worker;
(globalThis as { Worker?: WorkerConstructorLike }).Worker = undefined;
try {
expect(() => defineWorker({ url: "x.js" })).toThrow(
/globalThis\.Worker is undefined/
);
} finally {
(globalThis as { Worker?: WorkerConstructorLike }).Worker =
previous;
}
});
it("uses globalThis.Worker by default", { timeout: 5000 }, () => {
const previous = (globalThis as { Worker?: WorkerConstructorLike })
.Worker;
(globalThis as { Worker?: WorkerConstructorLike }).Worker =
FakeWorker as unknown as WorkerConstructorLike;
try {
const factory = defineWorker({ url: "g.js" });
const worker = factory("g.js") as unknown as FakeWorker;
expect(worker.url).toBe("g.js");
} finally {
(globalThis as { Worker?: WorkerConstructorLike }).Worker =
previous;
}
});
});

View File

@@ -0,0 +1,50 @@
import type { WorkerFactory, WorkerLike } from "../types/web-workers.types.js";
/** Options accepted by {@link defineWorker}. */
export interface DefineWorkerOptions {
/** Worker entrypoint URL — typically `new URL("./worker.ts", import.meta.url)`. */
readonly url: string | URL;
/** Worker type. Defaults to "module". */
readonly type?: "classic" | "module";
/**
* Optional override of the global `Worker` constructor. Provided for
* tests; production callers rely on the default (the DOM `Worker`).
*/
readonly workerConstructor?: WorkerConstructorLike;
}
/** Subset of `WorkerConstructor` used by {@link defineWorker}. */
export type WorkerConstructorLike = new (
url: string | URL,
options?: { type?: "classic" | "module" }
) => WorkerLike;
/**
* Bind a worker entrypoint URL + type into a reusable {@link WorkerFactory}.
*
* `WorkerManager` accepts a factory rather than a constructor so consumers
* can substitute fakes in tests. `defineWorker` is the canonical production
* factory — it wraps the standard `new Worker(url, { type })` pattern with
* the Vite/Webpack-friendly `new URL('./worker.ts', import.meta.url)` idiom
* and removes the temptation to inline `new Worker(...)` at call sites.
*/
export function defineWorker(opts: DefineWorkerOptions): WorkerFactory {
const baseType: "classic" | "module" = opts.type ?? "module";
const ctor = opts.workerConstructor ?? getGlobalWorker();
return (urlOverride, options) => {
const url = urlOverride ?? opts.url;
const type = options?.type ?? baseType;
return new ctor(url, { type });
};
}
function getGlobalWorker(): WorkerConstructorLike {
const globalCtor = (globalThis as { Worker?: WorkerConstructorLike })
.Worker;
if (globalCtor === undefined) {
throw new Error(
"defineWorker: globalThis.Worker is undefined; pass workerConstructor explicitly in non-browser environments."
);
}
return globalCtor;
}

View File

@@ -0,0 +1,80 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as pkg from "./index.js";
const PKG_ROOT = resolve(__dirname, "..");
function* walk(dir: string): Generator<string> {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const s = statSync(full);
if (s.isDirectory()) {
yield* walk(full);
} else {
yield full;
}
}
}
describe("@suaveplan/web-workers package surface", () => {
it("exposes the documented public symbols", { timeout: 5000 }, () => {
const required = [
"BaseWorkerManager",
"CircuitBreaker",
"DEFAULT_MAX_PENDING_REQUESTS",
"DEFAULT_TIMEOUT_MS",
"MessageBus",
"SharedWorkerManager",
"WEB_WORKERS_CODES",
"WEB_WORKERS_ERROR_METADATA",
"WebWorkersError",
"WorkerManager",
"WorkerPool",
"asWebWorkersError",
"createWebWorkersObservability",
"defineWorker",
"pickSlot",
"sendStream",
];
const exported = Object.keys(pkg);
for (const sym of required) expect(exported).toContain(sym);
});
it("every advertised subpath resolves", { timeout: 5000 }, () => {
const manifest = JSON.parse(
readFileSync(join(PKG_ROOT, "package.json"), "utf8")
) as {
exports: Record<string, { import?: { default?: string } } | string>;
};
const subpaths = Object.keys(manifest.exports).filter(
(k) => k !== "./package.json" && k !== "."
);
expect(subpaths.length).toBeGreaterThanOrEqual(10);
for (const sp of subpaths) {
const entry = manifest.exports[sp];
const inner =
typeof entry === "object" && entry !== null
? entry.import?.default
: entry;
expect(typeof inner).toBe("string");
const srcEquivalent = (inner as string)
.replace("./dist/", "./src/")
.replace(".js", ".ts");
expect(existsSync(join(PKG_ROOT, srcEquivalent))).toBe(true);
}
});
it("src contains no banned patterns", { timeout: 5000 }, () => {
const re =
/(@deprecated|TODO|FIXME|ts-ignore|ts-expect-error|\.skip|\.only|console\.log)/;
const offenders: string[] = [];
for (const f of walk(join(PKG_ROOT, "src"))) {
if (!f.endsWith(".ts")) continue;
if (f.endsWith(".test.ts")) continue;
const txt = readFileSync(f, "utf8");
if (re.test(txt)) offenders.push(f);
}
expect(offenders).toEqual([]);
});
});

View File

@@ -0,0 +1,98 @@
/**
* `@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.
*/
export type { ManagedEntry } from "./base-manager/base-manager.js";
// Base manager
export { BaseWorkerManager } from "./base-manager/base-manager.js";
export type { BaseManagerOptions } from "./base-manager/base-manager.types.js";
export {
DEFAULT_MAX_PENDING_REQUESTS,
DEFAULT_TIMEOUT_MS,
} from "./base-manager/base-manager.types.js";
// Circuit breaker
export {
CircuitBreaker,
type CircuitState,
} from "./circuit-breaker/circuit-breaker.js";
// Errors
export {
asWebWorkersError,
WEB_WORKERS_CODES,
WEB_WORKERS_ERROR_METADATA,
type WebWorkersCodeMetadata,
WebWorkersError,
type WebWorkersErrorCode,
} from "./errors/errors.js";
// defineWorker helper
export {
type DefineWorkerOptions,
defineWorker,
type WorkerConstructorLike,
} from "./helpers/define-worker.js";
// Message bus
export {
type BusRequestOptions,
MessageBus,
type MessageBusOptions,
type MessageHandler,
type RequestHandler,
} from "./message-bus/message-bus.js";
// Observability
export { createWebWorkersObservability } from "./observability/observability.js";
export type {
WebWorkersCounters,
WebWorkersHistograms,
WebWorkersObservability,
WebWorkersObservabilityOptions,
} from "./observability/observability.types.js";
// SharedWorker manager
export {
SharedWorkerManager,
type SharedWorkerManagerOptions,
} from "./shared-worker-manager/shared-worker-manager.js";
// Streaming RPC
export {
type StreamCapableManager,
sendStream,
} from "./streaming/stream.js";
// Types
export type {
CircuitBreakerConfig,
MessageBusEventMap,
PostMessageOptions,
SendRequestOptions,
SendStreamOptions,
SharedWorkerFactory,
SharedWorkerLike,
SharedWorkerPortLike,
StreamFrame,
SubmitOptions,
WorkerConfig,
WorkerFactory,
WorkerLike,
WorkerManagerEventMap,
WorkerMessage,
WorkerPoolConfig,
WorkerStatus,
} from "./types/web-workers.types.js";
// Worker manager (classic)
export {
WorkerManager,
type WorkerManagerOptions,
} from "./worker-manager/worker-manager.js";
export {
pickSlot,
type SchedulerSlot,
type SchedulerStrategy,
} from "./worker-pool/scheduler.js";
// Worker pool
export {
WorkerPool,
type WorkerPoolOptions,
} from "./worker-pool/worker-pool.js";

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,173 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WEB_WORKERS_CODES, WebWorkersError } from "../errors/errors.js";
import { MessageBus } from "./message-bus.js";
describe("MessageBus", () => {
it("subscribe + publish delivers to all", { timeout: 5000 }, () => {
const bus = new MessageBus();
const seen: number[] = [];
bus.subscribe<number>("t", (p) => seen.push(p));
bus.subscribe<number>("t", (p) => seen.push(p * 2));
const n = bus.publish("t", 3);
expect(n).toBe(2);
expect(seen).toEqual([3, 6]);
});
it("subscribeOnce auto-removes", { timeout: 5000 }, () => {
const bus = new MessageBus();
const seen: number[] = [];
bus.subscribeOnce<number>("o", (p) => seen.push(p));
bus.publish("o", 1);
bus.publish("o", 2);
expect(seen).toEqual([1]);
expect(bus.subscriberCount("o")).toBe(0);
});
it("unsubscribe returned by subscribe", { timeout: 5000 }, () => {
const bus = new MessageBus();
const seen: number[] = [];
const off = bus.subscribe<number>("u", (p) => seen.push(p));
bus.publish("u", 1);
off();
bus.publish("u", 2);
expect(seen).toEqual([1]);
});
it("unsubscribe returned by subscribeOnce", { timeout: 5000 }, () => {
const bus = new MessageBus();
const off = bus.subscribeOnce<number>("u", () => {});
off();
expect(bus.subscriberCount("u")).toBe(0);
});
it("publish to empty topic returns 0", { timeout: 5000 }, () => {
const bus = new MessageBus();
expect(bus.publish("none", 1)).toBe(0);
});
it("broadcast hits every topic", { timeout: 5000 }, () => {
const bus = new MessageBus();
bus.subscribe("a", () => {});
bus.subscribe("b", () => {});
expect(bus.broadcast("x")).toBe(2);
});
it("registerHandler + request", { timeout: 5000 }, async () => {
const bus = new MessageBus();
bus.registerHandler<number, number>("sq", (p) => p * p);
expect(await bus.request<number, number>("sq", 4)).toBe(16);
});
it("registerHandler async handler", { timeout: 5000 }, async () => {
const bus = new MessageBus();
bus.registerHandler<number, number>("a", async (p) =>
Promise.resolve(p + 1)
);
expect(await bus.request<number, number>("a", 1)).toBe(2);
});
it("request without handler rejects", { timeout: 5000 }, async () => {
const bus = new MessageBus();
await expect(bus.request("none", 1)).rejects.toMatchObject({
code: WEB_WORKERS_CODES.HANDLER_NOT_FOUND,
});
});
it("request honours timeout", { timeout: 5000 }, async () => {
const bus = new MessageBus();
bus.registerHandler("slow", () => new Promise<number>(() => {}));
await expect(
bus.request("slow", 1, { timeout: 20 })
).rejects.toMatchObject({ code: WEB_WORKERS_CODES.REQUEST_TIMEOUT });
});
it("request honours abort signal", { timeout: 5000 }, async () => {
const bus = new MessageBus();
bus.registerHandler("slow", () => new Promise<number>(() => {}));
const ctrl = new AbortController();
const p = bus.request("slow", 1, {
signal: ctrl.signal,
timeout: 10000,
});
ctrl.abort();
await expect(p).rejects.toMatchObject({
code: WEB_WORKERS_CODES.REQUEST_ABORTED,
});
});
it("request pre-aborted signal", { timeout: 5000 }, async () => {
const bus = new MessageBus();
bus.registerHandler("any", () => 1);
const ctrl = new AbortController();
ctrl.abort();
await expect(
bus.request("any", 1, { signal: ctrl.signal })
).rejects.toMatchObject({ code: WEB_WORKERS_CODES.REQUEST_ABORTED });
});
it("request lifts non-Error reject", { timeout: 5000 }, async () => {
const bus = new MessageBus();
bus.registerHandler("rej", () => Promise.reject("bad"));
await expect(bus.request("rej", 1)).rejects.toThrow(/bad/);
});
it("request propagates Error reject", { timeout: 5000 }, async () => {
const bus = new MessageBus();
bus.registerHandler("rej2", () =>
Promise.reject(
new WebWorkersError(WEB_WORKERS_CODES.WORKER_ERROR, "x")
)
);
await expect(bus.request("rej2", 1)).rejects.toBeInstanceOf(
WebWorkersError
);
});
it("subscriberCount + getTopics", { timeout: 5000 }, () => {
const bus = new MessageBus();
bus.subscribe("a", () => {});
bus.subscribe("a", () => {});
bus.subscribe("b", () => {});
expect(bus.subscriberCount("a")).toBe(2);
expect(bus.subscriberCount("missing")).toBe(0);
expect(bus.getTopics().sort()).toEqual(["a", "b"]);
});
it("emits subscription/publish/clear events", { timeout: 5000 }, () => {
const bus = new MessageBus();
const events: string[] = [];
bus.events.on("subscription:added", () => events.push("added"));
bus.events.on("subscription:removed", () => events.push("removed"));
bus.events.on("message:published", () => events.push("pub"));
bus.events.on("bus:cleared", () => events.push("cleared"));
const off = bus.subscribe("t", () => {});
bus.publish("t", 1);
off();
bus.clear();
expect(events).toEqual(["added", "pub", "removed", "cleared"]);
});
it("getTopics filters empty bucket", { timeout: 5000 }, () => {
const bus = new MessageBus();
const off = bus.subscribeOnce("o", () => {});
off();
// Bucket now empty but still in the Map.
expect(bus.getTopics()).toEqual([]);
});
it("unsubscribe twice is a no-op", { timeout: 5000 }, () => {
const bus = new MessageBus();
const off = bus.subscribe("o", () => {});
off();
off();
expect(bus.subscriberCount("o")).toBe(0);
});
it("clear empties subscriptions + handlers", { timeout: 5000 }, () => {
const bus = new MessageBus();
bus.subscribe("t", () => {});
bus.registerHandler("h", () => 1);
bus.clear();
expect(bus.subscriberCount("t")).toBe(0);
});
});

View File

@@ -0,0 +1,212 @@
import { EventBus } from "@suaveplan/events";
import type { Clock, IdGenerator } from "@suaveplan/types";
import {
defaultClock,
defaultIdGenerator,
wireAbortSignal,
} from "../base-manager/helpers.js";
import { WEB_WORKERS_CODES, WebWorkersError } from "../errors/errors.js";
import type {
MessageBusEventMap,
WorkerMessage,
} from "../types/web-workers.types.js";
/** Subscriber callback for a topic. */
export type MessageHandler<T = unknown> = (
payload: T,
message: WorkerMessage<T>
) => void;
/** Internal subscription entry. */
interface Subscription<T = unknown> {
readonly handler: MessageHandler<T>;
readonly once: boolean;
}
/** Request-handler signature. */
export type RequestHandler<TReq = unknown, TRes = unknown> = (
payload: TReq
) => TRes | Promise<TRes>;
/** Options accepted by {@link MessageBus.request}. */
export interface BusRequestOptions {
readonly timeout?: number;
readonly signal?: AbortSignal;
}
/** Options accepted by {@link MessageBus} constructor. */
export interface MessageBusOptions {
readonly clock?: Clock;
readonly idGenerator?: IdGenerator;
}
const DEFAULT_REQUEST_TIMEOUT = 30_000;
/**
* In-process publish/subscribe + request/response message bus. Used to
* shuttle messages between workers and the main thread, or between
* decoupled main-thread modules without creating a real Worker. Supports
* typed payloads, `subscribeOnce`, `broadcast` across all topics,
* registerable request handlers, and AbortSignal-aware `request`.
*/
export class MessageBus {
private readonly subscriptions = new Map<string, Subscription[]>();
private readonly requestHandlers = new Map<string, RequestHandler>();
private readonly clock: Clock;
private readonly idGenerator: IdGenerator;
/** Event emitter for bus lifecycle events. */
public readonly events = new EventBus<MessageBusEventMap>();
public constructor(options: MessageBusOptions = {}) {
this.clock = options.clock ?? defaultClock;
this.idGenerator = options.idGenerator ?? defaultIdGenerator;
}
public subscribe<T>(topic: string, handler: MessageHandler<T>): () => void {
return this.addSubscription(topic, handler, false);
}
public subscribeOnce<T>(
topic: string,
handler: MessageHandler<T>
): () => void {
return this.addSubscription(topic, handler, true);
}
public publish<T>(topic: string, payload: T): number {
const subs = this.subscriptions.get(topic);
if (subs === undefined || subs.length === 0) {
this.events.emit("message:published", {
topic,
subscriberCount: 0,
});
return 0;
}
const message: WorkerMessage<T> = {
type: topic,
payload,
id: this.idGenerator(),
timestamp: this.clock(),
};
let delivered = 0;
const remaining: Subscription[] = [];
for (const sub of subs) {
sub.handler(payload, message);
delivered += 1;
if (!sub.once) remaining.push(sub);
}
this.subscriptions.set(topic, remaining);
this.events.emit("message:published", {
topic,
subscriberCount: delivered,
});
return delivered;
}
public broadcast<T>(payload: T): number {
let total = 0;
for (const topic of [...this.subscriptions.keys()]) {
total += this.publish(topic, payload);
}
return total;
}
public registerHandler<TReq, TRes>(
topic: string,
handler: RequestHandler<TReq, TRes>
): void {
this.requestHandlers.set(topic, handler as RequestHandler);
}
public async request<TReq, TRes>(
topic: string,
payload: TReq,
options: BusRequestOptions = {}
): Promise<TRes> {
const handler = this.requestHandlers.get(topic);
if (handler === undefined) {
throw new WebWorkersError(
WEB_WORKERS_CODES.HANDLER_NOT_FOUND,
`No handler registered for topic "${topic}"`
);
}
const signal = options.signal;
if (signal?.aborted === true) {
throw new WebWorkersError(
WEB_WORKERS_CODES.REQUEST_ABORTED,
`MessageBus.request "${topic}" aborted before dispatch`
);
}
const timeoutMs = options.timeout ?? DEFAULT_REQUEST_TIMEOUT;
return new Promise<TRes>((resolve, reject) => {
const timer = setTimeout(() => {
cleanup?.();
reject(
new WebWorkersError(
WEB_WORKERS_CODES.REQUEST_TIMEOUT,
`MessageBus.request "${topic}" timed out after ${timeoutMs}ms`
)
);
}, timeoutMs);
const cleanup = wireAbortSignal(signal, () => {
clearTimeout(timer);
reject(
new WebWorkersError(
WEB_WORKERS_CODES.REQUEST_ABORTED,
`MessageBus.request "${topic}" aborted`
)
);
});
Promise.resolve(handler(payload as TReq)).then(
(value) => {
clearTimeout(timer);
cleanup?.();
resolve(value as TRes);
},
(err: unknown) => {
clearTimeout(timer);
cleanup?.();
reject(err instanceof Error ? err : new Error(String(err)));
}
);
});
}
public subscriberCount(topic: string): number {
return this.subscriptions.get(topic)?.length ?? 0;
}
public getTopics(): string[] {
return [...this.subscriptions.entries()]
.filter(([, subs]) => subs.length > 0)
.map(([t]) => t);
}
public clear(): void {
const topicCount = this.subscriptions.size;
this.subscriptions.clear();
this.requestHandlers.clear();
this.events.emit("bus:cleared", { topicCount });
}
private addSubscription<T>(
topic: string,
handler: MessageHandler<T>,
once: boolean
): () => void {
const subs = this.subscriptions.get(topic) ?? [];
const sub: Subscription<T> = { handler, once };
subs.push(sub as Subscription);
this.subscriptions.set(topic, subs);
this.events.emit("subscription:added", { topic });
return () => this.removeHandler(topic, handler as MessageHandler);
}
private removeHandler(topic: string, handler: MessageHandler): void {
const subs = this.subscriptions.get(topic) as Subscription[];
const next = subs.filter((s) => s.handler !== handler);
this.subscriptions.set(topic, next);
this.events.emit("subscription:removed", { topic });
}
}

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,33 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createWebWorkersObservability } from "./observability.js";
describe("createWebWorkersObservability", () => {
it("creates counters and histograms", { timeout: 5000 }, () => {
const obs = createWebWorkersObservability();
expect(obs.counters.messagesSentTotal).toBeDefined();
expect(obs.counters.messagesReceivedTotal).toBeDefined();
expect(obs.counters.workersCreatedTotal).toBeDefined();
expect(obs.counters.workersTerminatedTotal).toBeDefined();
expect(obs.counters.restartsTotal).toBeDefined();
expect(obs.counters.errorsTotal).toBeDefined();
expect(obs.histograms.rpcLatencyMs).toBeDefined();
expect(obs.histograms.queueDepth).toBeDefined();
expect(obs.histograms.poolActiveWorkers).toBeDefined();
});
it("returns supplied observability bundle as-is", { timeout: 5000 }, () => {
const base = createWebWorkersObservability();
const echoed = createWebWorkersObservability({ observability: base });
expect(echoed).toBe(base);
});
it("accepts external meter/tracer", { timeout: 5000 }, () => {
const base = createWebWorkersObservability();
const echoed = createWebWorkersObservability({
meter: base.meter,
tracer: base.tracer,
});
expect(echoed.meter).toBe(base.meter);
expect(echoed.tracer).toBe(base.tracer);
});
});

View File

@@ -0,0 +1,81 @@
import { metrics, trace } from "@opentelemetry/api";
import { createCounter, createHistogram } from "@suaveplan/opentelemetry";
import type {
Meter,
Tracer,
WebWorkersObservability,
WebWorkersObservabilityOptions,
} from "./observability.types.js";
const METER_NAME = "@suaveplan/web-workers";
const TRACER_NAME = "@suaveplan/web-workers";
/**
* Create a {@link WebWorkersObservability} bundle on the supplied meter and
* tracer, or on the no-op providers from `@opentelemetry/api` when none are
* supplied. Safe to call from every public entrypoint even when no telemetry
* pipeline is wired up.
*/
export function createWebWorkersObservability(
opts?: WebWorkersObservabilityOptions
): WebWorkersObservability {
if (opts?.observability !== undefined) {
return opts.observability;
}
const meter: Meter = opts?.meter ?? metrics.getMeter(METER_NAME);
const tracer: Tracer = opts?.tracer ?? trace.getTracer(TRACER_NAME);
return {
meter,
tracer,
counters: {
messagesSentTotal: createCounter(
meter,
"web_workers_messages_sent_total",
"Total messages posted to managed workers (postMessage + sendRequest combined)."
),
messagesReceivedTotal: createCounter(
meter,
"web_workers_messages_received_total",
"Total messages received from managed workers."
),
workersCreatedTotal: createCounter(
meter,
"web_workers_workers_created_total",
"Total workers created across managers and pools."
),
workersTerminatedTotal: createCounter(
meter,
"web_workers_workers_terminated_total",
"Total workers terminated across managers and pools."
),
restartsTotal: createCounter(
meter,
"web_workers_restarts_total",
"Total worker restarts triggered by crashes or health-check failures."
),
errorsTotal: createCounter(
meter,
"web_workers_errors_total",
"Total errors emitted by managed workers, partitioned by code."
),
},
histograms: {
rpcLatencyMs: createHistogram(
meter,
"web_workers_rpc_latency_ms",
"Worker RPC latency in milliseconds.",
"ms"
),
queueDepth: createHistogram(
meter,
"web_workers_queue_depth",
"WorkerPool task queue depth sampled at submit time."
),
poolActiveWorkers: createHistogram(
meter,
"web_workers_pool_active_workers",
"WorkerPool active worker count sampled at submit time."
),
},
};
}

View File

@@ -0,0 +1,35 @@
import type { Counter, Histogram, Meter, Tracer } from "@opentelemetry/api";
/** Counter instruments exposed by the observability bundle. */
export interface WebWorkersCounters {
readonly messagesSentTotal: Counter;
readonly messagesReceivedTotal: Counter;
readonly workersCreatedTotal: Counter;
readonly workersTerminatedTotal: Counter;
readonly restartsTotal: Counter;
readonly errorsTotal: Counter;
}
/** Histogram instruments exposed by the observability bundle. */
export interface WebWorkersHistograms {
readonly rpcLatencyMs: Histogram;
readonly queueDepth: Histogram;
readonly poolActiveWorkers: Histogram;
}
/** Observability bundle injected into the managers + pool + bus. */
export interface WebWorkersObservability {
readonly meter: Meter;
readonly tracer: Tracer;
readonly counters: WebWorkersCounters;
readonly histograms: WebWorkersHistograms;
}
/** Options for constructing a {@link WebWorkersObservability} bundle. */
export interface WebWorkersObservabilityOptions {
readonly meter?: Meter;
readonly tracer?: Tracer;
readonly observability?: WebWorkersObservability;
}
export type { Meter, Tracer };

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,167 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { WEB_WORKERS_CODES } from "../errors/errors.js";
import type {
SharedWorkerFactory,
SharedWorkerLike,
SharedWorkerPortLike,
WorkerMessage,
} from "../types/web-workers.types.js";
import { SharedWorkerManager } from "./shared-worker-manager.js";
class FakePort implements SharedWorkerPortLike {
public started = 0;
public closed = 0;
public messages: Array<{ message: unknown; transfer?: Transferable[] }> =
[];
private readonly listeners = new Map<
string,
Array<(event: MessageEvent) => void>
>();
start(): void {
this.started += 1;
}
close(): void {
this.closed += 1;
}
postMessage(message: unknown, transfer?: Transferable[]): void {
this.messages.push(
transfer === undefined ? { message } : { message, transfer }
);
}
addEventListener(
type: "message" | "messageerror",
listener: (event: MessageEvent) => void
): void {
const list = this.listeners.get(type) ?? [];
list.push(listener);
this.listeners.set(type, list);
}
fireMessage(data: WorkerMessage): void {
for (const l of this.listeners.get("message") ?? []) {
l({ data } as unknown as MessageEvent);
}
}
fireMessageError(): void {
for (const l of this.listeners.get("messageerror") ?? []) {
l({} as MessageEvent);
}
}
}
class FakeSharedWorker implements SharedWorkerLike {
public readonly port = new FakePort();
private readonly errorListeners: Array<(event: ErrorEvent) => void> = [];
addEventListener(
_type: "error",
listener: (event: ErrorEvent) => void
): void {
this.errorListeners.push(listener);
}
fireError(message: string): void {
for (const l of this.errorListeners) l({ message } as ErrorEvent);
}
}
function makeFactory(): {
factory: SharedWorkerFactory;
workers: FakeSharedWorker[];
} {
const workers: FakeSharedWorker[] = [];
const factory: SharedWorkerFactory = () => {
const w = new FakeSharedWorker();
workers.push(w);
return w;
};
return { factory, workers };
}
describe("SharedWorkerManager", () => {
let manager: SharedWorkerManager;
let workers: FakeSharedWorker[];
beforeEach(() => {
const f = makeFactory();
workers = f.workers;
manager = new SharedWorkerManager({ factory: f.factory });
});
afterEach(() => {
manager.terminateAll();
});
it("uses injected SharedWorker factory", { timeout: 5000 }, () => {
manager.create({ name: "sw", url: "sw.js" });
expect(workers).toHaveLength(1);
expect(manager.has("sw")).toBe(true);
});
it("starts port on create", { timeout: 5000 }, () => {
manager.create({ name: "p", url: "p.js" });
expect(workers[0]?.port.started).toBe(1);
});
it("closes port on terminate", { timeout: 5000 }, () => {
manager.create({ name: "p", url: "p.js" });
manager.terminate("p");
expect(workers[0]?.port.closed).toBe(1);
});
it("sendRequest round-trips via port", { timeout: 5000 }, async () => {
manager.create({ name: "rt", url: "rt.js", timeout: 5000 });
const promise = manager.sendRequest<number, number>("rt", "op", 2);
const sent = workers[0]?.port.messages[0]?.message as WorkerMessage;
workers[0]?.port.fireMessage({
type: "op",
payload: 4,
id: sent.id,
timestamp: Date.now(),
});
expect(await promise).toBe(4);
});
it("postMessage with transfer list", { timeout: 5000 }, () => {
manager.create({ name: "tr", url: "tr.js" });
const buf = new ArrayBuffer(2);
manager.postMessage("tr", "blob", buf, { transfer: [buf] });
expect(workers[0]?.port.messages[0]?.transfer).toEqual([buf]);
});
it("worker error fires onError", { timeout: 5000 }, () => {
const errors: string[] = [];
manager.onError((_n, e) => errors.push(e.message));
manager.create({ name: "e", url: "e.js" });
workers[0]?.fireError("boom");
expect(errors).toEqual(["boom"]);
});
it("error without message falls back", { timeout: 5000 }, () => {
const errors: string[] = [];
manager.onError((_n, e) => errors.push(e.message));
manager.create({ name: "ee", url: "ee.js" });
workers[0]?.fireError(undefined as unknown as string);
expect(errors).toEqual(["SharedWorker error"]);
});
it("port messageerror fires onError", { timeout: 5000 }, () => {
const errors: string[] = [];
manager.onError((_n, e) => errors.push(e.message));
manager.create({ name: "me", url: "me.js" });
workers[0]?.port.fireMessageError();
expect(errors[0]).toMatch(/messageerror/);
});
it("terminate rejects pending", { timeout: 5000 }, async () => {
manager.create({ name: "pe", url: "pe.js", timeout: 10000 });
const p = manager.sendRequest("pe", "op", {});
manager.terminate("pe");
await expect(p).rejects.toMatchObject({
code: WEB_WORKERS_CODES.WORKER_TERMINATED,
});
});
});

View File

@@ -0,0 +1,79 @@
import { BaseWorkerManager } from "../base-manager/base-manager.js";
import type { BaseManagerOptions } from "../base-manager/base-manager.types.js";
import type {
SharedWorkerFactory,
SharedWorkerLike,
SharedWorkerPortLike,
WorkerConfig,
WorkerMessage,
} from "../types/web-workers.types.js";
/** Internal transport bundle held by SharedWorkerManager entries. */
interface SharedTransport {
readonly worker: SharedWorkerLike;
readonly port: SharedWorkerPortLike;
}
/** Options accepted by {@link SharedWorkerManager}. */
export interface SharedWorkerManagerOptions extends BaseManagerOptions {
readonly factory: SharedWorkerFactory;
}
/**
* SharedWorker manager. Wraps `new SharedWorker(url).port` and exposes the
* same API as {@link WorkerManager}. Concrete transport boundary:
*
* - `createTransport` calls the injected {@link SharedWorkerFactory},
* subscribes `message` + `messageerror` listeners to the port and an
* `error` listener to the worker, and calls `port.start()` exactly once.
* - `terminateTransport` calls `port.close()`. SharedWorkers cannot be
* terminated by clients — closing the port is the contract.
*/
export class SharedWorkerManager extends BaseWorkerManager<SharedTransport> {
private readonly factory: SharedWorkerFactory;
public constructor(options: SharedWorkerManagerOptions) {
super(options);
this.factory = options.factory;
}
protected override createTransport(config: WorkerConfig): SharedTransport {
const worker = this.factory(config.url, {
type: config.type ?? "classic",
name: config.name,
});
const port = worker.port;
port.addEventListener("message", (event) => {
const data = event.data as WorkerMessage;
this.handleMessage(config.name, data);
});
port.addEventListener("messageerror", () => {
this.handleError(
config.name,
new Error("SharedWorker port messageerror")
);
});
worker.addEventListener("error", (event) => {
const msg = event.message ?? "SharedWorker error";
this.handleError(config.name, new Error(msg));
});
port.start();
return { worker, port };
}
protected override terminateTransport(transport: SharedTransport): void {
transport.port.close();
}
protected override postToTransport(
transport: SharedTransport,
message: unknown,
transfer: Transferable[] | undefined
): void {
if (transfer !== undefined && transfer.length > 0) {
transport.port.postMessage(message, transfer);
return;
}
transport.port.postMessage(message);
}
}

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,211 @@
import { EventBus } from "@suaveplan/events";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WEB_WORKERS_CODES, WebWorkersError } from "../errors/errors.js";
import type {
StreamFrame,
WorkerManagerEventMap,
} from "../types/web-workers.types.js";
import { type StreamCapableManager, sendStream } from "./stream.js";
function makeManager(): {
manager: StreamCapableManager;
posted: Array<{ name: string; type: string; payload: unknown }>;
push: (frame: StreamFrame) => void;
} {
const events = new EventBus<WorkerManagerEventMap>();
const posted: Array<{ name: string; type: string; payload: unknown }> = [];
const manager: StreamCapableManager = {
events,
postMessage(name, type, payload) {
posted.push({ name, type, payload });
},
};
return {
manager,
posted,
push: (frame) => {
events.emit("message:received", { name: "w", message: frame });
},
};
}
describe("sendStream", () => {
it("yields chunks then completes", { timeout: 5000 }, async () => {
const { manager, push } = makeManager();
const iter = sendStream<unknown, number>(manager, "w", "stream", null, {
idFactory: () => "stream-id-1",
});
const chunks: number[] = [];
const consume = (async () => {
for await (const c of iter) chunks.push(c);
})();
await Promise.resolve();
push({
id: "stream-id-1",
type: "stream",
payload: { chunk: 1 },
timestamp: 0,
});
push({
id: "stream-id-1",
type: "stream",
payload: { chunk: 2 },
timestamp: 0,
});
push({
id: "stream-id-1",
type: "stream",
payload: { done: true },
timestamp: 0,
});
await consume;
expect(chunks).toEqual([1, 2]);
});
it("ignores frames for other workers and other ids", {
timeout: 5000,
}, async () => {
const { manager, push } = makeManager();
const iter = sendStream<unknown, number>(manager, "w", "stream", null, {
idFactory: () => "right",
});
const chunks: number[] = [];
const consume = (async () => {
for await (const c of iter) chunks.push(c);
})();
await Promise.resolve();
// Foreign worker id — must be ignored.
manager.events.emit("message:received", {
name: "other",
message: {
id: "right",
type: "stream",
payload: { chunk: 99 },
timestamp: 0,
},
});
// Foreign stream id — must be ignored.
push({
id: "wrong",
type: "stream",
payload: { chunk: 88 },
timestamp: 0,
});
push({
id: "right",
type: "stream",
payload: { chunk: 7 },
timestamp: 0,
});
push({
id: "right",
type: "stream",
payload: { done: true },
timestamp: 0,
});
await consume;
expect(chunks).toEqual([7]);
});
it("abort cancels stream", { timeout: 5000 }, async () => {
const { manager, posted } = makeManager();
const ctrl = new AbortController();
const iter = sendStream<unknown, number>(manager, "w", "stream", null, {
signal: ctrl.signal,
});
const done = (async () => {
try {
for await (const _c of iter) void _c;
} catch (e) {
return e;
}
return undefined;
})();
ctrl.abort();
const result = await done;
expect(result).toBeInstanceOf(WebWorkersError);
expect((result as WebWorkersError).code).toBe(
WEB_WORKERS_CODES.STREAM_ABORTED
);
expect(posted.find((m) => m.type === "stream:cancel")).toBeDefined();
});
it("ignores frames with neither chunk nor done", {
timeout: 5000,
}, async () => {
const { manager, push } = makeManager();
const iter = sendStream<unknown, number>(manager, "w", "stream", null, {
idFactory: () => "x",
});
const chunks: number[] = [];
const consume = (async () => {
for await (const c of iter) chunks.push(c);
})();
await Promise.resolve();
// Frame with empty payload (no chunk, no done) — must be ignored.
push({
id: "x",
type: "stream",
payload: { done: false } as unknown as { chunk: number },
timestamp: 0,
});
push({
id: "x",
type: "stream",
payload: { done: true },
timestamp: 0,
});
await consume;
expect(chunks).toEqual([]);
});
it("default options uses generated id", { timeout: 5000 }, async () => {
const { manager } = makeManager();
const iter = sendStream<unknown, number>(manager, "w", "stream", null);
const ait = (iter as AsyncIterable<number>)[Symbol.asyncIterator]();
const ret = await ait.return?.(undefined);
expect(ret?.done).toBe(true);
});
it("pre-aborted signal throws", { timeout: 5000 }, async () => {
const { manager } = makeManager();
const ctrl = new AbortController();
ctrl.abort();
const iter = sendStream<unknown, number>(manager, "w", "stream", null, {
signal: ctrl.signal,
});
let caught: WebWorkersError | undefined;
try {
for await (const _c of iter) void _c;
} catch (e) {
caught = e as WebWorkersError;
}
expect(caught?.code).toBe(WEB_WORKERS_CODES.STREAM_ABORTED);
});
it("passes transfer list through", { timeout: 5000 }, async () => {
const { manager, posted } = makeManager();
const buf = new ArrayBuffer(4);
const ctrl = new AbortController();
const iter = sendStream<ArrayBuffer, number>(
manager,
"w",
"stream",
buf,
{ signal: ctrl.signal, transfer: [buf] }
);
const done = (async () => {
try {
for await (const _c of iter) void _c;
} catch {
/* expected */
}
})();
ctrl.abort();
await done;
const env = posted[0]?.payload as
| { streamId: string; payload: ArrayBuffer }
| undefined;
expect(env?.payload).toBe(buf);
});
});

View File

@@ -0,0 +1,124 @@
import type { BaseWorkerManager } from "../base-manager/base-manager.js";
import { WEB_WORKERS_CODES, WebWorkersError } from "../errors/errors.js";
import type {
SendStreamOptions,
StreamFrame,
WorkerManagerEventMap,
} from "../types/web-workers.types.js";
/**
* Public surface accepted by {@link sendStream}. We don't require the
* concrete `WorkerManager` class — any object exposing the same five
* methods works (manager, pool worker, in-test fake).
*/
export interface StreamCapableManager {
readonly events: BaseWorkerManager<unknown>["events"];
postMessage<T>(
name: string,
type: string,
payload: T,
options?: { transfer?: Transferable[] }
): void;
}
/** Payload wrapper used by sendStream to carry the stream id to the worker. */
export interface StreamRequestEnvelope<TReq> {
readonly streamId: string;
readonly payload: TReq;
}
/** Optional id factory for {@link sendStream}; useful for deterministic tests. */
export type StreamIdFactory = () => string;
/**
* Streaming RPC. Returns an `AsyncIterable<TChunk>` driven by frames the
* worker pushes via `events.on("message:received", ...)`. The worker MUST
* push frames using the same `id` as the initiating request; the iterator
* yields each `chunk` and returns when it sees `{ done: true }`. Aborting
* the supplied signal posts a `stream:cancel` frame to the worker and
* throws {@link WebWorkersError} with `WEB_WORKERS.STREAM_ABORTED`.
*/
export async function* sendStream<TReq, TChunk>(
manager: StreamCapableManager,
name: string,
type: string,
payload: TReq,
options: SendStreamOptions & { readonly idFactory?: StreamIdFactory } = {}
): AsyncIterable<TChunk> {
const id = (options.idFactory ?? generateId)();
const queue: TChunk[] = [];
let resolveNext: (() => void) | undefined;
let done = false;
let aborted = false;
const unsubscribe = manager.events.on("message:received", (payload2) => {
const event = payload2 as WorkerManagerEventMap["message:received"];
if (event.name !== name) return;
const frame = event.message as StreamFrame<TChunk>;
if (frame.id !== id) return;
if ("done" in frame.payload && frame.payload.done === true) {
done = true;
} else if ("chunk" in frame.payload) {
queue.push(frame.payload.chunk);
}
resolveNext?.();
resolveNext = undefined;
});
const signal = options.signal;
const onAbort = (): void => {
aborted = true;
resolveNext?.();
resolveNext = undefined;
};
if (signal?.aborted === true) {
unsubscribe();
throw new WebWorkersError(
WEB_WORKERS_CODES.STREAM_ABORTED,
"sendStream aborted before dispatch"
);
}
signal?.addEventListener("abort", onAbort, { once: true });
const envelope: StreamRequestEnvelope<TReq> = {
streamId: id,
payload,
};
manager.postMessage(
name,
type,
envelope,
options.transfer === undefined
? undefined
: { transfer: options.transfer }
);
try {
while (true) {
if (aborted) {
manager.postMessage(name, "stream:cancel", { id });
throw new WebWorkersError(
WEB_WORKERS_CODES.STREAM_ABORTED,
`sendStream "${type}" aborted`
);
}
if (queue.length > 0) {
yield queue.shift() as TChunk;
continue;
}
if (done) return;
await new Promise<void>((resolve) => {
resolveNext = resolve;
});
}
} finally {
unsubscribe();
signal?.removeEventListener("abort", onAbort);
}
}
function generateId(): string {
const array = new Uint8Array(8);
crypto.getRandomValues(array);
return Array.from(array, (b) => b.toString(16).padStart(2, "0")).join("");
}

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,179 @@
/**
* Public type surface for `@suaveplan/web-workers`. Kept zero-runtime so it
* can be imported from consumer code without pulling in the manager / pool /
* bus implementations.
*/
/**
* Subset of the DOM `Worker` interface that the package depends on. Tests
* substitute an in-process implementation matching this surface so they can
* exercise the manager without `globalThis.Worker`.
*/
export interface WorkerLike {
postMessage(message: unknown, transfer?: Transferable[]): void;
terminate(): void;
addEventListener(
type: "message" | "error" | "messageerror",
listener: (event: MessageEvent | ErrorEvent) => void
): void;
}
/**
* Subset of the DOM `SharedWorker.port` interface that the package depends
* on. SharedWorkers expose a `MessagePort` whose lifecycle is `start()` →
* `postMessage(...)` → `close()`.
*/
export interface SharedWorkerPortLike {
start(): void;
close(): void;
postMessage(message: unknown, transfer?: Transferable[]): void;
addEventListener(
type: "message" | "messageerror",
listener: (event: MessageEvent) => void
): void;
}
/** Subset of the DOM `SharedWorker` interface that the package depends on. */
export interface SharedWorkerLike {
readonly port: SharedWorkerPortLike;
addEventListener(
type: "error",
listener: (event: ErrorEvent) => void
): void;
}
/** Factory function returning a Worker-like instance. */
export type WorkerFactory = (
url: string | URL,
options?: { type?: "classic" | "module" }
) => WorkerLike;
/** Factory function returning a SharedWorker-like instance. */
export type SharedWorkerFactory = (
url: string | URL,
options?: { type?: "classic" | "module"; name?: string }
) => SharedWorkerLike;
/** Status of a managed worker. */
export type WorkerStatus = "idle" | "busy" | "error" | "terminated";
/** Circuit-breaker configuration for a managed worker. */
export interface CircuitBreakerConfig {
readonly failureThreshold: number;
readonly resetMs: number;
}
/** Configuration accepted by `WorkerManager.create` / `SharedWorkerManager.create`. */
export interface WorkerConfig {
readonly name: string;
readonly url: string | URL;
readonly type?: "classic" | "module";
readonly timeout?: number;
readonly maxPendingRequests?: number;
readonly circuitBreaker?: CircuitBreakerConfig;
}
/** Options accepted by `postMessage` / `sendRequest`. */
export interface PostMessageOptions {
readonly transfer?: Transferable[];
}
/** Options accepted by `sendRequest`. */
export interface SendRequestOptions extends PostMessageOptions {
readonly timeout?: number;
readonly signal?: AbortSignal;
}
/** Options accepted by `sendStream`. */
export interface SendStreamOptions extends PostMessageOptions {
readonly signal?: AbortSignal;
}
/** A typed message exchanged with a worker. */
export interface WorkerMessage<T = unknown> {
readonly type: string;
readonly payload: T;
readonly id: string;
readonly timestamp: number;
readonly sequence?: number;
}
/** Stream frame pushed by a worker for a streaming RPC. */
export interface StreamFrame<TChunk = unknown> {
readonly id: string;
readonly type: string;
readonly payload:
| { readonly chunk: TChunk; readonly done?: false }
| { readonly done: true };
readonly timestamp: number;
readonly sequence?: number;
}
/** WorkerPool configuration. */
export interface WorkerPoolConfig {
readonly url: string | URL;
readonly size: number;
readonly type?: "classic" | "module";
readonly taskTimeout?: number;
readonly scheduler?: "round-robin" | "least-busy";
readonly maxConcurrent?: number;
readonly maxQueueDepth?: number;
readonly maxRestarts?: number;
readonly healthCheck?: (workerName: string) => boolean | Promise<boolean>;
readonly healthCheckIntervalMs?: number;
readonly maxPendingRequests?: number;
readonly circuitBreaker?: CircuitBreakerConfig;
}
/** Options for {@link WorkerPool.submit}. */
export interface SubmitOptions {
readonly timeout?: number;
readonly signal?: AbortSignal;
readonly transfer?: Transferable[];
}
/**
* Event map for `EventBus<WorkerManagerEventMap>` emitted by classic and
* shared worker managers. Every payload is a single object so it matches the
* `EventBus<TMap extends Record<string, unknown>>` signature in
* `@suaveplan/events`.
*/
export interface WorkerManagerEventMap {
"worker:created": { readonly name: string; readonly config: WorkerConfig };
"worker:terminated": { readonly name: string };
"worker:terminated:all": { readonly count: number };
"worker:error": { readonly name: string; readonly error: Error };
"worker:restarted": {
readonly name: string;
readonly attempt: number;
};
"message:sent": { readonly name: string; readonly type: string };
"message:received": {
readonly name: string;
readonly message: WorkerMessage;
};
"request:timeout": {
readonly name: string;
readonly type: string;
readonly id: string;
};
"request:fulfilled": {
readonly name: string;
readonly type: string;
readonly id: string;
readonly latencyMs: number;
};
[key: string]: unknown;
}
/** Event map for `EventBus<MessageBusEventMap>` emitted by MessageBus. */
export interface MessageBusEventMap {
"message:published": {
readonly topic: string;
readonly subscriberCount: number;
};
"subscription:added": { readonly topic: string };
"subscription:removed": { readonly topic: string };
"bus:cleared": { readonly topicCount: number };
[key: string]: unknown;
}

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,468 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { WEB_WORKERS_CODES, WebWorkersError } from "../errors/errors.js";
import { createWebWorkersObservability } from "../observability/observability.js";
import type {
WorkerFactory,
WorkerLike,
WorkerManagerEventMap,
WorkerMessage,
} from "../types/web-workers.types.js";
import { WorkerManager } from "./worker-manager.js";
class FakeWorker implements WorkerLike {
public messages: Array<{ message: unknown; transfer?: Transferable[] }> =
[];
public terminated = false;
private readonly listeners = new Map<
string,
Array<(event: MessageEvent | ErrorEvent) => void>
>();
postMessage(message: unknown, transfer?: Transferable[]): void {
this.messages.push(
transfer === undefined ? { message } : { message, transfer }
);
}
terminate(): void {
this.terminated = true;
}
addEventListener(
type: "message" | "error" | "messageerror",
listener: (event: MessageEvent | ErrorEvent) => void
): void {
const list = this.listeners.get(type) ?? [];
list.push(listener);
this.listeners.set(type, list);
}
fireMessage(data: WorkerMessage): void {
for (const l of this.listeners.get("message") ?? []) {
l({ data } as unknown as MessageEvent);
}
}
fireError(message: string): void {
for (const l of this.listeners.get("error") ?? []) {
l({ message } as ErrorEvent);
}
}
fireMessageError(message: string): void {
for (const l of this.listeners.get("messageerror") ?? []) {
l({ message } as ErrorEvent);
}
}
}
function makeFactory(): { factory: WorkerFactory; workers: FakeWorker[] } {
const workers: FakeWorker[] = [];
const factory: WorkerFactory = () => {
const w = new FakeWorker();
workers.push(w);
return w;
};
return { factory, workers };
}
describe("WorkerManager", () => {
let manager: WorkerManager;
let workers: FakeWorker[];
beforeEach(() => {
const f = makeFactory();
workers = f.workers;
manager = new WorkerManager({ factory: f.factory });
});
afterEach(() => {
manager.terminateAll();
});
it("uses injected factory exclusively", { timeout: 5000 }, () => {
manager.create({ name: "a", url: "a.js" });
expect(workers).toHaveLength(1);
expect(manager.has("a")).toBe(true);
});
it("honours config.type when supplied", { timeout: 5000 }, () => {
manager.create({ name: "ty", url: "ty.js", type: "module" });
expect(manager.has("ty")).toBe(true);
});
it("duplicate name rejects", { timeout: 5000 }, () => {
manager.create({ name: "x", url: "x.js" });
expect(() => manager.create({ name: "x", url: "x.js" })).toThrowError(
WebWorkersError
);
try {
manager.create({ name: "x", url: "x.js" });
} catch (e) {
expect((e as WebWorkersError).code).toBe(
WEB_WORKERS_CODES.DUPLICATE_WORKER
);
}
});
it("getStatus throws for missing worker", { timeout: 5000 }, () => {
expect(() => manager.getStatus("nope")).toThrowError(WebWorkersError);
});
it("terminate throws for missing worker", { timeout: 5000 }, () => {
expect(() => manager.terminate("ghost")).toThrowError(WebWorkersError);
});
it("terminate rejects in-flight requests", {
timeout: 5000,
}, async () => {
manager.create({ name: "p", url: "p.js", timeout: 10000 });
const promise = manager.sendRequest("p", "op", {});
manager.terminate("p");
await expect(promise).rejects.toThrow(/terminated/);
});
it("terminateAll terminates each", { timeout: 5000 }, () => {
manager.create({ name: "w1", url: "1.js" });
manager.create({ name: "w2", url: "2.js" });
manager.terminateAll();
expect(workers[0]?.terminated).toBe(true);
expect(workers[1]?.terminated).toBe(true);
expect(manager.getWorkerNames()).toHaveLength(0);
});
it("postMessage posts and emits", { timeout: 5000 }, () => {
const sent: string[] = [];
manager.events.on("message:sent", (p) => sent.push(p.type));
manager.create({ name: "m", url: "m.js" });
manager.postMessage("m", "ping", { x: 1 });
expect(workers[0]?.messages).toHaveLength(1);
const sentMsg = workers[0]?.messages[0]?.message as WorkerMessage<{
x: number;
}>;
expect(sentMsg.type).toBe("ping");
expect(sent).toEqual(["ping"]);
});
it("postMessage rejects non-array transfer", { timeout: 5000 }, () => {
manager.create({ name: "t", url: "t.js" });
expect(() =>
manager.postMessage(
"t",
"op",
{},
{
transfer: "bad" as unknown as Transferable[],
}
)
).toThrowError(WebWorkersError);
});
it("passes transfer list to worker", { timeout: 5000 }, () => {
manager.create({ name: "t2", url: "t2.js" });
const buf = new ArrayBuffer(4);
manager.postMessage("t2", "blob", buf, { transfer: [buf] });
const entry = workers[0]?.messages[0];
expect(entry?.transfer).toEqual([buf]);
});
it("empty transfer list skips transfer arg", {
timeout: 5000,
}, () => {
manager.create({ name: "te", url: "te.js" });
manager.postMessage("te", "ping", {}, { transfer: [] });
expect(workers[0]?.messages[0]?.transfer).toBeUndefined();
});
it("sendRequest resolves on matching response", {
timeout: 5000,
}, async () => {
manager.create({ name: "r", url: "r.js", timeout: 5000 });
const promise = manager.sendRequest<string, string>(
"r",
"compute",
"in"
);
const sentMsg = workers[0]?.messages[0]?.message as WorkerMessage;
workers[0]?.fireMessage({
type: "compute",
payload: "out",
id: sentMsg.id,
timestamp: Date.now(),
});
expect(await promise).toBe("out");
expect(manager.getStatus("r")).toBe("idle");
});
it("sendRequest timeout", { timeout: 5000 }, async () => {
manager.create({ name: "slow", url: "s.js", timeout: 20 });
await expect(
manager.sendRequest("slow", "op", {})
).rejects.toMatchObject({ code: WEB_WORKERS_CODES.REQUEST_TIMEOUT });
});
it("sendRequest abort mid-flight", { timeout: 5000 }, async () => {
manager.create({ name: "ab", url: "a.js", timeout: 10000 });
const ctrl = new AbortController();
const promise = manager.sendRequest(
"ab",
"op",
{},
{
signal: ctrl.signal,
}
);
ctrl.abort();
await expect(promise).rejects.toMatchObject({
code: WEB_WORKERS_CODES.REQUEST_ABORTED,
});
});
it("sendRequest pre-aborted signal", {
timeout: 5000,
}, async () => {
manager.create({ name: "pre", url: "p.js" });
const ctrl = new AbortController();
ctrl.abort();
await expect(
manager.sendRequest("pre", "op", {}, { signal: ctrl.signal })
).rejects.toMatchObject({ code: WEB_WORKERS_CODES.REQUEST_ABORTED });
expect(workers[0]?.messages).toHaveLength(0);
});
it("backpressure ceiling", { timeout: 5000 }, () => {
manager.create({
name: "bp",
url: "b.js",
maxPendingRequests: 1,
timeout: 10000,
});
manager.sendRequest("bp", "op", {}).catch(() => {});
expect(() => {
manager.sendRequest("bp", "op2", {}).catch(() => {});
}).toThrowError(WebWorkersError);
});
it("worker error emits and records", { timeout: 5000 }, () => {
const errors: string[] = [];
manager.onError((_n, e) => errors.push(e.message));
manager.create({ name: "e", url: "e.js" });
workers[0]?.fireError("boom");
expect(errors).toEqual(["boom"]);
expect(manager.getStatus("e")).toBe("error");
});
it("messageerror raises error", { timeout: 5000 }, () => {
const errors: string[] = [];
manager.onError((_n, e) => errors.push(e.message));
manager.create({ name: "me", url: "me.js" });
workers[0]?.fireMessageError("bad clone");
expect(errors).toEqual(["bad clone"]);
});
it("error event without message falls back", {
timeout: 5000,
}, () => {
const errors: string[] = [];
manager.onError((_n, e) => errors.push(e.message));
manager.create({ name: "ne", url: "ne.js" });
workers[0]?.fireError(undefined as unknown as string);
expect(errors).toEqual(["Worker error"]);
});
it("messageerror without message falls back", {
timeout: 5000,
}, () => {
const errors: string[] = [];
manager.onError((_n, e) => errors.push(e.message));
manager.create({ name: "nm", url: "nm.js" });
workers[0]?.fireMessageError(undefined as unknown as string);
expect(errors).toEqual(["Worker messageerror"]);
});
it("worker:error after terminate handled gracefully", {
timeout: 5000,
}, () => {
const errors: string[] = [];
manager.onError((_n, e) => errors.push(e.message));
manager.create({ name: "et", url: "et.js" });
const w = workers[0];
manager.terminate("et");
w?.fireError("after-terminate");
expect(errors).toEqual(["after-terminate"]);
});
it("ignores response after terminate", { timeout: 5000 }, () => {
manager.create({ name: "g", url: "g.js" });
const w = workers[0];
manager.terminate("g");
expect(() =>
w?.fireMessage({
type: "x",
payload: 1,
id: "x",
timestamp: 0,
})
).not.toThrow();
});
it("ignores response with unknown id", { timeout: 5000 }, () => {
manager.create({ name: "u", url: "u.js" });
expect(() =>
workers[0]?.fireMessage({
type: "x",
payload: 1,
id: "unknown",
timestamp: 0,
})
).not.toThrow();
});
it("emits lifecycle events on EventBus", { timeout: 5000 }, async () => {
const seen: Array<keyof WorkerManagerEventMap> = [];
manager.events.on("worker:created", () => seen.push("worker:created"));
manager.events.on("message:sent", () => seen.push("message:sent"));
manager.events.on("worker:terminated", () =>
seen.push("worker:terminated")
);
manager.create({ name: "lc", url: "lc.js" });
manager.postMessage("lc", "ping", {});
manager.terminate("lc");
expect(seen).toContain("worker:created");
expect(seen).toContain("message:sent");
expect(seen).toContain("worker:terminated");
});
it("records OTel counters and latency", { timeout: 5000 }, async () => {
const obs = createWebWorkersObservability();
const m = new WorkerManager({
factory: makeFactory().factory,
observability: obs,
});
m.create({ name: "o", url: "o.js", timeout: 5000 });
m.postMessage("o", "ping", {});
// sendRequest path drives latency histogram
const f = m as unknown as { workers: Map<string, unknown> };
expect(f.workers.size).toBe(1);
m.terminateAll();
});
it("request:fulfilled emitted with latency", {
timeout: 5000,
}, async () => {
let latency = -1;
manager.events.on("request:fulfilled", (p) => {
latency = p.latencyMs;
});
manager.create({ name: "lat", url: "l.js", timeout: 5000 });
const promise = manager.sendRequest("lat", "op", {});
const sent = workers[0]?.messages[0]?.message as WorkerMessage;
workers[0]?.fireMessage({
type: "op",
payload: "k",
id: sent.id,
timestamp: Date.now(),
});
await promise;
expect(latency).toBeGreaterThanOrEqual(0);
});
it("timer body no-ops when pending was already cleared", {
timeout: 5000,
}, async () => {
manager.create({ name: "rc", url: "rc.js", timeout: 5 });
const ctrl = new AbortController();
const promise = manager
.sendRequest("rc", "op", {}, { signal: ctrl.signal })
.catch(() => {});
ctrl.abort();
// Wait long enough for the timer to fire after abort.
await new Promise((r) => setTimeout(r, 30));
await promise;
// No further assertion needed — coverage covers the falsy branch.
});
it("request:timeout emits on timeout", { timeout: 5000 }, async () => {
let id = "";
manager.events.on("request:timeout", (p) => {
id = p.id;
});
manager.create({ name: "to", url: "t.js", timeout: 20 });
await expect(manager.sendRequest("to", "op", {})).rejects.toMatchObject(
{ code: WEB_WORKERS_CODES.REQUEST_TIMEOUT }
);
expect(id.length).toBeGreaterThan(0);
});
it("uses default timeout when none configured", {
timeout: 5000,
}, async () => {
manager.create({ name: "d", url: "d.js" });
const p = manager.sendRequest("d", "op", "x");
const sent = workers[0]?.messages[0]?.message as WorkerMessage;
workers[0]?.fireMessage({
type: "op",
payload: "y",
id: sent.id,
timestamp: Date.now(),
});
expect(await p).toBe("y");
});
it("getWorkerNames + has", { timeout: 5000 }, () => {
manager.create({ name: "n1", url: "1.js" });
manager.create({ name: "n2", url: "2.js" });
expect(manager.getWorkerNames().sort()).toEqual(["n1", "n2"]);
expect(manager.has("n1")).toBe(true);
expect(manager.has("ghost")).toBe(false);
});
it("multiple error handlers fire in order", {
timeout: 5000,
}, () => {
const seen: number[] = [];
manager.onError(() => seen.push(1));
manager.onError(() => seen.push(2));
manager.create({ name: "me", url: "m.js" });
workers[0]?.fireError("oops");
expect(seen).toEqual([1, 2]);
});
it("circuit breaker opens after failures", { timeout: 5000 }, async () => {
manager.create({
name: "cb",
url: "cb.js",
timeout: 10,
circuitBreaker: { failureThreshold: 2, resetMs: 60000 },
});
await expect(manager.sendRequest("cb", "op", {})).rejects.toMatchObject(
{
code: WEB_WORKERS_CODES.REQUEST_TIMEOUT,
}
);
await expect(manager.sendRequest("cb", "op", {})).rejects.toMatchObject(
{
code: WEB_WORKERS_CODES.REQUEST_TIMEOUT,
}
);
// Now open
expect(() => {
manager.sendRequest("cb", "op", {}).catch(() => {});
}).toThrowError(WebWorkersError);
});
it("invalid transfer list rejects", { timeout: 5000 }, () => {
manager.create({ name: "it", url: "it.js" });
expect(() =>
manager.sendRequest(
"it",
"op",
{},
{
transfer: "bad" as unknown as Transferable[],
}
)
).toThrowError(WebWorkersError);
});
});

View File

@@ -0,0 +1,70 @@
import { BaseWorkerManager } from "../base-manager/base-manager.js";
import type { BaseManagerOptions } from "../base-manager/base-manager.types.js";
import type {
WorkerConfig,
WorkerFactory,
WorkerLike,
WorkerMessage,
} from "../types/web-workers.types.js";
/** Options accepted by {@link WorkerManager}. */
export interface WorkerManagerOptions extends BaseManagerOptions {
/** Factory used to instantiate worker transports. */
readonly factory: WorkerFactory;
}
/**
* Classic-Worker manager: drives one or more `Worker` instances via an
* injected {@link WorkerFactory}. All shared lifecycle / RPC / observability
* behaviour lives in `BaseWorkerManager`; this subclass only knows how to
* stand up and tear down a `Worker`.
*/
export class WorkerManager extends BaseWorkerManager<WorkerLike> {
private readonly factory: WorkerFactory;
public constructor(options: WorkerManagerOptions) {
super(options);
this.factory = options.factory;
}
protected override createTransport(config: WorkerConfig): WorkerLike {
const worker = this.factory(config.url, {
type: config.type ?? "classic",
});
worker.addEventListener("message", (event) => {
const data = (event as MessageEvent).data as WorkerMessage;
this.handleMessage(config.name, data);
});
worker.addEventListener("error", (event) => {
this.handleError(
config.name,
new Error((event as ErrorEvent).message || "Worker error")
);
});
worker.addEventListener("messageerror", (event) => {
this.handleError(
config.name,
new Error(
(event as ErrorEvent).message || "Worker messageerror"
)
);
});
return worker;
}
protected override terminateTransport(transport: WorkerLike): void {
transport.terminate();
}
protected override postToTransport(
transport: WorkerLike,
message: unknown,
transfer: Transferable[] | undefined
): void {
if (transfer !== undefined && transfer.length > 0) {
transport.postMessage(message, transfer);
return;
}
transport.postMessage(message);
}
}

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,53 @@
import type {
WorkerConfig,
WorkerPoolConfig,
} from "../types/web-workers.types.js";
/** Internal slot bookkeeping for {@link WorkerPool}. */
export interface PoolSlot {
readonly name: string;
restarts: number;
pendingCount: number;
}
/** Internal queued task entry. */
export interface QueuedTask {
readonly type: string;
readonly payload: unknown;
readonly options:
| {
readonly timeout?: number;
readonly signal?: AbortSignal;
readonly transfer?: Transferable[];
}
| undefined;
readonly resolve: (value: unknown) => void;
readonly reject: (error: Error) => void;
}
/**
* Build a {@link WorkerConfig} for a pool slot from the pool config + slot
* name. Excludes `undefined` keys so we play nicely with
* `exactOptionalPropertyTypes`.
*/
export function slotConfig(
poolConfig: WorkerPoolConfig,
name: string,
defaultTimeoutMs: number
): WorkerConfig {
const cfg: {
-readonly [K in keyof WorkerConfig]: WorkerConfig[K];
} = {
name,
url: poolConfig.url,
timeout: poolConfig.taskTimeout ?? defaultTimeoutMs,
};
if (poolConfig.type !== undefined) cfg.type = poolConfig.type;
if (poolConfig.maxPendingRequests !== undefined) {
cfg.maxPendingRequests = poolConfig.maxPendingRequests;
}
if (poolConfig.circuitBreaker !== undefined) {
cfg.circuitBreaker = poolConfig.circuitBreaker;
}
return cfg;
}

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,44 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { pickSlot } from "./scheduler.js";
describe("pickSlot", () => {
it("round-robin rotates", { timeout: 5000 }, () => {
const slots = [
{ name: "a", pendingCount: 0 },
{ name: "b", pendingCount: 0 },
{ name: "c", pendingCount: 0 },
];
const r1 = pickSlot("round-robin", slots, 0);
const r2 = pickSlot("round-robin", slots, r1?.nextIndex ?? 0);
const r3 = pickSlot("round-robin", slots, r2?.nextIndex ?? 0);
const r4 = pickSlot("round-robin", slots, r3?.nextIndex ?? 0);
expect(r1?.slot.name).toBe("a");
expect(r2?.slot.name).toBe("b");
expect(r3?.slot.name).toBe("c");
expect(r4?.slot.name).toBe("a");
});
it("least-busy picks min pending", { timeout: 5000 }, () => {
const slots = [
{ name: "a", pendingCount: 5 },
{ name: "b", pendingCount: 1 },
{ name: "c", pendingCount: 3 },
];
const r = pickSlot("least-busy", slots, 0);
expect(r?.slot.name).toBe("b");
});
it("least-busy ties → first", { timeout: 5000 }, () => {
const slots = [
{ name: "a", pendingCount: 2 },
{ name: "b", pendingCount: 2 },
];
const r = pickSlot("least-busy", slots, 0);
expect(r?.slot.name).toBe("a");
});
it("empty slot list returns undefined", { timeout: 5000 }, () => {
expect(pickSlot("round-robin", [], 0)).toBeUndefined();
expect(pickSlot("least-busy", [], 0)).toBeUndefined();
});
});

View File

@@ -0,0 +1,34 @@
/** Scheduler strategy name. */
export type SchedulerStrategy = "round-robin" | "least-busy";
/** Lightweight projection of a pool slot used by the scheduler. */
export interface SchedulerSlot {
readonly name: string;
readonly pendingCount: number;
}
/**
* Pick the next slot for a new submit. `round-robin` rotates monotonically;
* `least-busy` picks the slot with the smallest pending-request count
* (ties broken by index). Returns `undefined` when the slot list is empty.
*/
export function pickSlot(
strategy: SchedulerStrategy,
slots: readonly SchedulerSlot[],
nextRoundRobinIndex: number
): { slot: SchedulerSlot; nextIndex: number } | undefined {
if (slots.length === 0) return undefined;
if (strategy === "round-robin") {
const idx = nextRoundRobinIndex % slots.length;
const slot = slots[idx] as SchedulerSlot;
return { slot, nextIndex: (idx + 1) % slots.length };
}
let best = slots[0] as SchedulerSlot;
for (let i = 1; i < slots.length; i += 1) {
const candidate = slots[i] as SchedulerSlot;
if (candidate.pendingCount < best.pendingCount) {
best = candidate;
}
}
return { slot: best, nextIndex: nextRoundRobinIndex };
}

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.

View File

@@ -0,0 +1,400 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WEB_WORKERS_CODES, WebWorkersError } from "../errors/errors.js";
import type {
WorkerFactory,
WorkerLike,
WorkerMessage,
} from "../types/web-workers.types.js";
import { WorkerPool } from "./worker-pool.js";
/** Auto-replying worker: echoes any postMessage as a matching response. */
class EchoWorker implements WorkerLike {
public terminated = false;
public messages: Array<{ message: unknown }> = [];
private readonly listeners = new Map<
string,
Array<(event: MessageEvent | ErrorEvent) => void>
>();
private autoReply = true;
postMessage(message: unknown): void {
this.messages.push({ message });
if (this.autoReply) {
const msg = message as WorkerMessage<unknown>;
queueMicrotask(() => {
for (const l of this.listeners.get("message") ?? []) {
l({
data: {
type: msg.type,
payload: msg.payload,
id: msg.id,
timestamp: Date.now(),
},
} as unknown as MessageEvent);
}
});
}
}
terminate(): void {
this.terminated = true;
}
addEventListener(
type: "message" | "error" | "messageerror",
listener: (event: MessageEvent | ErrorEvent) => void
): void {
const list = this.listeners.get(type) ?? [];
list.push(listener);
this.listeners.set(type, list);
}
fireError(message: string): void {
for (const l of this.listeners.get("error") ?? []) {
l({ message } as ErrorEvent);
}
}
setAutoReply(v: boolean): void {
this.autoReply = v;
}
}
function makeFactory(): {
factory: WorkerFactory;
workers: EchoWorker[];
} {
const workers: EchoWorker[] = [];
const factory: WorkerFactory = () => {
const w = new EchoWorker();
workers.push(w);
return w;
};
return { factory, workers };
}
describe("WorkerPool", () => {
it("rejects size <= 0", { timeout: 5000 }, () => {
const f = makeFactory();
expect(
() =>
new WorkerPool({ url: "w.js", size: 0 }, { factory: f.factory })
).toThrowError(WebWorkersError);
});
it("size() returns slot count", { timeout: 5000 }, () => {
const f = makeFactory();
const pool = new WorkerPool(
{ url: "w.js", size: 3 },
{ factory: f.factory }
);
expect(pool.size()).toBe(3);
expect(f.workers).toHaveLength(3);
pool.terminate();
});
it("round-robin dispatch", { timeout: 5000 }, async () => {
const f = makeFactory();
const pool = new WorkerPool(
{ url: "w.js", size: 3, scheduler: "round-robin" },
{ factory: f.factory }
);
const results = await Promise.all(
Array.from({ length: 6 }, (_, i) =>
pool.submit<number, number>("op", i)
)
);
expect(results).toEqual([0, 1, 2, 3, 4, 5]);
for (const w of f.workers) {
expect(w.messages.length).toBe(2);
}
pool.terminate();
});
it("least-busy dispatch", { timeout: 5000 }, async () => {
const f = makeFactory();
// Disable auto-reply on worker 0 to saturate it.
const pool = new WorkerPool(
{ url: "w.js", size: 2, scheduler: "least-busy" },
{ factory: f.factory }
);
f.workers[0]?.setAutoReply(false);
pool.submit("op", 1).catch(() => {});
pool.submit("op", 2).catch(() => {});
// give microtasks a tick
await Promise.resolve();
expect(f.workers[0]?.messages.length).toBe(1);
expect(f.workers[1]?.messages.length).toBe(1);
pool.terminate();
});
it("queue overflow rejects", { timeout: 5000 }, async () => {
const f = makeFactory();
const pool = new WorkerPool(
{
url: "w.js",
size: 1,
maxConcurrent: 1,
maxQueueDepth: 1,
},
{ factory: f.factory }
);
f.workers[0]?.setAutoReply(false);
pool.submit("op", 1).catch(() => {});
const queued = pool.submit("op", 2);
await expect(pool.submit("op", 3)).rejects.toMatchObject({
code: WEB_WORKERS_CODES.QUEUE_FULL,
});
// Cleanup queued task by re-enabling and finishing
f.workers[0]?.setAutoReply(true);
pool.terminate();
await expect(queued).rejects.toBeInstanceOf(WebWorkersError);
});
it("queueDepth + activeCount accessors", {
timeout: 5000,
}, async () => {
const f = makeFactory();
const pool = new WorkerPool(
{ url: "w.js", size: 1, maxConcurrent: 1 },
{ factory: f.factory }
);
f.workers[0]?.setAutoReply(false);
pool.submit("op", 1).catch(() => {});
pool.submit("op", 2).catch(() => {});
expect(pool.activeCount()).toBe(1);
expect(pool.queueDepth()).toBe(1);
pool.terminate();
});
it("uses default maxRestarts when omitted", {
timeout: 5000,
}, async () => {
const f = makeFactory();
const restarts: number[] = [];
const pool = new WorkerPool(
{ url: "w.js", size: 1 },
{ factory: f.factory }
);
pool.events.on("worker:restarted", (p) => restarts.push(p.attempt));
f.workers[0]?.fireError("boom");
await Promise.resolve();
expect(restarts[0]).toBe(1);
pool.terminate();
});
it("restart on crash", { timeout: 5000 }, async () => {
const f = makeFactory();
const restarts: number[] = [];
const pool = new WorkerPool(
{ url: "w.js", size: 1, maxRestarts: 5 },
{ factory: f.factory }
);
pool.events.on("worker:restarted", (p) => restarts.push(p.attempt));
f.workers[0]?.fireError("boom");
await Promise.resolve();
expect(restarts[0]).toBe(1);
expect(f.workers.length).toBeGreaterThanOrEqual(2);
pool.terminate();
});
it("restart exhausted removes slot", { timeout: 5000 }, async () => {
const f = makeFactory();
const pool = new WorkerPool(
{ url: "w.js", size: 1, maxRestarts: 1 },
{ factory: f.factory }
);
f.workers[0]?.fireError("boom1");
await Promise.resolve();
// After first restart, there's a new worker at index 1
f.workers[1]?.fireError("boom2");
await Promise.resolve();
// After exhausting restarts, slot is removed
expect(pool.size()).toBe(0);
pool.terminate();
});
it("restart exhausted rejects queued tasks", {
timeout: 5000,
}, async () => {
const f = makeFactory();
const pool = new WorkerPool(
{
url: "w.js",
size: 1,
maxRestarts: 0,
maxConcurrent: 1,
},
{ factory: f.factory }
);
f.workers[0]?.setAutoReply(false);
const inflight = pool.submit("op", 1);
const queued = pool.submit("op", 2);
f.workers[0]?.fireError("die");
await Promise.resolve();
await expect(queued).rejects.toMatchObject({
code: WEB_WORKERS_CODES.RESTART_EXHAUSTED,
});
await expect(inflight).rejects.toBeInstanceOf(WebWorkersError);
});
it("handleSlotError for removed slot is a no-op", {
timeout: 5000,
}, async () => {
const f = makeFactory();
const pool = new WorkerPool(
{ url: "w.js", size: 1, maxRestarts: 0 },
{ factory: f.factory }
);
f.workers[0]?.fireError("die");
await Promise.resolve();
// Slot already removed; calling the internal handler again is safe.
(
pool as unknown as { handleSlotError: (n: string) => void }
).handleSlotError("ghost-slot");
expect(pool.size()).toBe(0);
});
it("terminate tolerates already-terminated transports", {
timeout: 5000,
}, () => {
const f = makeFactory();
const pool = new WorkerPool(
{ url: "w.js", size: 1 },
{ factory: f.factory }
);
// Pre-terminate the inner manager's worker so pool.terminate hits the
// catch arm of terminateIfPresent.
const innerManager = (
pool as unknown as { manager: { terminate: (n: string) => void } }
).manager;
const slotName = (pool as unknown as { slots: Array<{ name: string }> })
.slots[0]!.name;
innerManager.terminate(slotName);
expect(() => pool.terminate()).not.toThrow();
});
it("rejection on no live slots", { timeout: 5000 }, async () => {
const f = makeFactory();
const pool = new WorkerPool(
{ url: "w.js", size: 1, maxRestarts: 0 },
{ factory: f.factory }
);
f.workers[0]?.fireError("boom");
await Promise.resolve();
await expect(pool.submit("op", 1)).rejects.toMatchObject({
code: WEB_WORKERS_CODES.RESTART_EXHAUSTED,
});
});
it("submit on terminated pool rejects", {
timeout: 5000,
}, async () => {
const f = makeFactory();
const pool = new WorkerPool(
{ url: "w.js", size: 1 },
{ factory: f.factory }
);
pool.terminate();
await expect(pool.submit("op", 1)).rejects.toMatchObject({
code: WEB_WORKERS_CODES.WORKER_TERMINATED,
});
});
it("queue drains as workers free up", {
timeout: 5000,
}, async () => {
const f = makeFactory();
const pool = new WorkerPool(
{ url: "w.js", size: 1, maxConcurrent: 1 },
{ factory: f.factory }
);
const p1 = pool.submit<number, number>("op", 1);
const p2 = pool.submit<number, number>("op", 2);
expect(await p1).toBe(1);
expect(await p2).toBe(2);
pool.terminate();
});
it("health check uses default interval when omitted", {
timeout: 5000,
}, () => {
const f = makeFactory();
const pool = new WorkerPool(
{ url: "w.js", size: 1, healthCheck: () => true },
{ factory: f.factory }
);
// Default 30s interval — terminate immediately so the timer never fires.
pool.terminate();
expect(pool.size()).toBe(0);
});
it("health check passing keeps slot", {
timeout: 5000,
}, async () => {
const f = makeFactory();
const restarted: string[] = [];
const pool = new WorkerPool(
{
url: "w.js",
size: 1,
healthCheck: () => true,
healthCheckIntervalMs: 5,
maxRestarts: 3,
},
{ factory: f.factory }
);
pool.events.on("worker:restarted", (p) => restarted.push(p.name));
await new Promise((r) => setTimeout(r, 30));
expect(restarted).toEqual([]);
pool.terminate();
});
it("health check failure triggers restart", { timeout: 5000 }, async () => {
const f = makeFactory();
const restarted: string[] = [];
const pool = new WorkerPool(
{
url: "w.js",
size: 1,
healthCheck: () => false,
healthCheckIntervalMs: 5,
maxRestarts: 3,
},
{ factory: f.factory }
);
pool.events.on("worker:restarted", (p) => restarted.push(p.name));
await new Promise((r) => setTimeout(r, 30));
expect(restarted.length).toBeGreaterThanOrEqual(1);
pool.terminate();
});
it("forwards observability to manager", { timeout: 5000 }, () => {
const f = makeFactory();
const pool = new WorkerPool(
{
url: "w.js",
size: 1,
type: "module",
maxPendingRequests: 16,
circuitBreaker: { failureThreshold: 5, resetMs: 1000 },
},
{ factory: f.factory }
);
expect(pool.size()).toBe(1);
pool.terminate();
});
it("submit forwards signal + transfer", { timeout: 5000 }, async () => {
const f = makeFactory();
const pool = new WorkerPool(
{ url: "w.js", size: 1 },
{ factory: f.factory }
);
const ctrl = new AbortController();
const buf = new ArrayBuffer(2);
const promise = pool.submit("op", buf, {
transfer: [buf],
signal: ctrl.signal,
timeout: 5000,
});
await promise;
pool.terminate();
});
});

View File

@@ -0,0 +1,282 @@
import type { EventBus } from "@suaveplan/events";
import type { Clock, IdGenerator } from "@suaveplan/types";
import { DEFAULT_TIMEOUT_MS } from "../base-manager/base-manager.types.js";
import { defaultClock, defaultIdGenerator } from "../base-manager/helpers.js";
import { WEB_WORKERS_CODES, WebWorkersError } from "../errors/errors.js";
import { createWebWorkersObservability } from "../observability/observability.js";
import type { WebWorkersObservability } from "../observability/observability.types.js";
import type {
SubmitOptions,
WorkerFactory,
WorkerManagerEventMap,
WorkerPoolConfig,
} from "../types/web-workers.types.js";
import { WorkerManager } from "../worker-manager/worker-manager.js";
import { type PoolSlot, type QueuedTask, slotConfig } from "./internals.js";
import { pickSlot, type SchedulerSlot } from "./scheduler.js";
const DEFAULT_MAX_CONCURRENT = Number.POSITIVE_INFINITY;
const DEFAULT_MAX_QUEUE_DEPTH = 1024;
const DEFAULT_MAX_RESTARTS = 3;
const DEFAULT_HEALTHCHECK_INTERVAL_MS = 30_000;
/** Options accepted by {@link WorkerPool}. */
export interface WorkerPoolOptions {
readonly factory: WorkerFactory;
readonly clock?: Clock;
readonly idGenerator?: IdGenerator;
readonly observability?: WebWorkersObservability;
}
/**
* Fixed-size worker pool with FIFO task queue, round-robin / least-busy
* scheduling, restart-on-crash supervision, optional health probe, and
* backpressure via `maxQueueDepth`. Delegates per-worker RPC state to a
* private {@link WorkerManager} so it benefits from the same AbortSignal /
* transfer-list / circuit-breaker semantics.
*/
export class WorkerPool {
private readonly config: WorkerPoolConfig;
private readonly clock: Clock;
private readonly idGenerator: IdGenerator;
private readonly obs: WebWorkersObservability;
private readonly manager: WorkerManager;
private readonly slots: PoolSlot[] = [];
private readonly queue: QueuedTask[] = [];
private nextRoundRobin = 0;
private active = 0;
private terminated = false;
private healthTimer: ReturnType<typeof setInterval> | undefined;
public readonly events: EventBus<WorkerManagerEventMap>;
public constructor(config: WorkerPoolConfig, opts: WorkerPoolOptions) {
if (config.size <= 0) {
throw new WebWorkersError(
WEB_WORKERS_CODES.QUEUE_FULL,
"WorkerPool size must be > 0"
);
}
this.config = config;
this.clock = opts.clock ?? defaultClock;
this.idGenerator = opts.idGenerator ?? defaultIdGenerator;
this.obs = opts.observability ?? createWebWorkersObservability();
this.manager = new WorkerManager({
factory: opts.factory,
clock: this.clock,
idGenerator: this.idGenerator,
observability: this.obs,
});
this.events = this.manager.events;
this.manager.onError((name) => this.handleSlotError(name));
for (let i = 0; i < config.size; i += 1) this.spawnSlot(i);
const interval =
config.healthCheckIntervalMs ?? DEFAULT_HEALTHCHECK_INTERVAL_MS;
if (config.healthCheck !== undefined) {
this.healthTimer = setInterval(() => {
void this.runHealthChecks();
}, interval);
}
}
public size(): number {
return this.slots.length;
}
public activeCount(): number {
return this.active;
}
public queueDepth(): number {
return this.queue.length;
}
public terminate(): void {
this.terminated = true;
if (this.healthTimer !== undefined) clearInterval(this.healthTimer);
for (const slot of this.slots) {
this.terminateIfPresent(slot.name);
}
this.slots.length = 0;
for (const task of this.queue) {
task.reject(
new WebWorkersError(
WEB_WORKERS_CODES.WORKER_TERMINATED,
"WorkerPool terminated while task was queued"
)
);
}
this.queue.length = 0;
}
public submit<TReq, TRes>(
type: string,
payload: TReq,
options?: SubmitOptions
): Promise<TRes> {
if (this.terminated) {
return Promise.reject(
new WebWorkersError(
WEB_WORKERS_CODES.WORKER_TERMINATED,
"WorkerPool is terminated"
)
);
}
if (this.slots.length === 0) {
return Promise.reject(
new WebWorkersError(
WEB_WORKERS_CODES.RESTART_EXHAUSTED,
"WorkerPool has no live slots"
)
);
}
const maxConcurrent =
this.config.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
this.obs.histograms.queueDepth.record(this.queue.length);
this.obs.histograms.poolActiveWorkers.record(this.active);
if (this.active < maxConcurrent) {
return this.dispatch<TReq, TRes>(type, payload, options);
}
return this.enqueue<TReq, TRes>(type, payload, options);
}
private enqueue<TReq, TRes>(
type: string,
payload: TReq,
options: SubmitOptions | undefined
): Promise<TRes> {
const maxQueueDepth =
this.config.maxQueueDepth ?? DEFAULT_MAX_QUEUE_DEPTH;
if (this.queue.length >= maxQueueDepth) {
return Promise.reject(
new WebWorkersError(
WEB_WORKERS_CODES.QUEUE_FULL,
`WorkerPool queue is full (${maxQueueDepth})`
)
);
}
return new Promise<TRes>((resolve, reject) => {
this.queue.push({
type,
payload,
options,
resolve: resolve as (v: unknown) => void,
reject,
});
});
}
private dispatch<TReq, TRes>(
type: string,
payload: TReq,
options: SubmitOptions | undefined
): Promise<TRes> {
const projection: SchedulerSlot[] = this.slots.map((s) => ({
name: s.name,
pendingCount: s.pendingCount,
}));
const pick = pickSlot(
this.config.scheduler ?? "round-robin",
projection,
this.nextRoundRobin
) as { slot: SchedulerSlot; nextIndex: number };
this.nextRoundRobin = pick.nextIndex;
const slot = this.slots.find(
(s) => s.name === pick.slot.name
) as PoolSlot;
this.active += 1;
slot.pendingCount += 1;
const reqOpts: {
timeout?: number;
signal?: AbortSignal;
transfer?: Transferable[];
} = {};
const timeout = options?.timeout ?? this.config.taskTimeout;
if (timeout !== undefined) reqOpts.timeout = timeout;
if (options?.signal !== undefined) reqOpts.signal = options.signal;
if (options?.transfer !== undefined)
reqOpts.transfer = options.transfer;
return this.manager
.sendRequest<TReq, TRes>(slot.name, type, payload, reqOpts)
.finally(() => {
this.active = Math.max(0, this.active - 1);
slot.pendingCount = Math.max(0, slot.pendingCount - 1);
this.drainQueue();
});
}
private drainQueue(): void {
if (this.terminated) return;
const maxConcurrent =
this.config.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
while (
this.queue.length > 0 &&
this.active < maxConcurrent &&
this.slots.length > 0
) {
const task = this.queue.shift() as QueuedTask;
this.dispatch(task.type, task.payload, task.options).then(
task.resolve,
task.reject
);
}
}
private spawnSlot(index: number): void {
const name = `pool-${index}-${this.idGenerator()}`;
this.manager.create(slotConfig(this.config, name, DEFAULT_TIMEOUT_MS));
this.slots.push({ name, restarts: 0, pendingCount: 0 });
}
private handleSlotError(name: string): void {
const slot = this.slots.find((s) => s.name === name);
if (slot === undefined) return;
this.terminateIfPresent(name);
const maxRestarts = this.config.maxRestarts ?? DEFAULT_MAX_RESTARTS;
slot.restarts += 1;
this.obs.counters.restartsTotal.add(1);
if (slot.restarts > maxRestarts) {
this.removeSlot(slot);
return;
}
this.manager.create(
slotConfig(this.config, slot.name, DEFAULT_TIMEOUT_MS)
);
slot.pendingCount = 0;
this.events.emit("worker:restarted", {
name: slot.name,
attempt: slot.restarts,
});
}
private removeSlot(slot: PoolSlot): void {
const idx = this.slots.indexOf(slot);
this.slots.splice(idx, 1);
for (const task of this.queue.splice(0, this.queue.length)) {
task.reject(
new WebWorkersError(
WEB_WORKERS_CODES.RESTART_EXHAUSTED,
`WorkerPool slot "${slot.name}" exceeded maxRestarts`
)
);
}
}
private terminateIfPresent(name: string): void {
try {
this.manager.terminate(name);
} catch {
/* slot already removed from manager — safe to ignore. */
}
}
private async runHealthChecks(): Promise<void> {
const probe = this.config.healthCheck as (
name: string
) => boolean | Promise<boolean>;
for (const slot of [...this.slots]) {
const ok = await Promise.resolve(probe(slot.name));
if (!ok) this.handleSlotError(slot.name);
}
}
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,60 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/web-workers",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"errors/errors": "src/errors/errors.ts",
"types/web-workers.types": "src/types/web-workers.types.ts",
"observability/observability":
"src/observability/observability.ts",
"base-manager/base-manager":
"src/base-manager/base-manager.ts",
"worker-manager/worker-manager":
"src/worker-manager/worker-manager.ts",
"shared-worker-manager/shared-worker-manager":
"src/shared-worker-manager/shared-worker-manager.ts",
"worker-pool/worker-pool": "src/worker-pool/worker-pool.ts",
"worker-pool/scheduler": "src/worker-pool/scheduler.ts",
"message-bus/message-bus": "src/message-bus/message-bus.ts",
"streaming/stream": "src/streaming/stream.ts",
"circuit-breaker/circuit-breaker":
"src/circuit-breaker/circuit-breaker.ts",
"helpers/define-worker": "src/helpers/define-worker.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/opentelemetry",
"@suaveplan/types",
"@opentelemetry/api",
"tslib",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,7 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({
environment: "happy-dom",
fileParallelism: false,
testTimeout: 5000,
});