Initial Commit
This commit is contained in:
96
packages/foundation/data/web-workers/README.md
Normal file
96
packages/foundation/data/web-workers/README.md
Normal 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.
|
||||
|
||||
122
packages/foundation/data/web-workers/package.json
Normal file
122
packages/foundation/data/web-workers/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
# `base-manager`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`base-manager.ts` declares `BaseWorkerManager<TTransport>`, the abstract
|
||||
class that hosts everything shared between the classic-Worker manager
|
||||
(`WorkerManager`) and the SharedWorker manager (`SharedWorkerManager`).
|
||||
Both concrete classes extend this base and only override
|
||||
`createTransport`, `terminateTransport`, and `postToTransport` — the rest
|
||||
of the lifecycle, RPC state, AbortSignal wiring, transfer-list validation,
|
||||
backpressure ceiling, circuit-breaker integration, OTel emission, and
|
||||
`EventBus` lifecycle events all live here.
|
||||
|
||||
Centralising this logic is what makes the contract testable in isolation
|
||||
and identical across transports: the same `terminate-rejects-pending`
|
||||
semantics, the same backpressure code, the same OTel counter increments
|
||||
fire regardless of whether the underlying transport is a `Worker` or a
|
||||
SharedWorker port.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-name worker registry with `idle / busy / error / terminated` status.
|
||||
- Pending-request map with timeout + AbortSignal + backpressure ceiling
|
||||
(`maxPendingRequests`).
|
||||
- Per-worker circuit breaker integration (`config.circuitBreaker`).
|
||||
- Transfer-list validation (rejects non-array `transfer` with `INVALID_TRANSFER`).
|
||||
- `EventBus<WorkerManagerEventMap>` emission on every lifecycle event.
|
||||
- OTel counter increments on every send / receive / create / terminate.
|
||||
- RPC latency histogram observation on every fulfilled request.
|
||||
- Multi-handler `onError` registry.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
`BaseWorkerManager` is abstract — consumers always work with `WorkerManager`
|
||||
or `SharedWorkerManager` directly. The shared API surface that
|
||||
`BaseWorkerManager` exposes is therefore consumed via the subclasses:
|
||||
|
||||
```ts
|
||||
import { WorkerManager } from "@suaveplan/web-workers";
|
||||
|
||||
const manager = new WorkerManager({ factory });
|
||||
manager.create({ name: "hash", url: WORKER_URL });
|
||||
const digest = await manager.sendRequest<string, string>(
|
||||
"hash",
|
||||
"sha256",
|
||||
"hello"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Subclassing for a custom transport (e.g. a `MessageChannel`-backed in-process
|
||||
worker):
|
||||
|
||||
```ts
|
||||
import {
|
||||
BaseWorkerManager,
|
||||
type BaseManagerOptions,
|
||||
type WorkerConfig,
|
||||
type WorkerMessage,
|
||||
} from "@suaveplan/web-workers";
|
||||
|
||||
interface ChannelTransport {
|
||||
readonly port: MessagePort;
|
||||
}
|
||||
|
||||
class MessageChannelManager extends BaseWorkerManager<ChannelTransport> {
|
||||
constructor(opts: BaseManagerOptions) {
|
||||
super(opts);
|
||||
}
|
||||
protected override createTransport(config: WorkerConfig): ChannelTransport {
|
||||
const { port1, port2 } = new MessageChannel();
|
||||
port1.onmessage = (event) => {
|
||||
this.handleMessage(
|
||||
config.name,
|
||||
event.data as WorkerMessage
|
||||
);
|
||||
};
|
||||
port1.start();
|
||||
// hand `port2` to the worker side somehow (e.g. via postMessage)
|
||||
return { port: port1 };
|
||||
}
|
||||
protected override terminateTransport(t: ChannelTransport): void {
|
||||
t.port.close();
|
||||
}
|
||||
protected override postToTransport(
|
||||
t: ChannelTransport,
|
||||
message: unknown,
|
||||
transfer: Transferable[] | undefined
|
||||
): void {
|
||||
if (transfer !== undefined && transfer.length > 0) {
|
||||
t.port.postMessage(message, transfer);
|
||||
} else {
|
||||
t.port.postMessage(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `BaseWorkerManager<TTransport>` — abstract class.
|
||||
- `events: EventBus<WorkerManagerEventMap>` — lifecycle event bus.
|
||||
- `create(config: WorkerConfig)` — throws `DUPLICATE_WORKER`.
|
||||
- `terminate(name)` — throws `WORKER_NOT_FOUND`; rejects pending with
|
||||
`WORKER_TERMINATED`.
|
||||
- `terminateAll()` — iterates `terminate(name)` for each.
|
||||
- `has(name): boolean`.
|
||||
- `getStatus(name): WorkerStatus` — throws `WORKER_NOT_FOUND`.
|
||||
- `getWorkerNames(): string[]`.
|
||||
- `onError(handler: (name, error) => void): void`.
|
||||
- `postMessage<T>(name, type, payload, options?)` — fire-and-forget;
|
||||
validates transfer.
|
||||
- `sendRequest<TReq, TRes>(name, type, payload, options?)` — request /
|
||||
response with timeout, signal, transfer, backpressure, circuit breaker.
|
||||
- Protected abstract methods: `createTransport`, `terminateTransport`,
|
||||
`postToTransport`.
|
||||
- Protected helpers: `handleMessage`, `handleError`, `getEntry`.
|
||||
- `ManagedEntry<TTransport>` — exported internal entry type for subclass
|
||||
visibility.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The constructor is `protected` — only subclasses can instantiate. The
|
||||
internal `PendingEntry` interface stores both the `timer` handle and the
|
||||
`cleanupSignal` closure so both can be torn down in any of the three exit
|
||||
paths (success, abort, terminate, timeout). The `entry.pending.delete(id)`
|
||||
call happens unconditionally in both the timer + abort paths so a single
|
||||
race where the abort fires after the timer body started running still
|
||||
leaves the pending map in a consistent state. `dispatchRequest` is split
|
||||
out of `sendRequest` to keep both methods under the 50-line soft limit and
|
||||
to give `sendRequest` a clean place to do the synchronous pre-flight
|
||||
(backpressure check, pre-aborted signal, transfer validation, breaker
|
||||
probe). The status field flips `busy → idle` only when the pending count
|
||||
hits zero, so concurrent in-flight requests don't prematurely reset the
|
||||
status.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,76 @@
|
||||
# `helpers`
|
||||
|
||||
## Purpose
|
||||
|
||||
`helpers.ts` co-locates the small utilities the base manager and the
|
||||
message bus both depend on: a default wall-clock implementation, a default
|
||||
id generator backed by `crypto.getRandomValues`, the transfer-list validator
|
||||
that raises `WEB_WORKERS.INVALID_TRANSFER`, and a one-shot abort-signal
|
||||
wiring helper that returns a cleanup closure.
|
||||
|
||||
Extracting them avoids duplication between `BaseWorkerManager` and
|
||||
`MessageBus` (both need clock, id-generator, and abort wiring) and keeps
|
||||
the base manager's main file under the 300-line ceiling.
|
||||
|
||||
## Features
|
||||
|
||||
- `defaultClock` — wall-clock `Date.now()` Clock implementation.
|
||||
- `defaultIdGenerator` — cryptographically random 16-hex-character ids.
|
||||
- `validateTransfer(transfer)` — passes through `undefined` and arrays,
|
||||
throws `WebWorkersError(INVALID_TRANSFER)` for anything else (string,
|
||||
object, number).
|
||||
- `wireAbortSignal(signal, onAbort)` — registers a one-shot abort listener
|
||||
and returns a cleanup function that removes it.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
defaultIdGenerator,
|
||||
validateTransfer,
|
||||
} from "@suaveplan/web-workers/base-manager";
|
||||
|
||||
const id = defaultIdGenerator();
|
||||
const transfer = validateTransfer(opts?.transfer);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { wireAbortSignal } from "@suaveplan/web-workers/base-manager";
|
||||
|
||||
function withAbort<T>(
|
||||
signal: AbortSignal | undefined,
|
||||
onTimeout: () => void,
|
||||
promise: Promise<T>
|
||||
): Promise<T> {
|
||||
const cleanup = wireAbortSignal(signal, onTimeout);
|
||||
return promise.finally(() => {
|
||||
cleanup?.();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `defaultClock: Clock` — `() => Date.now()`.
|
||||
- `defaultIdGenerator: IdGenerator` — `() => string` returning 16
|
||||
lowercase-hex chars (64 bits of entropy).
|
||||
- `validateTransfer(transfer: Transferable[] | undefined): Transferable[] | undefined`
|
||||
— pass-through on undefined / array; throws on non-array.
|
||||
- `wireAbortSignal(signal: AbortSignal | undefined, onAbort: () => void): (() => void) | undefined`
|
||||
— returns a cleanup closure or `undefined` when `signal` is undefined.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defaultIdGenerator` uses `crypto.getRandomValues(new Uint8Array(8))` and
|
||||
hex-encodes the bytes. 64 bits of entropy is sufficient for per-process
|
||||
RPC correlation (`birthday-paradox` collision probability is < 1e-9 for
|
||||
fewer than 10⁵ outstanding requests). `wireAbortSignal` uses
|
||||
`addEventListener('abort', listener, { once: true })` so the listener
|
||||
removes itself after firing — the returned cleanup is therefore safe to
|
||||
call any number of times. `validateTransfer` is intentionally permissive
|
||||
on the array's contents (it doesn't try to prove every element is actually
|
||||
`Transferable`); structured-clone will throw at `postMessage` time if a
|
||||
non-transferable slips in. Surfacing that error is the transport's job,
|
||||
not the validator's.
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
# `circuit-breaker`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`circuit-breaker.ts` exposes `CircuitBreaker`, a per-worker failure-
|
||||
isolating state machine the base manager uses to short-circuit
|
||||
`sendRequest` calls to workers that have failed `failureThreshold`
|
||||
consecutive times. The breaker transitions `closed → open` after the
|
||||
failure budget is exhausted, `open → half-open` once `resetMs` has
|
||||
elapsed, and either `closed` (on success) or back to `open` (on failure)
|
||||
from half-open.
|
||||
|
||||
Adding a breaker bounds the blast radius of a flaky worker: instead of
|
||||
every request waiting for the full timeout, the breaker rejects them
|
||||
synchronously with `WEB_WORKERS.CIRCUIT_OPEN` and lets the cool-down do
|
||||
its job. The base manager calls `beforeRequest` / `recordSuccess` /
|
||||
`recordFailure` at the right inflection points so consumers get the
|
||||
behaviour for free by adding `circuitBreaker: { ... }` to their
|
||||
`WorkerConfig`.
|
||||
|
||||
## Features
|
||||
|
||||
- Three-state state machine: `closed`, `open`, `half-open`.
|
||||
- Validates configuration (`failureThreshold > 0`, `resetMs >= 0`).
|
||||
- `beforeRequest` throws `WebWorkersError(CIRCUIT_OPEN)` when open and
|
||||
the cool-down hasn't elapsed; transitions to half-open when it has.
|
||||
- `recordSuccess` closes a half-open breaker and resets the consecutive
|
||||
failure counter.
|
||||
- `recordFailure` re-opens a half-open breaker immediately, or opens a
|
||||
closed breaker once the threshold is reached.
|
||||
- Injectable `Clock` for deterministic tests.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { WorkerManager } from "@suaveplan/web-workers";
|
||||
|
||||
const manager = new WorkerManager({ factory });
|
||||
manager.create({
|
||||
name: "flaky",
|
||||
url: WORKER_URL,
|
||||
circuitBreaker: { failureThreshold: 3, resetMs: 30_000 },
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { CircuitBreaker } from "@suaveplan/web-workers";
|
||||
|
||||
class GuardedTransport {
|
||||
private readonly breaker = new CircuitBreaker(
|
||||
{ failureThreshold: 5, resetMs: 60_000 },
|
||||
() => Date.now()
|
||||
);
|
||||
async call(op: () => Promise<unknown>): Promise<unknown> {
|
||||
this.breaker.beforeRequest();
|
||||
try {
|
||||
const result = await op();
|
||||
this.breaker.recordSuccess();
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.breaker.recordFailure();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class CircuitBreaker`.
|
||||
- `constructor(config: CircuitBreakerConfig, clock: Clock)`.
|
||||
- `get current: CircuitState`.
|
||||
- `beforeRequest(): void` — throws `WebWorkersError(CIRCUIT_OPEN)` when
|
||||
open and cool-down not elapsed.
|
||||
- `recordSuccess(): void`.
|
||||
- `recordFailure(): void`.
|
||||
- `type CircuitState = "closed" | "open" | "half-open"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The breaker is single-instance per worker — the base manager creates it
|
||||
in `create()` and holds it on the `ManagedEntry`. Failures only count
|
||||
when consecutive, so a successful request mid-failure-window resets the
|
||||
counter. The half-open state allows exactly one probe request through;
|
||||
the next call to `recordFailure` re-opens the breaker AND resets the
|
||||
cool-down timer so the next probe is `resetMs` away. `Clock` is injected
|
||||
(rather than reading `Date.now` directly) so tests can advance time
|
||||
deterministically without `vi.useFakeTimers()` machinery.
|
||||
@@ -0,0 +1,94 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
94
packages/foundation/data/web-workers/src/errors/errors.md
Normal file
94
packages/foundation/data/web-workers/src/errors/errors.md
Normal 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, ...>`.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
172
packages/foundation/data/web-workers/src/errors/errors.ts
Normal file
172
packages/foundation/data/web-workers/src/errors/errors.ts
Normal 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 });
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
# `define-worker`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`define-worker.ts` exposes `defineWorker()`, a small helper that wraps the
|
||||
canonical `new Worker(url, { type })` pattern in a reusable
|
||||
`WorkerFactory`. The base manager accepts a factory rather than a
|
||||
constructor so tests can inject in-process fakes; `defineWorker` is the
|
||||
production factory that consumers wire into `WorkerManager` / `WorkerPool`.
|
||||
|
||||
Centralising this pattern keeps the Vite / Webpack `new URL('./worker.ts', import.meta.url)`
|
||||
idiom in one place and removes the temptation to inline `new Worker(...)`
|
||||
at call sites, which would defeat the factory-injection seam.
|
||||
|
||||
## Features
|
||||
|
||||
- Returns a `WorkerFactory` bound to a default URL and `type`.
|
||||
- Per-invocation `type` override.
|
||||
- Optional `workerConstructor` override for tests (substitute a
|
||||
`MessageChannel`-backed fake, a Bun `Worker`, etc.).
|
||||
- Clear error when `globalThis.Worker` is unavailable (Node.js without
|
||||
the worker-threads polyfill, custom runtimes).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { defineWorker, WorkerManager } from "@suaveplan/web-workers";
|
||||
|
||||
const factory = defineWorker({
|
||||
url: new URL("./encode.worker.ts", import.meta.url),
|
||||
type: "module",
|
||||
});
|
||||
|
||||
const manager = new WorkerManager({ factory });
|
||||
manager.create({ name: "encode", url: "ignored-by-factory" });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
defineWorker,
|
||||
type WorkerConstructorLike,
|
||||
} from "@suaveplan/web-workers";
|
||||
|
||||
class TestWorker {
|
||||
constructor(url: string | URL) {
|
||||
this.url = url;
|
||||
}
|
||||
url: string | URL;
|
||||
postMessage() {}
|
||||
terminate() {}
|
||||
addEventListener() {}
|
||||
}
|
||||
|
||||
const factory = defineWorker({
|
||||
url: "spec://test",
|
||||
workerConstructor: TestWorker as unknown as WorkerConstructorLike,
|
||||
});
|
||||
|
||||
const w = factory("spec://test");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `defineWorker(opts: DefineWorkerOptions): WorkerFactory`.
|
||||
- `interface DefineWorkerOptions { url; type?; workerConstructor? }`.
|
||||
- `type WorkerConstructorLike = new (url, options?) => WorkerLike`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The factory accepts a `urlOverride` parameter so consumers can pass a
|
||||
per-create URL to the same factory (the manager passes `config.url` here).
|
||||
When the consumer constructs the factory with a default URL and the
|
||||
manager passes a different URL, the per-invocation URL wins — that's what
|
||||
lets a single factory drive multiple workers loaded from different
|
||||
scripts. `workerConstructor` is an escape hatch for non-browser
|
||||
environments and tests; the production path uses `globalThis.Worker`.
|
||||
When that's undefined the helper throws an explicit
|
||||
`Error("globalThis.Worker is undefined")` rather than the cryptic
|
||||
`TypeError: undefined is not a constructor` that JavaScript would
|
||||
otherwise produce — that diagnostic was specifically worth surfacing
|
||||
because the failure mode is common in Node-based test runs without
|
||||
appropriate polyfills.
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
80
packages/foundation/data/web-workers/src/index.test.ts
Normal file
80
packages/foundation/data/web-workers/src/index.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
98
packages/foundation/data/web-workers/src/index.ts
Normal file
98
packages/foundation/data/web-workers/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,107 @@
|
||||
# `message-bus`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`message-bus.ts` declares `MessageBus`, an in-process publish/subscribe +
|
||||
request/response message bus. It's the right tool when you want to
|
||||
decouple modules without actually crossing a worker boundary — every
|
||||
listener and handler runs synchronously on the main thread.
|
||||
|
||||
The bus complements the worker managers: managers handle cross-thread
|
||||
RPC over `postMessage`, while the bus handles same-thread routing.
|
||||
Modules that produce events (e.g. an UI store) publish to a topic, and
|
||||
modules that consume them (e.g. an analytics pipeline) subscribe.
|
||||
|
||||
## Features
|
||||
|
||||
- Topic-based pub/sub: `subscribe(topic, handler)`, `subscribeOnce`,
|
||||
`publish<T>(topic, payload)`, `broadcast<T>(payload)`.
|
||||
- Request/response: `registerHandler(topic, handler)`, `request(topic, payload, options?)`
|
||||
with `timeout` and `signal`.
|
||||
- Returns delivery counts from `publish` / `broadcast` so callers know how
|
||||
many subscribers received the message.
|
||||
- `subscriberCount`, `getTopics` (only topics with live subscribers),
|
||||
`clear` (drop everything + emit `bus:cleared`).
|
||||
- `EventBus<MessageBusEventMap>` lifecycle event channel.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MessageBus } from "@suaveplan/web-workers";
|
||||
|
||||
const bus = new MessageBus();
|
||||
bus.subscribe<UserEvent>("user-login", (event) =>
|
||||
analytics.track("login", event)
|
||||
);
|
||||
bus.publish("user-login", { userId, at: Date.now() });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MessageBus, WEB_WORKERS_CODES } from "@suaveplan/web-workers";
|
||||
|
||||
const bus = new MessageBus();
|
||||
|
||||
// Request handler with async work + abort support.
|
||||
bus.registerHandler<SearchQuery, SearchResult>(
|
||||
"search.execute",
|
||||
async (query) => {
|
||||
const docs = await searchIndex.query(query);
|
||||
return { hits: docs.length, results: docs };
|
||||
}
|
||||
);
|
||||
|
||||
const ctrl = new AbortController();
|
||||
try {
|
||||
const result = await bus.request<SearchQuery, SearchResult>(
|
||||
"search.execute",
|
||||
{ text: "hello", filter: { lang: "en" } },
|
||||
{ signal: ctrl.signal, timeout: 5_000 }
|
||||
);
|
||||
renderResults(result);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
(err as { code?: string }).code === WEB_WORKERS_CODES.REQUEST_TIMEOUT
|
||||
) {
|
||||
renderTimeout();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class MessageBus`.
|
||||
- `constructor(options?: MessageBusOptions)`.
|
||||
- `subscribe<T>(topic, handler): () => void`.
|
||||
- `subscribeOnce<T>(topic, handler): () => void`.
|
||||
- `publish<T>(topic, payload): number` (delivery count).
|
||||
- `broadcast<T>(payload): number`.
|
||||
- `registerHandler<TReq, TRes>(topic, handler)`.
|
||||
- `request<TReq, TRes>(topic, payload, options?: BusRequestOptions): Promise<TRes>`.
|
||||
- throws `WEB_WORKERS.HANDLER_NOT_FOUND`,
|
||||
`WEB_WORKERS.REQUEST_TIMEOUT`, `WEB_WORKERS.REQUEST_ABORTED`.
|
||||
- `subscriberCount(topic): number`, `getTopics(): string[]`,
|
||||
`clear(): void`.
|
||||
- `readonly events: EventBus<MessageBusEventMap>`.
|
||||
- `type MessageHandler<T> = (payload: T, message: WorkerMessage<T>) => void`.
|
||||
- `type RequestHandler<TReq, TRes> = (payload: TReq) => TRes | Promise<TRes>`.
|
||||
- `interface BusRequestOptions { timeout?: number; signal?: AbortSignal }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bus uses two separate maps: `subscriptions` (per-topic array of
|
||||
once/persistent handlers) and `requestHandlers` (single handler per topic).
|
||||
This mirrors the upstream API so consumers can pub/sub and request/respond
|
||||
without coupling the two channels. `subscribeOnce` is implemented by
|
||||
marking the entry `once: true` and filtering it out on the publish loop —
|
||||
the unsubscribe closure returned by `subscribeOnce` removes the entry
|
||||
proactively if the consumer wants to cancel before the first publish.
|
||||
`request` is `async` to keep the type contract clean, but the handler is
|
||||
invoked synchronously inside the promise body so consumers can rely on
|
||||
the handler running before the next microtask.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
# `observability`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`observability.ts` exposes `createWebWorkersObservability()`, the factory
|
||||
that builds the OpenTelemetry counter + histogram bundle every manager and
|
||||
pool consumes. The bundle is injected at construction time, which means a
|
||||
single OTel meter/tracer pair drives every managed worker, every pool slot,
|
||||
and every RPC in a process.
|
||||
|
||||
The package defaults to the no-op providers from `@opentelemetry/api` so
|
||||
consumers who don't wire telemetry pay zero cost. When telemetry is wired,
|
||||
metrics flow through the consumer's `MeterProvider` without further code
|
||||
changes on the manager side.
|
||||
|
||||
## Features
|
||||
|
||||
- Six counters: messages sent / received, workers created / terminated,
|
||||
restarts, errors.
|
||||
- Three histograms: RPC latency (ms), queue depth, pool active workers.
|
||||
- Single-pass construction: callers receive a `{ meter, tracer, counters, histograms }`
|
||||
bundle and pass it to manager / pool constructors via the
|
||||
`observability` option.
|
||||
- Pass-through behaviour: a fully-formed bundle on
|
||||
`WebWorkersObservabilityOptions.observability` short-circuits and returns
|
||||
the same reference so consumers can share a single bundle across multiple
|
||||
manager instances.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createWebWorkersObservability,
|
||||
WorkerManager,
|
||||
} from "@suaveplan/web-workers";
|
||||
|
||||
const obs = createWebWorkersObservability();
|
||||
const manager = new WorkerManager({
|
||||
factory: defineWorker({ url: WORKER_URL }),
|
||||
observability: obs,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
createWebWorkersObservability,
|
||||
WorkerPool,
|
||||
} from "@suaveplan/web-workers";
|
||||
|
||||
const meter = metrics.getMeter("my-app/render");
|
||||
const obs = createWebWorkersObservability({ meter });
|
||||
|
||||
const renderPool = new WorkerPool(
|
||||
{ url: RENDER_URL, size: 4, scheduler: "least-busy" },
|
||||
{
|
||||
factory: defineWorker({ url: RENDER_URL }),
|
||||
observability: obs,
|
||||
}
|
||||
);
|
||||
const audioPool = new WorkerPool(
|
||||
{ url: AUDIO_URL, size: 2 },
|
||||
{
|
||||
factory: defineWorker({ url: AUDIO_URL }),
|
||||
// share the same bundle so cross-pool metrics aggregate on a single
|
||||
// meter
|
||||
observability: obs,
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createWebWorkersObservability(opts?: WebWorkersObservabilityOptions): WebWorkersObservability`
|
||||
— builds a bundle on the supplied (or no-op) meter / tracer.
|
||||
- `WebWorkersObservabilityOptions = { meter?, tracer?, observability? }`
|
||||
— every field optional; `observability` short-circuits.
|
||||
- `WebWorkersObservability = { meter, tracer, counters, histograms }`.
|
||||
- `WebWorkersCounters` — `messagesSentTotal`, `messagesReceivedTotal`,
|
||||
`workersCreatedTotal`, `workersTerminatedTotal`, `restartsTotal`,
|
||||
`errorsTotal`.
|
||||
- `WebWorkersHistograms` — `rpcLatencyMs`, `queueDepth`, `poolActiveWorkers`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Instruments are created via `createCounter` / `createHistogram` from
|
||||
`@suaveplan/opentelemetry`, which guarantees consistent naming + unit
|
||||
conventions across Suaveplan. Counter names use the
|
||||
`web_workers_*_total` convention (Prometheus-compatible). Histograms use
|
||||
ms-valued buckets for latency and unit-less counts for depth gauges. The
|
||||
`tracer` field is exposed for consumers who want to wrap spans around
|
||||
manager methods — none of the package's own code currently creates spans
|
||||
since RPC calls would create one span per request which is rarely the
|
||||
right granularity (consumers should span the higher-level operation).
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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."
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,115 @@
|
||||
# `shared-worker-manager`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`shared-worker-manager.ts` declares `SharedWorkerManager`, the concrete
|
||||
SharedWorker manager. It extends `BaseWorkerManager` with a transport
|
||||
bundle (`{ worker, port }`) — `worker` is the `SharedWorkerLike` itself
|
||||
(used for the cross-port `error` event) and `port` is the
|
||||
`SharedWorkerPortLike` (`MessagePort`-shaped) used for `message` /
|
||||
`messageerror` events and outbound `postMessage` calls.
|
||||
|
||||
The shared API surface (`create` / `terminate` / `postMessage` /
|
||||
`sendRequest` / RPC options / lifecycle events) is identical to
|
||||
`WorkerManager` — only the transport lifecycle differs. SharedWorkers
|
||||
can't be `terminate()`d by a client; the closest equivalent is closing
|
||||
the port, which lets the SharedWorker shut down when the last port closes.
|
||||
|
||||
## Features
|
||||
|
||||
- Injectable `SharedWorkerFactory` for tests.
|
||||
- Calls `port.start()` exactly once on create to begin message delivery.
|
||||
- Wires `message` and `messageerror` on the port, plus `error` on the
|
||||
worker itself.
|
||||
- Calls `port.close()` on terminate.
|
||||
- Inherits the full RPC surface (timeout, signal, transfer, backpressure,
|
||||
circuit breaker, OTel, EventBus).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SharedWorkerManager } from "@suaveplan/web-workers";
|
||||
|
||||
const factory = (url: string | URL, opts?: { name?: string }) =>
|
||||
new SharedWorker(url, { type: "module", name: opts?.name });
|
||||
|
||||
const manager = new SharedWorkerManager({ factory });
|
||||
manager.create({ name: "global-state", url: SHARED_WORKER_URL });
|
||||
const state = await manager.sendRequest<void, AppState>(
|
||||
"global-state",
|
||||
"get-state",
|
||||
undefined
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createWebWorkersObservability,
|
||||
SharedWorkerManager,
|
||||
} from "@suaveplan/web-workers";
|
||||
|
||||
const obs = createWebWorkersObservability();
|
||||
const manager = new SharedWorkerManager({
|
||||
factory: (url, opts) =>
|
||||
new SharedWorker(url, {
|
||||
type: "module",
|
||||
name: opts?.name,
|
||||
}),
|
||||
observability: obs,
|
||||
});
|
||||
|
||||
manager.create({
|
||||
name: "tab-coordination",
|
||||
url: SHARED_URL,
|
||||
timeout: 5_000,
|
||||
maxPendingRequests: 32,
|
||||
});
|
||||
|
||||
manager.events.on("message:received", ({ name, message }) => {
|
||||
if (message.type === "tab-closed") forwardToUI(message.payload);
|
||||
});
|
||||
|
||||
const ctrl = new AbortController();
|
||||
window.addEventListener("beforeunload", () => ctrl.abort());
|
||||
|
||||
const ack = await manager.sendRequest<TabClaim, ClaimAck>(
|
||||
"tab-coordination",
|
||||
"claim-leader",
|
||||
{ tabId },
|
||||
{ signal: ctrl.signal }
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface SharedWorkerManagerOptions extends BaseManagerOptions`
|
||||
— adds the required `factory: SharedWorkerFactory`.
|
||||
- `class SharedWorkerManager extends BaseWorkerManager<SharedTransport>`
|
||||
where `SharedTransport = { worker, port }`. Public surface identical to
|
||||
`WorkerManager`.
|
||||
- Protected overrides:
|
||||
- `createTransport(config) → { worker, port }` — calls the factory,
|
||||
wires the listeners, calls `port.start()`.
|
||||
- `terminateTransport({ port }) → port.close()`.
|
||||
- `postToTransport(transport, message, transfer)` — same transfer
|
||||
handling as `WorkerManager`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
SharedWorkers don't expose a synchronous `terminate()` — `port.close()` is
|
||||
the closest available control. The SharedWorker process itself shuts down
|
||||
when all client ports are closed (browser-implementation specific). The
|
||||
`error` listener registers on the `SharedWorker` instance, not the port,
|
||||
because that's where the spec sends `MessagePort`-unrelated errors (e.g.
|
||||
script-load failures). `messageerror` lives on the port because it's
|
||||
emitted when the structured-clone fails on the receiving side, which is
|
||||
per-port. The transport bundle `{ worker, port }` exists because the base
|
||||
manager needs a single opaque handle, but `createTransport` needs to call
|
||||
methods on both the worker (for `error`) and the port (for `start`,
|
||||
`postMessage`, `close`, `message`, `messageerror`).
|
||||
@@ -0,0 +1,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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
107
packages/foundation/data/web-workers/src/streaming/stream.md
Normal file
107
packages/foundation/data/web-workers/src/streaming/stream.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
124
packages/foundation/data/web-workers/src/streaming/stream.ts
Normal file
124
packages/foundation/data/web-workers/src/streaming/stream.ts
Normal 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("");
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
# `web-workers.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
`web-workers.types.ts` is the zero-runtime type surface for the package. It
|
||||
declares the structural shapes managers, pools, the bus, and the streaming
|
||||
RPC depend on: `WorkerLike` / `SharedWorkerLike` / `SharedWorkerPortLike`
|
||||
DOM-subset interfaces, the `WorkerFactory` / `SharedWorkerFactory` injection
|
||||
seams, `WorkerConfig`, `WorkerPoolConfig`, RPC option bundles, and the two
|
||||
`EventBus<TMap>` event maps emitted by managers and the bus.
|
||||
|
||||
Keeping every shared type in one file means consumers can import the type
|
||||
contract via `@suaveplan/web-workers/types` without pulling in the manager
|
||||
or pool implementations — useful for shared protocol definitions between a
|
||||
host page and a worker entrypoint.
|
||||
|
||||
## Features
|
||||
|
||||
- DOM-subset interfaces (`WorkerLike`, `SharedWorkerLike`,
|
||||
`SharedWorkerPortLike`) so tests can inject in-process fakes without
|
||||
touching `globalThis.Worker`.
|
||||
- Factory function types (`WorkerFactory`, `SharedWorkerFactory`) used by
|
||||
every manager and pool.
|
||||
- Configuration types (`WorkerConfig`, `WorkerPoolConfig`,
|
||||
`CircuitBreakerConfig`).
|
||||
- Option bundles for `postMessage` / `sendRequest` / `sendStream` /
|
||||
`WorkerPool.submit` with `signal`, `timeout`, and `transfer` fields.
|
||||
- Message and frame types (`WorkerMessage`, `StreamFrame`).
|
||||
- Single-payload event maps (`WorkerManagerEventMap`, `MessageBusEventMap`)
|
||||
matching the `EventBus<TMap>` contract.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
WorkerConfig,
|
||||
WorkerFactory,
|
||||
} from "@suaveplan/web-workers";
|
||||
|
||||
const factory: WorkerFactory = (url, opts) =>
|
||||
new Worker(url, { type: opts?.type ?? "module" });
|
||||
|
||||
const config: WorkerConfig = {
|
||||
name: "encode",
|
||||
url: new URL("./encode.worker.ts", import.meta.url),
|
||||
timeout: 5_000,
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SendRequestOptions,
|
||||
WorkerManagerEventMap,
|
||||
} from "@suaveplan/web-workers/types";
|
||||
import type { EventBus } from "@suaveplan/events";
|
||||
|
||||
interface ChannelLike {
|
||||
readonly events: EventBus<WorkerManagerEventMap>;
|
||||
send<T>(
|
||||
name: string,
|
||||
type: string,
|
||||
payload: T,
|
||||
opts: SendRequestOptions
|
||||
): Promise<unknown>;
|
||||
}
|
||||
|
||||
function withCancellation(
|
||||
channel: ChannelLike,
|
||||
name: string,
|
||||
payload: unknown,
|
||||
timeoutMs: number,
|
||||
signal: AbortSignal
|
||||
): Promise<unknown> {
|
||||
return channel.send(name, "compute", payload, {
|
||||
timeout: timeoutMs,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `WorkerLike`, `SharedWorkerLike`, `SharedWorkerPortLike` — structural
|
||||
subsets of the DOM types.
|
||||
- `WorkerFactory = (url, opts?) => WorkerLike`,
|
||||
`SharedWorkerFactory = (url, opts?) => SharedWorkerLike`.
|
||||
- `WorkerStatus = "idle" | "busy" | "error" | "terminated"`.
|
||||
- `CircuitBreakerConfig = { failureThreshold: number; resetMs: number }`.
|
||||
- `WorkerConfig` — `{ name, url, type?, timeout?, maxPendingRequests?, circuitBreaker? }`.
|
||||
- `WorkerPoolConfig` — see source; carries pool size, scheduler, queue
|
||||
budget, restart budget, health-check probe.
|
||||
- `PostMessageOptions = { transfer?: Transferable[] }`,
|
||||
`SendRequestOptions = PostMessageOptions & { timeout?, signal? }`,
|
||||
`SendStreamOptions = PostMessageOptions & { signal? }`,
|
||||
`SubmitOptions = { timeout?, signal?, transfer? }`.
|
||||
- `WorkerMessage<T>` — `{ type, payload, id, timestamp, sequence? }`.
|
||||
- `StreamFrame<TChunk>` — `{ id, type, payload: { chunk } | { done: true }, timestamp, sequence? }`.
|
||||
- `WorkerManagerEventMap` — covers `worker:created`, `worker:terminated`,
|
||||
`worker:terminated:all`, `worker:error`, `worker:restarted`, `message:sent`,
|
||||
`message:received`, `request:timeout`, `request:fulfilled`.
|
||||
- `MessageBusEventMap` — `message:published`, `subscription:added`,
|
||||
`subscription:removed`, `bus:cleared`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The DOM-subset interfaces deliberately drop everything the package doesn't
|
||||
use (`onmessage`, `dispatchEvent`, etc.) so any in-process fake stays cheap.
|
||||
Event maps carry a `[key: string]: unknown` index signature so they satisfy
|
||||
the `EventMap = Record<string, unknown>` constraint of
|
||||
`@suaveplan/events`'s `EventBus<TMap>`. Every event payload is a single
|
||||
object — that's a deliberate change from the upstream variadic-args event
|
||||
map, since the `EventBus.on` listener signature is
|
||||
`(payload: TMap[K]) => void` (single payload, not spread).
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
# `worker-manager`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`worker-manager.ts` declares `WorkerManager`, the concrete classic-`Worker`
|
||||
manager. It extends `BaseWorkerManager<WorkerLike>` and implements the
|
||||
three transport hooks — `createTransport` (calls the injected
|
||||
`WorkerFactory` and wires `message` / `error` / `messageerror` listeners),
|
||||
`terminateTransport` (calls `worker.terminate()`), and `postToTransport`
|
||||
(calls `postMessage` with the optional transfer list).
|
||||
|
||||
Every cross-cutting concern (pending-request map, AbortSignal, transfer
|
||||
validation, backpressure, circuit breaker, OTel, EventBus emission) lives
|
||||
in the base class — `WorkerManager` is therefore a thin transport shim that
|
||||
keeps the lifecycle for `Worker` correct.
|
||||
|
||||
## Features
|
||||
|
||||
- Injectable `WorkerFactory` (test fakes substitute trivially).
|
||||
- Wires `message`, `error`, and `messageerror` events to the base manager's
|
||||
`handleMessage` / `handleError`.
|
||||
- Falls back to `"Worker error"` / `"Worker messageerror"` when the event
|
||||
carries no message (some browsers strip it for cross-origin workers).
|
||||
- Inherits everything from `BaseWorkerManager` including `postMessage`,
|
||||
`sendRequest`, `terminate`, `terminateAll`, lifecycle events, OTel.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { defineWorker, WorkerManager } from "@suaveplan/web-workers";
|
||||
|
||||
const factory = defineWorker({
|
||||
url: new URL("./encode.worker.ts", import.meta.url),
|
||||
type: "module",
|
||||
});
|
||||
|
||||
const manager = new WorkerManager({ factory });
|
||||
manager.create({ name: "encode", url: "ignored-by-factory" });
|
||||
const out = await manager.sendRequest<string, ArrayBuffer>(
|
||||
"encode",
|
||||
"encode-frame",
|
||||
"hello"
|
||||
);
|
||||
manager.terminateAll();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
defineWorker,
|
||||
WorkerManager,
|
||||
createWebWorkersObservability,
|
||||
} from "@suaveplan/web-workers";
|
||||
|
||||
const obs = createWebWorkersObservability();
|
||||
const factory = defineWorker({ url: WORKER_URL });
|
||||
const manager = new WorkerManager({ factory, observability: obs });
|
||||
|
||||
manager.events.on("worker:error", ({ name, error }) => {
|
||||
console.warn(`worker ${name} crashed`, error);
|
||||
});
|
||||
|
||||
const ctrl = new AbortController();
|
||||
manager.create({
|
||||
name: "compute",
|
||||
url: WORKER_URL,
|
||||
timeout: 10_000,
|
||||
maxPendingRequests: 64,
|
||||
circuitBreaker: { failureThreshold: 3, resetMs: 30_000 },
|
||||
});
|
||||
|
||||
const buffer = new ArrayBuffer(4 * 1024 * 1024);
|
||||
const result = await manager.sendRequest<ArrayBuffer, ArrayBuffer>(
|
||||
"compute",
|
||||
"transform",
|
||||
buffer,
|
||||
{
|
||||
transfer: [buffer],
|
||||
signal: ctrl.signal,
|
||||
timeout: 5_000,
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface WorkerManagerOptions extends BaseManagerOptions`
|
||||
— adds the required `factory: WorkerFactory`.
|
||||
- `class WorkerManager extends BaseWorkerManager<WorkerLike>`.
|
||||
- `constructor(options: WorkerManagerOptions)`.
|
||||
- Inherits the full base API (`create`, `terminate`, `terminateAll`,
|
||||
`postMessage`, `sendRequest`, `has`, `getStatus`, `getWorkerNames`,
|
||||
`onError`, `events`).
|
||||
- Protected overrides (not part of the public API):
|
||||
- `createTransport(config) → WorkerLike` — calls the factory, wires
|
||||
listeners.
|
||||
- `terminateTransport(worker)` — `worker.terminate()`.
|
||||
- `postToTransport(worker, message, transfer)` — sends with or without
|
||||
the transfer list.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `error` and `messageerror` listeners both lift their event's `message`
|
||||
field through a `|| "fallback"` so a missing message string still produces
|
||||
a meaningful `Error`. The `||` (over `??`) catches empty strings, which
|
||||
some browsers emit for cross-origin worker scripts (the spec leaves the
|
||||
exact value implementation-defined). `postToTransport` short-circuits when
|
||||
`transfer` is undefined or empty so the underlying `postMessage` is called
|
||||
with one argument — passing `undefined` or `[]` as the second arg works in
|
||||
most browsers but happy-dom rejects `[]` with `Failed to execute postMessage`,
|
||||
so the split is required for tests to run cleanly under the happy-dom
|
||||
environment.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
# `internals`
|
||||
|
||||
## Purpose
|
||||
|
||||
`internals.ts` holds the small internal types and helpers `worker-pool.ts`
|
||||
needs but that don't make sense to surface in the public API: the
|
||||
`PoolSlot` bookkeeping shape, the `QueuedTask` entry used while tasks
|
||||
wait for a free worker, and the `slotConfig` helper that materialises a
|
||||
`WorkerConfig` for a slot from the `WorkerPoolConfig`.
|
||||
|
||||
Splitting these out keeps the main pool file under the 300-line ceiling
|
||||
and makes the internal shapes independently importable from a sibling
|
||||
helper file or future selector.
|
||||
|
||||
## Features
|
||||
|
||||
- `PoolSlot` — per-slot mutable record: `name`, `restarts`, `pendingCount`.
|
||||
- `QueuedTask` — pending submit waiting for a worker: `type`, `payload`,
|
||||
`options`, `resolve`, `reject`.
|
||||
- `slotConfig(poolConfig, name, defaultTimeoutMs)` — builds a complete
|
||||
`WorkerConfig` while respecting `exactOptionalPropertyTypes` (no
|
||||
`undefined`-valued keys leak through).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { slotConfig } from "@suaveplan/web-workers";
|
||||
|
||||
const cfg = slotConfig(
|
||||
{ url: WORKER_URL, size: 4, taskTimeout: 10_000 },
|
||||
"pool-0-abc",
|
||||
30_000
|
||||
);
|
||||
// cfg === { name: "pool-0-abc", url: WORKER_URL, timeout: 10_000 }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { slotConfig } from "@suaveplan/web-workers";
|
||||
|
||||
const cfg = slotConfig(
|
||||
{
|
||||
url: WORKER_URL,
|
||||
size: 2,
|
||||
type: "module",
|
||||
maxPendingRequests: 64,
|
||||
circuitBreaker: { failureThreshold: 3, resetMs: 30_000 },
|
||||
},
|
||||
"pool-1-xyz",
|
||||
30_000
|
||||
);
|
||||
// cfg.type === "module"
|
||||
// cfg.maxPendingRequests === 64
|
||||
// cfg.circuitBreaker is the same object reference
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface PoolSlot { readonly name: string; restarts: number; pendingCount: number }`.
|
||||
- `interface QueuedTask { type, payload, options, resolve, reject }`.
|
||||
- `slotConfig(poolConfig: WorkerPoolConfig, name: string, defaultTimeoutMs: number): WorkerConfig`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`slotConfig` builds the target object incrementally rather than via
|
||||
conditional-spread syntax (`...(x !== undefined ? { x } : {})`) because
|
||||
the spread version flagged uncovered branches in istanbul that couldn't
|
||||
be exercised once the spread expanded to an empty object. The incremental
|
||||
`if (x !== undefined) cfg.x = x` form has the same semantics under
|
||||
`exactOptionalPropertyTypes` (the key is absent rather than
|
||||
`undefined`-valued) but every branch is reachable from a single test that
|
||||
exercises the optional config field. The cast `-readonly [K in keyof WorkerConfig]: WorkerConfig[K]`
|
||||
is used to allow incremental assignment of `readonly` fields on the
|
||||
local variable, then the value is widened back to `WorkerConfig` at
|
||||
return.
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
# `scheduler`
|
||||
|
||||
## Purpose
|
||||
|
||||
`scheduler.ts` exposes `pickSlot`, the pure function `WorkerPool` calls to
|
||||
decide which slot receives the next submit. The function takes a
|
||||
projection of the pool's slots (only the data the scheduler needs:
|
||||
`name` + `pendingCount`) and the strategy name, and returns the chosen
|
||||
slot plus the next round-robin index.
|
||||
|
||||
Isolating the scheduling decision in a pure function makes it independently
|
||||
testable, swappable (a future scheduler can be added without touching the
|
||||
pool), and free of side effects.
|
||||
|
||||
## Features
|
||||
|
||||
- Two scheduling strategies: `round-robin` and `least-busy`.
|
||||
- `SchedulerSlot` projection — lightweight view of the pool's slots so the
|
||||
scheduler doesn't depend on `WorkerPool`'s internals.
|
||||
- Returns `undefined` when the slot list is empty, letting the caller
|
||||
decide what to do (typically reject with `RESTART_EXHAUSTED`).
|
||||
- Round-robin returns the next index so the caller can persist it.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { pickSlot } from "@suaveplan/web-workers";
|
||||
|
||||
const slots = [
|
||||
{ name: "w0", pendingCount: 0 },
|
||||
{ name: "w1", pendingCount: 0 },
|
||||
];
|
||||
const pick = pickSlot("round-robin", slots, 0);
|
||||
// pick.slot.name === "w0", pick.nextIndex === 1
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
pickSlot,
|
||||
type SchedulerSlot,
|
||||
type SchedulerStrategy,
|
||||
} from "@suaveplan/web-workers";
|
||||
|
||||
class CustomPool {
|
||||
private slots: Array<{ name: string; pendingCount: number }> = [];
|
||||
private nextRR = 0;
|
||||
pick(strategy: SchedulerStrategy): SchedulerSlot | undefined {
|
||||
const projection = this.slots.map((s) => ({
|
||||
name: s.name,
|
||||
pendingCount: s.pendingCount,
|
||||
}));
|
||||
const result = pickSlot(strategy, projection, this.nextRR);
|
||||
if (result === undefined) return undefined;
|
||||
this.nextRR = result.nextIndex;
|
||||
return result.slot;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type SchedulerStrategy = "round-robin" | "least-busy"`.
|
||||
- `interface SchedulerSlot { name: string; pendingCount: number }`.
|
||||
- `pickSlot(strategy, slots, nextRoundRobinIndex): { slot, nextIndex } | undefined`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`round-robin` uses `nextRoundRobinIndex % slots.length` so the caller can
|
||||
keep a monotonically-increasing counter without worrying about wraparound.
|
||||
`least-busy` linear-scans the slot list; ties go to the lowest-index slot
|
||||
(deterministic). The pure-function shape means the same slot projection
|
||||
always returns the same result — no hidden state, no randomness. For
|
||||
large pools (> 100 slots) a heap-based least-busy could be faster, but
|
||||
the typical pool size (4–16 slots) makes the linear scan negligible.
|
||||
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
12
packages/foundation/data/web-workers/tsconfig.lib.json
Normal file
12
packages/foundation/data/web-workers/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
60
packages/foundation/data/web-workers/vite.config.ts
Normal file
60
packages/foundation/data/web-workers/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
7
packages/foundation/data/web-workers/vitest.config.ts
Normal file
7
packages/foundation/data/web-workers/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig({
|
||||
environment: "happy-dom",
|
||||
fileParallelism: false,
|
||||
testTimeout: 5000,
|
||||
});
|
||||
Reference in New Issue
Block a user