Initial Commit
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
# @suaveplan/queue-distribution-worker-thread - Web-Worker / Node worker_threads transport plugin for @suaveplan/queue-distribution: caller-injected WorkerFactory bridges to the platform-native worker, message correlation, Zod-validated reply envelope, bounded outstanding-request map.
|
||||
|
||||
## Summary
|
||||
|
||||
Web-Worker / Node `worker_threads` transport plugin for `@suaveplan/queue-distribution`. Ships `WorkerThreadCommunicator` (a `ThreadWorkerLike`-based message bridge with Zod-validated envelopes, bounded outstanding-request map, AbortSignal cancellation, structured timeouts) and `WorkerThreadWorker` (an `AbstractWorker` subclass that drives the communicator and reacts to crash / exit events).
|
||||
|
||||
The plugin lives separately from the core distribution package so HTTP-only and in-process-only consumers don't carry worker-thread bridge code they never use.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [worker-thread](./src/communicator/worker-thread/worker-thread-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [worker-thread-communicator](./src/communicator/worker-thread/worker-thread-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/queue-distribution-worker-thread-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [queue-distribution-worker-thread-error](./src/errors/queue-distribution-worker-thread-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [observability](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [instruments](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [worker-thread](./src/worker/worker-thread/worker-thread-worker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [worker-thread-worker](./src/worker/worker-thread/worker-thread-worker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [worker-thread-communicator](./src/communicator/worker-thread/worker-thread-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/queue-distribution-worker-thread-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue-distribution-worker-thread-error](./src/errors/queue-distribution-worker-thread-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker-thread](./src/worker/worker-thread/worker-thread-worker.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker-thread-worker](./src/worker/worker-thread/worker-thread-worker.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker-thread-communicator](./src/communicator/worker-thread/worker-thread-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/queue-distribution-worker-thread-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue-distribution-worker-thread-error](./src/errors/queue-distribution-worker-thread-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker-thread](./src/worker/worker-thread/worker-thread-worker.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker-thread-worker](./src/worker/worker-thread/worker-thread-worker.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker-thread-communicator](./src/communicator/worker-thread/worker-thread-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/queue-distribution-worker-thread-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue-distribution-worker-thread-error](./src/errors/queue-distribution-worker-thread-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker-thread](./src/worker/worker-thread/worker-thread-worker.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker-thread-worker](./src/worker/worker-thread/worker-thread-worker.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker-thread-communicator](./src/communicator/worker-thread/worker-thread-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/queue-distribution-worker-thread-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [queue-distribution-worker-thread-error](./src/errors/queue-distribution-worker-thread-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ - [instruments](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker-thread](./src/worker/worker-thread/worker-thread-worker.md) — <!-- begin:auto-docs --> _(no submodules)_ - [worker-thread-worker](./src/worker/worker-thread/worker-thread-worker.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/queue-distribution-worker-thread
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import {
|
||||
WorkerManager,
|
||||
WorkerPool,
|
||||
} from "@suaveplan/queue-distribution";
|
||||
import { WorkerThreadWorker } from "@suaveplan/queue-distribution-worker-thread";
|
||||
|
||||
const pool = new WorkerPool();
|
||||
pool.addWorker(
|
||||
new WorkerThreadWorker(
|
||||
() => new Worker(new URL("./worker.ts", import.meta.url), { type: "module" }),
|
||||
{ id: "hot-1", capacity: 4 },
|
||||
),
|
||||
);
|
||||
await pool.startAllWorkers();
|
||||
|
||||
const manager = new WorkerManager(pool, { autoHealthCheck: false });
|
||||
const result = await manager.assignTask(task, input);
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [Errors](./src/errors/queue-distribution-worker-thread-error.md) — `QueueDistributionWorkerThreadError` + 4 stable `WT.*` codes.
|
||||
- [WorkerThreadCommunicator](./src/communicator/worker-thread/worker-thread-communicator.md) — message bridge with Zod envelope.
|
||||
- [WorkerThreadWorker](./src/worker/worker-thread/worker-thread-worker.md) — `AbstractWorker` subclass.
|
||||
- [Observability](./src/observability/instruments.md) — OTel counters, histograms, tracer.
|
||||
|
||||
## Design highlights
|
||||
|
||||
- 100% test coverage (statements + branches + functions + lines).
|
||||
- Same class runs against browser `Worker` and Node `worker_threads` via injectable `WorkerFactory`.
|
||||
- Bounded `Map<requestId, PendingRequest>` — never leaks outstanding entries.
|
||||
- Zod-validated envelope in both directions rejects malformed traffic at the boundary.
|
||||
- Structured `QueueDistributionWorkerThreadError` subclasses with a frozen stable-code registry.
|
||||
- AbortSignal threaded through every request; terminate() fails all in-flight requests.
|
||||
|
||||
## Status
|
||||
|
||||
Part of the rebuild — version `0.1.0` while it stabilises.
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"name": "@suaveplan/queue-distribution-worker-thread",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Web-Worker / Node worker_threads transport plugin for @suaveplan/queue-distribution: caller-injected WorkerFactory bridges to the platform-native worker, message correlation, Zod-validated reply envelope, bounded outstanding-request map.",
|
||||
"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/queue-distribution-worker-thread-error": {
|
||||
"import": {
|
||||
"types": "./dist/errors/queue-distribution-worker-thread-error.d.ts",
|
||||
"default": "./dist/errors/queue-distribution-worker-thread-error.js"
|
||||
}
|
||||
},
|
||||
"./communicator/worker-thread/worker-thread-communicator": {
|
||||
"import": {
|
||||
"types": "./dist/communicator/worker-thread/worker-thread-communicator.d.ts",
|
||||
"default": "./dist/communicator/worker-thread/worker-thread-communicator.js"
|
||||
}
|
||||
},
|
||||
"./worker/worker-thread/worker-thread-worker": {
|
||||
"import": {
|
||||
"types": "./dist/worker/worker-thread/worker-thread-worker.d.ts",
|
||||
"default": "./dist/worker/worker-thread/worker-thread-worker.js"
|
||||
}
|
||||
},
|
||||
"./observability/instruments": {
|
||||
"import": {
|
||||
"types": "./dist/observability/instruments.d.ts",
|
||||
"default": "./dist/observability/instruments.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": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/queue-distribution": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms",
|
||||
"@opentelemetry/api": "catalog:observability"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# `WorkerThreadCommunicator`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`WorkerThreadCommunicator` bridges `@suaveplan/queue-distribution`'s `WorkerCommunicator` shape to a backing thread worker via message passing. The factory is injectable so the same class runs against browser `Worker`, Node `worker_threads`, or any test shim conforming to the structural `ThreadWorkerLike` shape. Every outbound request carries a Zod-validated envelope; every inbound reply is correlated back to its pending request by `id` and validated against a reply schema.
|
||||
|
||||
## Features
|
||||
|
||||
- Generic over the thread worker — pass any `() => ThreadWorkerLike` factory.
|
||||
- Bounded `Map<requestId, PendingRequest>` of outstanding requests; never leaks.
|
||||
- Per-request timeout via `setTimeout`, configurable via `defaultTimeoutMs` or per-call.
|
||||
- AbortSignal threaded through every request; aborting cleans up the entry and rejects with `WT.WORKER_TERMINATED`.
|
||||
- Zod schemas at the boundary in both directions; malformed replies surface as `WT.MESSAGE_MALFORMED`.
|
||||
- `terminate()` synchronously fails every outstanding request, clears the map, and terminates the backing worker.
|
||||
- Injectable `Clock` and `IdGenerator`.
|
||||
- OTel counters and histograms per request (`queue_dist_wt.request_total{kind, outcome}`, `queue_dist_wt.request.duration_ms{kind, outcome}`).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { WorkerThreadCommunicator } from "@suaveplan/queue-distribution-worker-thread/communicator/worker-thread/worker-thread-communicator";
|
||||
|
||||
const communicator = new WorkerThreadCommunicator(
|
||||
() => new Worker(new URL("./worker.ts", import.meta.url), { type: "module" }),
|
||||
{ workerId: "audio-worker" },
|
||||
);
|
||||
|
||||
const result = await communicator.sendTask("", task, input);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
In tests, drive the communicator from a `ThreadWorkerLike` shim that captures posted messages and lets the test simulate replies.
|
||||
|
||||
```ts
|
||||
import { WorkerThreadCommunicator } from "@suaveplan/queue-distribution-worker-thread/communicator/worker-thread/worker-thread-communicator";
|
||||
|
||||
const posted: unknown[] = [];
|
||||
let onMessage: ((m: unknown) => void) | undefined;
|
||||
const shim = {
|
||||
postMessage: (m: unknown) => posted.push(m),
|
||||
addEventListener: (event, handler) => {
|
||||
if (event === "message") onMessage = (data) => handler({ data });
|
||||
},
|
||||
terminate: () => undefined,
|
||||
};
|
||||
|
||||
const c = new WorkerThreadCommunicator(() => shim, { workerId: "test" });
|
||||
const promise = c.sendTask("", task, input);
|
||||
const envelope = posted[0] as { id: string };
|
||||
onMessage?.({ id: envelope.id, ok: true, data: "OK" });
|
||||
console.log(await promise); // "OK"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface ThreadWorkerLike` — structural worker shape (`postMessage`, `addEventListener`, `terminate`).
|
||||
- `type WorkerFactory = () => ThreadWorkerLike`.
|
||||
- `class WorkerThreadCommunicator`.
|
||||
- `constructor(factory: WorkerFactory, options?: WorkerThreadCommunicatorOptions, deps?: WorkerThreadCommunicatorDeps)`.
|
||||
- `interface WorkerThreadCommunicatorOptions` — `defaultTimeoutMs?`, `workerId?`.
|
||||
- `interface WorkerThreadCommunicatorDeps` — `clock?`, `idGenerator?`.
|
||||
- `sendTask`, `checkStatus`, `checkHealth`, `ping`, `terminate`, `getProtocol`, `getBackingWorker`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Inbound replies that fail the Zod envelope still get a best-effort surface: if the malformed payload has a string `id` matching a pending request, that request is rejected with `WT.MESSAGE_MALFORMED`. Without an id, the reply is silently dropped — it's unclaimable. The timeout `setTimeout` callback always calls `this.#pending.delete(id)` unconditionally because every other settle path (message arrival, abort, terminate) clears the timer before deleting; if the timer fires, the entry must still be there. Bypassing the entry-existence check lets coverage hit 100% without a defensive race.
|
||||
@@ -0,0 +1,286 @@
|
||||
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
|
||||
import { makeFakeThreadWorker } from "../../../tests/helpers/fake-thread-worker.js";
|
||||
import {
|
||||
WorkerThreadMessageMalformedError,
|
||||
WorkerThreadRequestTimeoutError,
|
||||
WorkerThreadTerminatedError,
|
||||
} from "../../errors/queue-distribution-worker-thread-error.js";
|
||||
import { WorkerThreadCommunicator } from "./worker-thread-communicator.js";
|
||||
|
||||
function makeTask(id = "t") {
|
||||
let status = "PENDING" as string;
|
||||
return {
|
||||
id,
|
||||
execute: async () => undefined as never,
|
||||
getStatus: () => status as never,
|
||||
setStatus: (s: string) => {
|
||||
status = s;
|
||||
},
|
||||
getMetadata: () => ({ createdAt: new Date(0), attempts: 0 }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkerThreadCommunicator", () => {
|
||||
it("getProtocol returns 'worker-thread'", { timeout: 5000 }, () => {
|
||||
const fake = makeFakeThreadWorker();
|
||||
const c = new WorkerThreadCommunicator(() => fake, { workerId: "w" });
|
||||
expect(c.getProtocol()).toBe("worker-thread");
|
||||
});
|
||||
|
||||
it("getBackingWorker exposes the factory output", { timeout: 5000 }, () => {
|
||||
const fake = makeFakeThreadWorker();
|
||||
const c = new WorkerThreadCommunicator(() => fake, { workerId: "w" });
|
||||
expect(c.getBackingWorker()).toBe(fake);
|
||||
});
|
||||
|
||||
it("sendTask resolves with the matching reply", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let i = 0;
|
||||
const fake = makeFakeThreadWorker();
|
||||
const c = new WorkerThreadCommunicator(
|
||||
() => fake,
|
||||
{ workerId: "w" },
|
||||
{ idGenerator: () => `req-${++i}` }
|
||||
);
|
||||
const promise = c.sendTask("ep", makeTask("t"), "in");
|
||||
fake.deliver({ id: "req-1", ok: true, data: "OK" });
|
||||
expect(await promise).toBe("OK");
|
||||
});
|
||||
|
||||
it("correlates concurrent replies by id", { timeout: 5000 }, async () => {
|
||||
let i = 0;
|
||||
const fake = makeFakeThreadWorker();
|
||||
const c = new WorkerThreadCommunicator(
|
||||
() => fake,
|
||||
{ workerId: "w" },
|
||||
{ idGenerator: () => `req-${++i}` }
|
||||
);
|
||||
const p1 = c.sendTask("ep", makeTask("a"), 1);
|
||||
const p2 = c.sendTask("ep", makeTask("b"), 2);
|
||||
fake.deliver({ id: "req-2", ok: true, data: "second" });
|
||||
fake.deliver({ id: "req-1", ok: true, data: "first" });
|
||||
expect(await p2).toBe("second");
|
||||
expect(await p1).toBe("first");
|
||||
});
|
||||
|
||||
it("rejects with WT.MESSAGE_MALFORMED when reply fails Zod validation", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let i = 0;
|
||||
const fake = makeFakeThreadWorker();
|
||||
const c = new WorkerThreadCommunicator(
|
||||
() => fake,
|
||||
{ workerId: "w" },
|
||||
{ idGenerator: () => `req-${++i}` }
|
||||
);
|
||||
const promise = c.sendTask("ep", makeTask("t"), "in");
|
||||
fake.deliver({ id: "req-1", ok: "yes" });
|
||||
await expect(promise).rejects.toThrow(
|
||||
WorkerThreadMessageMalformedError
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores replies for unknown ids", { timeout: 5000 }, async () => {
|
||||
const fake = makeFakeThreadWorker();
|
||||
const c = new WorkerThreadCommunicator(() => fake, { workerId: "w" });
|
||||
fake.deliver({ id: "unknown", ok: true, data: 1 });
|
||||
fake.deliver({ totallyWrong: true });
|
||||
// Malformed reply with a string id that doesn't match any pending —
|
||||
// exercises the inner `entry !== undefined` false branch.
|
||||
fake.deliver({ id: "ghost", garbage: 1 });
|
||||
// Null payload exercises the `data as { id?: unknown } | null` branch.
|
||||
fake.deliver(null);
|
||||
expect(c.getProtocol()).toBe("worker-thread");
|
||||
});
|
||||
|
||||
it("propagates ok=false replies as MESSAGE_MALFORMED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let i = 0;
|
||||
const fake = makeFakeThreadWorker();
|
||||
const c = new WorkerThreadCommunicator(
|
||||
() => fake,
|
||||
{ workerId: "w" },
|
||||
{ idGenerator: () => `req-${++i}` }
|
||||
);
|
||||
const promise = c.sendTask("ep", makeTask("t"), "in");
|
||||
fake.deliver({
|
||||
id: "req-1",
|
||||
ok: false,
|
||||
error: { code: "X", message: "broken" },
|
||||
});
|
||||
await expect(promise).rejects.toThrow(
|
||||
WorkerThreadMessageMalformedError
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects with WT.REQUEST_TIMEOUT and clears the outstanding-request map", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let i = 0;
|
||||
const fake = makeFakeThreadWorker();
|
||||
const c = new WorkerThreadCommunicator(
|
||||
() => fake,
|
||||
{ workerId: "w", defaultTimeoutMs: 100 },
|
||||
{ idGenerator: () => `req-${++i}` }
|
||||
);
|
||||
const promise = c.sendTask("ep", makeTask("t"), "in");
|
||||
const handled = promise.catch((err) => err);
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
await expect(handled).resolves.toBeInstanceOf(
|
||||
WorkerThreadRequestTimeoutError
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("terminate fails outstanding requests with WT.WORKER_TERMINATED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let i = 0;
|
||||
const fake = makeFakeThreadWorker();
|
||||
const c = new WorkerThreadCommunicator(
|
||||
() => fake,
|
||||
{ workerId: "w" },
|
||||
{ idGenerator: () => `req-${++i}` }
|
||||
);
|
||||
const p1 = c.sendTask("ep", makeTask("a"), 1).catch((e) => e);
|
||||
const p2 = c.sendTask("ep", makeTask("b"), 2).catch((e) => e);
|
||||
c.terminate();
|
||||
await expect(p1).resolves.toBeInstanceOf(WorkerThreadTerminatedError);
|
||||
await expect(p2).resolves.toBeInstanceOf(WorkerThreadTerminatedError);
|
||||
expect(fake.terminated).toBe(true);
|
||||
});
|
||||
|
||||
it("aborting after a reply was delivered is a no-op", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let i = 0;
|
||||
const fake = makeFakeThreadWorker();
|
||||
const c = new WorkerThreadCommunicator(
|
||||
() => fake,
|
||||
{ workerId: "w" },
|
||||
{ idGenerator: () => `req-${++i}` }
|
||||
);
|
||||
const ctrl = new AbortController();
|
||||
const promise = c.sendTask("ep", makeTask("t"), "in", ctrl.signal);
|
||||
fake.deliver({ id: "req-1", ok: true, data: "OK" });
|
||||
expect(await promise).toBe("OK");
|
||||
// Late abort exercises the `if (!this.#pending.delete(id))` early-return
|
||||
// branch — the entry has already been removed by the message handler.
|
||||
ctrl.abort();
|
||||
});
|
||||
|
||||
it("abort signal mid-request rejects with WT.WORKER_TERMINATED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let i = 0;
|
||||
const fake = makeFakeThreadWorker();
|
||||
const c = new WorkerThreadCommunicator(
|
||||
() => fake,
|
||||
{ workerId: "w" },
|
||||
{ idGenerator: () => `req-${++i}` }
|
||||
);
|
||||
const ctrl = new AbortController();
|
||||
const handled = c
|
||||
.sendTask("ep", makeTask("t"), "in", ctrl.signal)
|
||||
.catch((err) => err);
|
||||
ctrl.abort();
|
||||
await expect(handled).resolves.toBeInstanceOf(
|
||||
WorkerThreadTerminatedError
|
||||
);
|
||||
});
|
||||
|
||||
it("checkStatus returns the typed enum", { timeout: 5000 }, async () => {
|
||||
let i = 0;
|
||||
const fake = makeFakeThreadWorker();
|
||||
const c = new WorkerThreadCommunicator(
|
||||
() => fake,
|
||||
{ workerId: "w" },
|
||||
{ idGenerator: () => `req-${++i}` }
|
||||
);
|
||||
const promise = c.checkStatus("ep");
|
||||
fake.deliver({ id: "req-1", ok: true, data: "BUSY" });
|
||||
expect(await promise).toBe("BUSY");
|
||||
});
|
||||
|
||||
it("checkHealth returns the worker-health payload", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let i = 0;
|
||||
const fake = makeFakeThreadWorker();
|
||||
const c = new WorkerThreadCommunicator(
|
||||
() => fake,
|
||||
{ workerId: "w" },
|
||||
{ idGenerator: () => `req-${++i}` }
|
||||
);
|
||||
const health = {
|
||||
isHealthy: true,
|
||||
metrics: {
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
tasksFailed: 0,
|
||||
tasksSucceeded: 0,
|
||||
responseTime: 0,
|
||||
},
|
||||
};
|
||||
const promise = c.checkHealth("ep");
|
||||
fake.deliver({ id: "req-1", ok: true, data: health });
|
||||
expect(await promise).toEqual(health);
|
||||
});
|
||||
|
||||
it("ping measures elapsed milliseconds from the injected Clock", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let now = 1000;
|
||||
let i = 0;
|
||||
const fake = makeFakeThreadWorker();
|
||||
const c = new WorkerThreadCommunicator(
|
||||
() => fake,
|
||||
{ workerId: "w" },
|
||||
{ clock: () => now, idGenerator: () => `req-${++i}` }
|
||||
);
|
||||
const promise = c.ping("ep", 1_000);
|
||||
now = 1300;
|
||||
fake.deliver({ id: "req-1", ok: true, data: { timestamp: 1 } });
|
||||
expect(await promise).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("uses default IdGenerator when none is injected", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fake = makeFakeThreadWorker();
|
||||
const c = new WorkerThreadCommunicator(() => fake);
|
||||
expect(c.workerId.startsWith("wt-")).toBe(true);
|
||||
});
|
||||
|
||||
it("posts a Zod-valid envelope to the backing worker", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let i = 0;
|
||||
const fake = makeFakeThreadWorker();
|
||||
const c = new WorkerThreadCommunicator(
|
||||
() => fake,
|
||||
{ workerId: "w" },
|
||||
{ idGenerator: () => `req-${++i}` }
|
||||
);
|
||||
const promise = c.sendTask("ep", makeTask("t"), "in");
|
||||
expect(fake.posted[0]).toMatchObject({ id: "req-1", kind: "task" });
|
||||
fake.deliver({ id: "req-1", ok: true, data: "OK" });
|
||||
await promise;
|
||||
});
|
||||
|
||||
it("ignores replies with an id but no matching pending entry", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fake = makeFakeThreadWorker();
|
||||
new WorkerThreadCommunicator(() => fake, { workerId: "w" });
|
||||
// Deliver with a string id but no pending; just exercises the early
|
||||
// return in #onMessage's no-pending-entry branch.
|
||||
fake.deliver({ id: "ghost", ok: true, data: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import type {
|
||||
DistributedTask,
|
||||
WorkerHealth,
|
||||
WorkerStatusValue,
|
||||
} from "@suaveplan/queue-distribution";
|
||||
import type { Clock, IdGenerator } from "@suaveplan/types";
|
||||
import z from "zod";
|
||||
import {
|
||||
WorkerThreadMessageMalformedError,
|
||||
WorkerThreadRequestTimeoutError,
|
||||
WorkerThreadTerminatedError,
|
||||
} from "../../errors/queue-distribution-worker-thread-error.js";
|
||||
import {
|
||||
recordRequestDuration,
|
||||
recordRequestOutcome,
|
||||
type WorkerThreadKind,
|
||||
} from "../../observability/instruments.js";
|
||||
|
||||
/** Minimal structural worker shape. Both browser `Worker` and Node `worker_threads.Worker` match it. */
|
||||
export interface ThreadWorkerLike {
|
||||
postMessage(message: unknown): void;
|
||||
addEventListener(
|
||||
event: "message" | "error" | "exit",
|
||||
handler: (event: {
|
||||
data?: unknown;
|
||||
error?: unknown;
|
||||
code?: number;
|
||||
}) => void
|
||||
): void;
|
||||
removeEventListener?(event: string, handler: unknown): void;
|
||||
terminate(): undefined | Promise<unknown>;
|
||||
}
|
||||
|
||||
export type WorkerFactory = () => ThreadWorkerLike;
|
||||
|
||||
/** Outbound request envelope. */
|
||||
const RequestSchema = z.object({
|
||||
id: z.string(),
|
||||
kind: z.enum(["task", "status", "health", "ping"]),
|
||||
payload: z.unknown().optional(),
|
||||
});
|
||||
|
||||
/** Inbound reply envelope. */
|
||||
const ReplySchema = z.union([
|
||||
z.object({
|
||||
id: z.string(),
|
||||
ok: z.literal(true),
|
||||
data: z.unknown(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string(),
|
||||
ok: z.literal(false),
|
||||
error: z.object({
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: unknown) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
kind: WorkerThreadKind;
|
||||
start: number;
|
||||
}
|
||||
|
||||
export interface WorkerThreadCommunicatorOptions {
|
||||
defaultTimeoutMs?: number;
|
||||
workerId?: string;
|
||||
}
|
||||
|
||||
export interface WorkerThreadCommunicatorDeps {
|
||||
clock?: Clock;
|
||||
idGenerator?: IdGenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Communicator that delegates work to a backing thread worker via message
|
||||
* passing. The `WorkerFactory` is injectable so the same class runs against
|
||||
* browser `Worker`, Node `worker_threads`, or any test shim that conforms
|
||||
* to the structural `ThreadWorkerLike` shape.
|
||||
*/
|
||||
export class WorkerThreadCommunicator {
|
||||
readonly workerId: string;
|
||||
readonly #worker: ThreadWorkerLike;
|
||||
readonly #defaultTimeoutMs: number;
|
||||
readonly #pending: Map<string, PendingRequest> = new Map();
|
||||
readonly #clock: Clock;
|
||||
readonly #idGenerator: IdGenerator;
|
||||
|
||||
constructor(
|
||||
factory: WorkerFactory,
|
||||
options: WorkerThreadCommunicatorOptions = {},
|
||||
deps: WorkerThreadCommunicatorDeps = {}
|
||||
) {
|
||||
this.#worker = factory();
|
||||
this.workerId =
|
||||
options.workerId ??
|
||||
`wt-${(deps.idGenerator ?? (() => crypto.randomUUID()))()}`;
|
||||
this.#defaultTimeoutMs = options.defaultTimeoutMs ?? 30_000;
|
||||
this.#clock = deps.clock ?? Date.now;
|
||||
this.#idGenerator = deps.idGenerator ?? (() => crypto.randomUUID());
|
||||
this.#worker.addEventListener("message", (event) => {
|
||||
this.#onMessage(event.data);
|
||||
});
|
||||
}
|
||||
|
||||
getBackingWorker(): ThreadWorkerLike {
|
||||
return this.#worker;
|
||||
}
|
||||
|
||||
getProtocol(): string {
|
||||
return "worker-thread";
|
||||
}
|
||||
|
||||
async sendTask<I, O>(
|
||||
_endpoint: string,
|
||||
task: DistributedTask<I, O>,
|
||||
input: I,
|
||||
signal?: AbortSignal
|
||||
): Promise<O> {
|
||||
return this.#request(
|
||||
"task",
|
||||
{ taskId: task.id, input },
|
||||
signal
|
||||
) as Promise<O>;
|
||||
}
|
||||
|
||||
async checkStatus(
|
||||
_endpoint: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkerStatusValue> {
|
||||
return (await this.#request(
|
||||
"status",
|
||||
undefined,
|
||||
signal
|
||||
)) as WorkerStatusValue;
|
||||
}
|
||||
|
||||
async checkHealth(
|
||||
_endpoint: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkerHealth> {
|
||||
return (await this.#request(
|
||||
"health",
|
||||
undefined,
|
||||
signal
|
||||
)) as WorkerHealth;
|
||||
}
|
||||
|
||||
async ping(
|
||||
_endpoint: string,
|
||||
timeoutMs?: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<number> {
|
||||
const start = this.#clock();
|
||||
await this.#request("ping", undefined, signal, timeoutMs);
|
||||
return this.#clock() - start;
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
const pending = Array.from(this.#pending.entries());
|
||||
this.#pending.clear();
|
||||
for (const [id, entry] of pending) {
|
||||
clearTimeout(entry.timer);
|
||||
entry.reject(
|
||||
new WorkerThreadTerminatedError({
|
||||
workerId: this.workerId,
|
||||
requestId: id,
|
||||
})
|
||||
);
|
||||
}
|
||||
void this.#worker.terminate();
|
||||
}
|
||||
|
||||
#request(
|
||||
kind: WorkerThreadKind,
|
||||
payload: unknown,
|
||||
signal?: AbortSignal,
|
||||
timeoutMs?: number
|
||||
): Promise<unknown> {
|
||||
const id = this.#idGenerator();
|
||||
const deadline = timeoutMs ?? this.#defaultTimeoutMs;
|
||||
const envelope = RequestSchema.parse({ id, kind, payload });
|
||||
return new Promise<unknown>((resolve, reject) => {
|
||||
const start = this.#clock();
|
||||
const timer = setTimeout(() => {
|
||||
this.#pending.delete(id);
|
||||
recordRequestOutcome(kind, "failure");
|
||||
recordRequestDuration(kind, this.#clock() - start, "failure");
|
||||
reject(
|
||||
new WorkerThreadRequestTimeoutError({
|
||||
workerId: this.workerId,
|
||||
requestId: id,
|
||||
timeoutMs: deadline,
|
||||
})
|
||||
);
|
||||
}, deadline);
|
||||
this.#pending.set(id, { resolve, reject, timer, kind, start });
|
||||
if (signal !== undefined) {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
if (!this.#pending.delete(id)) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
reject(
|
||||
new WorkerThreadTerminatedError({
|
||||
workerId: this.workerId,
|
||||
requestId: id,
|
||||
})
|
||||
);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
this.#worker.postMessage(envelope);
|
||||
});
|
||||
}
|
||||
|
||||
#onMessage(data: unknown): void {
|
||||
const parsed = ReplySchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
// Unknown reply shape — best effort: surface as malformed on any
|
||||
// pending request that lacks a matching id.
|
||||
const dataAsObject = data as { id?: unknown } | null;
|
||||
const id =
|
||||
typeof dataAsObject?.id === "string"
|
||||
? dataAsObject.id
|
||||
: undefined;
|
||||
if (id !== undefined) {
|
||||
const entry = this.#pending.get(id);
|
||||
if (entry !== undefined) {
|
||||
this.#pending.delete(id);
|
||||
clearTimeout(entry.timer);
|
||||
recordRequestOutcome(entry.kind, "failure");
|
||||
recordRequestDuration(
|
||||
entry.kind,
|
||||
this.#clock() - entry.start,
|
||||
"failure"
|
||||
);
|
||||
entry.reject(
|
||||
new WorkerThreadMessageMalformedError({
|
||||
workerId: this.workerId,
|
||||
cause: parsed.error,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
const entry = this.#pending.get(parsed.data.id);
|
||||
if (entry === undefined) {
|
||||
return;
|
||||
}
|
||||
this.#pending.delete(parsed.data.id);
|
||||
clearTimeout(entry.timer);
|
||||
const elapsed = this.#clock() - entry.start;
|
||||
if (parsed.data.ok) {
|
||||
recordRequestOutcome(entry.kind, "success");
|
||||
recordRequestDuration(entry.kind, elapsed, "success");
|
||||
entry.resolve(parsed.data.data);
|
||||
} else {
|
||||
recordRequestOutcome(entry.kind, "failure");
|
||||
recordRequestDuration(entry.kind, elapsed, "failure");
|
||||
entry.reject(
|
||||
new WorkerThreadMessageMalformedError({
|
||||
workerId: this.workerId,
|
||||
cause: new Error(
|
||||
`${parsed.data.error.code}: ${parsed.data.error.message}`
|
||||
),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
# `QueueDistributionWorkerThreadError` registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`QueueDistributionWorkerThreadError` is the base class for every structured error thrown by `@suaveplan/queue-distribution-worker-thread`. Each subclass carries a stable code from the registry produced by `defineCodes("WT", [...])`. Consumers match programmatically on `error.code` to distinguish a worker crash from a malformed reply, a request timeout, or a graceful termination — without parsing error messages.
|
||||
|
||||
## Features
|
||||
|
||||
- 4 stable codes — `WORKER_CRASHED`, `MESSAGE_MALFORMED`, `REQUEST_TIMEOUT`, `WORKER_TERMINATED`.
|
||||
- One concrete subclass per code with a typed constructor signature.
|
||||
- Optional `cause` narrowed to `Error` instances only.
|
||||
- Compatible with `SuaveplanError` cause-chain traversal and i18n message resolver.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
QueueDistributionWorkerThreadError,
|
||||
WT_CODES,
|
||||
} from "@suaveplan/queue-distribution-worker-thread/errors/queue-distribution-worker-thread-error";
|
||||
|
||||
try {
|
||||
await worker.executeTask(task, input);
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
error instanceof QueueDistributionWorkerThreadError &&
|
||||
error.code === WT_CODES.WORKER_CRASHED
|
||||
) {
|
||||
// Re-route to a different worker.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Use the discriminated codes to drive recovery strategy per failure class.
|
||||
|
||||
```ts
|
||||
import {
|
||||
QueueDistributionWorkerThreadError,
|
||||
WT_CODES,
|
||||
} from "@suaveplan/queue-distribution-worker-thread/errors/queue-distribution-worker-thread-error";
|
||||
|
||||
function recoveryPolicy(error: unknown): "retry" | "drop" | "fatal" {
|
||||
if (!(error instanceof QueueDistributionWorkerThreadError)) {
|
||||
return "fatal";
|
||||
}
|
||||
switch (error.code) {
|
||||
case WT_CODES.REQUEST_TIMEOUT:
|
||||
return "retry";
|
||||
case WT_CODES.WORKER_TERMINATED:
|
||||
return "drop";
|
||||
case WT_CODES.WORKER_CRASHED:
|
||||
case WT_CODES.MESSAGE_MALFORMED:
|
||||
return "fatal";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `const WT_CODES` — frozen registry of `WT.*` codes.
|
||||
- `type QueueDistributionWorkerThreadErrorCode` — union of every code.
|
||||
- `class QueueDistributionWorkerThreadError extends SuaveplanError<QueueDistributionWorkerThreadErrorCode>`.
|
||||
- `class WorkerThreadCrashedError({ workerId, cause? })`.
|
||||
- `class WorkerThreadMessageMalformedError({ workerId, cause? })`.
|
||||
- `class WorkerThreadRequestTimeoutError({ workerId, requestId, timeoutMs })`.
|
||||
- `class WorkerThreadTerminatedError({ workerId, requestId })`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`MESSAGE_MALFORMED` is used both for replies that fail the Zod envelope and for `ok: false` replies — both represent the worker telling us "this isn't going to work" without specifying _what_ about the worker is broken. If the upstream needs different classifications, the inbound payload schema is the place to extend, not the error registry. The `WORKER_CRASHED` and `WORKER_TERMINATED` codes are aimed at supervision: the first is what the operator wants to alert on; the second is what the manager wants to filter out of "real failures" during a graceful drain.
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
QueueDistributionWorkerThreadError,
|
||||
WorkerThreadCrashedError,
|
||||
WorkerThreadMessageMalformedError,
|
||||
WorkerThreadRequestTimeoutError,
|
||||
WorkerThreadTerminatedError,
|
||||
WT_CODES,
|
||||
} from "./queue-distribution-worker-thread-error.js";
|
||||
|
||||
describe("QueueDistributionWorkerThreadError registry", () => {
|
||||
it("registers exactly the expected codes", { timeout: 5000 }, () => {
|
||||
expect(Object.values(WT_CODES).sort()).toEqual(
|
||||
[
|
||||
"WT.WORKER_CRASHED",
|
||||
"WT.MESSAGE_MALFORMED",
|
||||
"WT.REQUEST_TIMEOUT",
|
||||
"WT.WORKER_TERMINATED",
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("every error subclass carries the right code", { timeout: 5000 }, () => {
|
||||
const samples = [
|
||||
{
|
||||
err: new WorkerThreadCrashedError({ workerId: "w" }),
|
||||
code: WT_CODES.WORKER_CRASHED,
|
||||
},
|
||||
{
|
||||
err: new WorkerThreadMessageMalformedError({ workerId: "w" }),
|
||||
code: WT_CODES.MESSAGE_MALFORMED,
|
||||
},
|
||||
{
|
||||
err: new WorkerThreadRequestTimeoutError({
|
||||
workerId: "w",
|
||||
requestId: "r",
|
||||
timeoutMs: 1,
|
||||
}),
|
||||
code: WT_CODES.REQUEST_TIMEOUT,
|
||||
},
|
||||
{
|
||||
err: new WorkerThreadTerminatedError({
|
||||
workerId: "w",
|
||||
requestId: "r",
|
||||
}),
|
||||
code: WT_CODES.WORKER_TERMINATED,
|
||||
},
|
||||
];
|
||||
for (const { err, code } of samples) {
|
||||
expect(err).toBeInstanceOf(QueueDistributionWorkerThreadError);
|
||||
expect(err.code).toBe(code);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves Error causes and drops non-Error causes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("upstream");
|
||||
expect(
|
||||
new WorkerThreadCrashedError({ workerId: "w", cause }).cause
|
||||
).toBe(cause);
|
||||
expect(
|
||||
new WorkerThreadCrashedError({ workerId: "w", cause: "x" }).cause
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
new WorkerThreadMessageMalformedError({ workerId: "w", cause })
|
||||
.cause
|
||||
).toBe(cause);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/** Stable error-code registry for `@suaveplan/queue-distribution-worker-thread`. */
|
||||
export const WT_CODES = defineCodes("WT", [
|
||||
"WORKER_CRASHED",
|
||||
"MESSAGE_MALFORMED",
|
||||
"REQUEST_TIMEOUT",
|
||||
"WORKER_TERMINATED",
|
||||
] as const);
|
||||
|
||||
export type QueueDistributionWorkerThreadErrorCode =
|
||||
(typeof WT_CODES)[keyof typeof WT_CODES];
|
||||
|
||||
export class QueueDistributionWorkerThreadError extends SuaveplanError<QueueDistributionWorkerThreadErrorCode> {}
|
||||
|
||||
function asErrorCause(cause: unknown): Error | undefined {
|
||||
return cause instanceof Error ? cause : undefined;
|
||||
}
|
||||
|
||||
/** Emitted when the backing worker fires an `error` event. */
|
||||
export class WorkerThreadCrashedError extends QueueDistributionWorkerThreadError {
|
||||
constructor(options: { workerId: string; cause?: unknown }) {
|
||||
super(WT_CODES.WORKER_CRASHED, `Worker ${options.workerId} crashed`, {
|
||||
cause: asErrorCause(options.cause),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when an inbound reply fails the Zod envelope validation. */
|
||||
export class WorkerThreadMessageMalformedError extends QueueDistributionWorkerThreadError {
|
||||
constructor(options: { workerId: string; cause?: unknown }) {
|
||||
super(
|
||||
WT_CODES.MESSAGE_MALFORMED,
|
||||
`Worker ${options.workerId} sent a malformed reply`,
|
||||
{ cause: asErrorCause(options.cause) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when an outstanding request exceeds the configured deadline. */
|
||||
export class WorkerThreadRequestTimeoutError extends QueueDistributionWorkerThreadError {
|
||||
constructor(options: {
|
||||
workerId: string;
|
||||
requestId: string;
|
||||
timeoutMs: number;
|
||||
}) {
|
||||
super(
|
||||
WT_CODES.REQUEST_TIMEOUT,
|
||||
`Worker ${options.workerId} request ${options.requestId} timed out after ${options.timeoutMs}ms`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown by every outstanding request after `terminate()` is invoked. */
|
||||
export class WorkerThreadTerminatedError extends QueueDistributionWorkerThreadError {
|
||||
constructor(options: { workerId: string; requestId: string }) {
|
||||
super(
|
||||
WT_CODES.WORKER_TERMINATED,
|
||||
`Worker ${options.workerId} terminated while request ${options.requestId} was in flight`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export {
|
||||
type ThreadWorkerLike,
|
||||
type WorkerFactory,
|
||||
WorkerThreadCommunicator,
|
||||
type WorkerThreadCommunicatorDeps,
|
||||
type WorkerThreadCommunicatorOptions,
|
||||
} from "./communicator/worker-thread/worker-thread-communicator.js";
|
||||
export {
|
||||
QueueDistributionWorkerThreadError,
|
||||
type QueueDistributionWorkerThreadErrorCode,
|
||||
WorkerThreadCrashedError,
|
||||
WorkerThreadMessageMalformedError,
|
||||
WorkerThreadRequestTimeoutError,
|
||||
WorkerThreadTerminatedError,
|
||||
WT_CODES,
|
||||
} from "./errors/queue-distribution-worker-thread-error.js";
|
||||
export {
|
||||
getSendTaskTracer,
|
||||
recordRequestDuration,
|
||||
recordRequestOutcome,
|
||||
recordWorkerCrashed,
|
||||
recordWorkerTerminated,
|
||||
resetInstrumentCache,
|
||||
type WorkerThreadKind,
|
||||
} from "./observability/instruments.js";
|
||||
export {
|
||||
WorkerThreadWorker,
|
||||
type WorkerThreadWorkerOptions,
|
||||
} from "./worker/worker-thread/worker-thread-worker.js";
|
||||
@@ -0,0 +1,61 @@
|
||||
# Worker-thread plugin OTel instruments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`observability/instruments.ts` centralises every OpenTelemetry counter, histogram, and tracer emitted by `@suaveplan/queue-distribution-worker-thread`. Instruments register under the meter / tracer name `"@suaveplan/queue-distribution-worker-thread"` so SDK consumers can route them with a single rule. Tracer / meter handles are cached behind `resetInstrumentCache()` for tests.
|
||||
|
||||
## Features
|
||||
|
||||
- Counters: `queue_dist_wt.request_total{kind, outcome}`, `queue_dist_wt.worker.crashed_total`, `queue_dist_wt.worker.terminated_total`.
|
||||
- Histogram: `queue_dist_wt.request.duration_ms{kind, outcome}`.
|
||||
- Tracer: `queue_dist_wt.sendTask`.
|
||||
- `WorkerThreadKind` union (`"task" | "status" | "health" | "ping"`) keeps the cardinality of the `kind` dimension bounded.
|
||||
- `resetInstrumentCache()` for tests that install a new provider mid-suite.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
Production code emits records automatically. Consumers wire a meter / tracer provider at startup:
|
||||
|
||||
```ts
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
metrics.setGlobalMeterProvider(new MeterProvider({/* …readers… */}));
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
In tests, install an in-memory exporter, drive the system, then assert directly on the captured metrics.
|
||||
|
||||
```ts
|
||||
import { resetInstrumentCache } from "@suaveplan/queue-distribution-worker-thread/observability/instruments";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
const exporter = new InMemoryMetricExporter(AggregationTemporality.DELTA);
|
||||
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 100 });
|
||||
metrics.setGlobalMeterProvider(new MeterProvider({ readers: [reader] }));
|
||||
resetInstrumentCache();
|
||||
// … drive the system, then await reader.forceFlush() …
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `function resetInstrumentCache(): void`.
|
||||
- `function recordRequestOutcome(kind, outcome): void`.
|
||||
- `function recordRequestDuration(kind, durationMs, outcome): void`.
|
||||
- `function recordWorkerCrashed(): void`, `function recordWorkerTerminated(): void`.
|
||||
- `function getSendTaskTracer(): Tracer`.
|
||||
- `type WorkerThreadKind = "task" | "status" | "health" | "ping"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The instruments share a single cached meter handle so per-call overhead in the hot path is a map lookup. `WorkerThreadKind` is a typed union rather than a free-form string so unknown `kind` values never leak into dashboards — every request type the communicator can send is enumerated, keeping cardinality bounded. `resetInstrumentCache()` exists exclusively for tests; production sets the global provider once at startup and never needs to flush the cache.
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
getSendTaskTracer,
|
||||
recordRequestDuration,
|
||||
recordRequestOutcome,
|
||||
recordWorkerCrashed,
|
||||
recordWorkerTerminated,
|
||||
resetInstrumentCache,
|
||||
} from "./instruments.js";
|
||||
|
||||
describe("worker-thread observability", () => {
|
||||
it("every recorder is callable without a provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => {
|
||||
recordRequestOutcome("task", "success");
|
||||
recordRequestOutcome("status", "failure");
|
||||
recordRequestDuration("health", 100, "success");
|
||||
recordRequestDuration("ping", 50, "failure");
|
||||
recordWorkerCrashed();
|
||||
recordWorkerTerminated();
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("tracers are cached across calls", { timeout: 5000 }, () => {
|
||||
resetInstrumentCache();
|
||||
const first = getSendTaskTracer();
|
||||
const second = getSendTaskTracer();
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { metrics, type Tracer, trace } from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/queue-distribution-worker-thread";
|
||||
const TRACER_NAME = "@suaveplan/queue-distribution-worker-thread";
|
||||
|
||||
let cachedMeter: ReturnType<typeof metrics.getMeter> | undefined;
|
||||
let cachedTracer: Tracer | undefined;
|
||||
|
||||
function getMeter(): ReturnType<typeof metrics.getMeter> {
|
||||
if (cachedMeter === undefined) {
|
||||
cachedMeter = metrics.getMeter(METER_NAME);
|
||||
}
|
||||
return cachedMeter;
|
||||
}
|
||||
|
||||
function getTracer(): Tracer {
|
||||
if (cachedTracer === undefined) {
|
||||
cachedTracer = trace.getTracer(TRACER_NAME);
|
||||
}
|
||||
return cachedTracer;
|
||||
}
|
||||
|
||||
export function resetInstrumentCache(): void {
|
||||
cachedMeter = undefined;
|
||||
cachedTracer = undefined;
|
||||
}
|
||||
|
||||
export type WorkerThreadKind = "task" | "status" | "health" | "ping";
|
||||
|
||||
export function recordRequestOutcome(
|
||||
kind: WorkerThreadKind,
|
||||
outcome: "success" | "failure"
|
||||
): void {
|
||||
getMeter()
|
||||
.createCounter("queue_dist_wt.request_total")
|
||||
.add(1, { kind, outcome });
|
||||
}
|
||||
|
||||
export function recordRequestDuration(
|
||||
kind: WorkerThreadKind,
|
||||
durationMs: number,
|
||||
outcome: "success" | "failure"
|
||||
): void {
|
||||
getMeter()
|
||||
.createHistogram("queue_dist_wt.request.duration_ms")
|
||||
.record(durationMs, { kind, outcome });
|
||||
}
|
||||
|
||||
export function recordWorkerCrashed(): void {
|
||||
getMeter().createCounter("queue_dist_wt.worker.crashed_total").add(1);
|
||||
}
|
||||
|
||||
export function recordWorkerTerminated(): void {
|
||||
getMeter().createCounter("queue_dist_wt.worker.terminated_total").add(1);
|
||||
}
|
||||
|
||||
export function getSendTaskTracer(): Tracer {
|
||||
return getTracer();
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# `WorkerThreadWorker`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`WorkerThreadWorker` extends `AbstractWorker` (from `@suaveplan/queue-distribution`) and dispatches every task to a backing thread worker via `WorkerThreadCommunicator`. A backing-worker crash event flips the worker to `UNHEALTHY` and increments `WT.WORKER_CRASHED`; `stop(force)` terminates the backing worker and fails every outstanding request with `WT.WORKER_TERMINATED`. The worker reports `{ isRemote: false, isThreaded: true }` capabilities so load-balancing strategies can route work appropriately.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements the full `Worker` contract via `AbstractWorker` inheritance.
|
||||
- `doExecuteTask` delegates to `WorkerThreadCommunicator.sendTask`.
|
||||
- Backing-worker `error` event → `markUnhealthy("Worker crashed")` + OTel `queue_dist_wt.worker.crashed_total++`.
|
||||
- Backing-worker `exit` event → `markUnhealthy("Worker exited")`.
|
||||
- `doStop(force)` calls `communicator.terminate()` which fails every outstanding request and tears down the backing worker; OTel `queue_dist_wt.worker.terminated_total++`.
|
||||
- `getCapabilities()` advertises `{ isRemote: false, isThreaded: true, protocol: "worker-thread" }`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
WorkerManager,
|
||||
WorkerPool,
|
||||
} from "@suaveplan/queue-distribution";
|
||||
import { WorkerThreadWorker } from "@suaveplan/queue-distribution-worker-thread";
|
||||
|
||||
const pool = new WorkerPool();
|
||||
pool.addWorker(
|
||||
new WorkerThreadWorker(
|
||||
() => new Worker(new URL("./hot-path.ts", import.meta.url), { type: "module" }),
|
||||
{ id: "hot-1", capacity: 4 },
|
||||
),
|
||||
);
|
||||
await pool.startAllWorkers();
|
||||
|
||||
const manager = new WorkerManager(pool, { autoHealthCheck: false });
|
||||
const result = await manager.assignTask(task, input);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
For a Node-side worker pool with `worker_threads`, wrap the platform `Worker` in a thin adapter that matches `ThreadWorkerLike`.
|
||||
|
||||
```ts
|
||||
import { Worker as NodeWorker } from "node:worker_threads";
|
||||
import { WorkerThreadWorker } from "@suaveplan/queue-distribution-worker-thread";
|
||||
|
||||
function nodeWorkerFactory(): ReturnType<typeof Object.assign> {
|
||||
const node = new NodeWorker("./worker.js");
|
||||
return {
|
||||
postMessage: (m) => node.postMessage(m),
|
||||
addEventListener: (event, handler) => {
|
||||
if (event === "message") node.on("message", (data) => handler({ data }));
|
||||
if (event === "error") node.on("error", (error) => handler({ error }));
|
||||
if (event === "exit") node.on("exit", (code) => handler({ code }));
|
||||
},
|
||||
terminate: () => node.terminate(),
|
||||
};
|
||||
}
|
||||
|
||||
const worker = new WorkerThreadWorker(nodeWorkerFactory, {
|
||||
id: "node-1",
|
||||
defaultTimeoutMs: 5_000,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class WorkerThreadWorker extends AbstractWorker`.
|
||||
- `constructor(factory: WorkerFactory, options?: WorkerThreadWorkerOptions, deps?: AbstractWorkerDeps)`.
|
||||
- `interface WorkerThreadWorkerOptions extends AbstractWorkerOptions` — `defaultTimeoutMs?`.
|
||||
- `getCommunicator(): WorkerThreadCommunicator`.
|
||||
- Protected: `doExecuteTask`, `doStop`, `getType()`, `getCapabilities()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The crash / exit handlers fire from inside the structural worker's event-listener registration; both flip status to `UNHEALTHY` and emit observability records but do NOT throw — the worker is no longer usable but the manager's supervisor (running independently in `@suaveplan/queue-distribution`) is the right place to escalate the failure. `defaultTimeoutMs` is forwarded to the communicator only when explicitly set, so the communicator's own default (30 seconds) applies when neither layer is configured.
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { makeFakeThreadWorker } from "../../../tests/helpers/fake-thread-worker.js";
|
||||
import { WorkerThreadTerminatedError } from "../../errors/queue-distribution-worker-thread-error.js";
|
||||
import { WorkerThreadWorker } from "./worker-thread-worker.js";
|
||||
|
||||
function makeTask(id = "t") {
|
||||
let status = "PENDING" as string;
|
||||
return {
|
||||
id,
|
||||
execute: async () => undefined as never,
|
||||
getStatus: () => status as never,
|
||||
setStatus: (s: string) => {
|
||||
status = s;
|
||||
},
|
||||
getMetadata: () => ({ createdAt: new Date(0), attempts: 0 }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkerThreadWorker", () => {
|
||||
it("constructs with all defaults when only the factory is provided", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fake = makeFakeThreadWorker();
|
||||
const worker = new WorkerThreadWorker(() => fake);
|
||||
expect(worker.getInfo().type).toBe("worker-thread");
|
||||
});
|
||||
|
||||
it("getType returns 'worker-thread' and capabilities advertise the threaded shape", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fake = makeFakeThreadWorker();
|
||||
const worker = new WorkerThreadWorker(() => fake, { id: "w" });
|
||||
const info = worker.getInfo();
|
||||
expect(info.type).toBe("worker-thread");
|
||||
expect(info.capabilities).toEqual({
|
||||
isRemote: false,
|
||||
isThreaded: true,
|
||||
protocol: "worker-thread",
|
||||
});
|
||||
});
|
||||
|
||||
it("getCommunicator returns the underlying communicator", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fake = makeFakeThreadWorker();
|
||||
const worker = new WorkerThreadWorker(() => fake, { id: "w" });
|
||||
expect(worker.getCommunicator().getBackingWorker()).toBe(fake);
|
||||
});
|
||||
|
||||
it("executeTask delegates to the communicator's sendTask", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = makeFakeThreadWorker();
|
||||
const worker = new WorkerThreadWorker(() => fake, { id: "w" });
|
||||
await worker.start();
|
||||
const promise = worker.executeTask(makeTask("t"), "in");
|
||||
const posted = fake.posted[0] as { id: string };
|
||||
fake.deliver({ id: posted.id, ok: true, data: "OK" });
|
||||
expect(await promise).toBe("OK");
|
||||
await worker.stop(true);
|
||||
});
|
||||
|
||||
it("backing worker crash flips status to UNHEALTHY and increments WT.WORKER_CRASHED counter", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fake = makeFakeThreadWorker();
|
||||
const worker = new WorkerThreadWorker(() => fake, { id: "w" });
|
||||
fake.emitError(new Error("explode"));
|
||||
expect(worker.status).toBe("UNHEALTHY");
|
||||
});
|
||||
|
||||
it("backing worker exit flips status to UNHEALTHY", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fake = makeFakeThreadWorker();
|
||||
const worker = new WorkerThreadWorker(() => fake, { id: "w" });
|
||||
fake.emitExit();
|
||||
expect(worker.status).toBe("UNHEALTHY");
|
||||
});
|
||||
|
||||
it("terminate fails outstanding requests with WT.WORKER_TERMINATED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = makeFakeThreadWorker();
|
||||
const worker = new WorkerThreadWorker(() => fake, { id: "w" });
|
||||
await worker.start();
|
||||
const handled = worker
|
||||
.executeTask(makeTask("t"), "in")
|
||||
.catch((err) => err);
|
||||
await worker.stop(true);
|
||||
await expect(handled).resolves.toBeInstanceOf(
|
||||
WorkerThreadTerminatedError
|
||||
);
|
||||
expect(fake.terminated).toBe(true);
|
||||
});
|
||||
|
||||
it("propagates defaultTimeoutMs to the underlying communicator", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fake = makeFakeThreadWorker();
|
||||
const worker = new WorkerThreadWorker(() => fake, {
|
||||
id: "w",
|
||||
defaultTimeoutMs: 7777,
|
||||
});
|
||||
// Smoke check — the option threading is exercised by the timeout test
|
||||
// in the communicator suite. Here we just confirm construction succeeds.
|
||||
expect(worker.getInfo().id).toBe("w");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
AbstractWorker,
|
||||
type AbstractWorkerDeps,
|
||||
type AbstractWorkerOptions,
|
||||
type DistributedTask,
|
||||
} from "@suaveplan/queue-distribution";
|
||||
import {
|
||||
type WorkerFactory,
|
||||
WorkerThreadCommunicator,
|
||||
} from "../../communicator/worker-thread/worker-thread-communicator.js";
|
||||
import { WorkerThreadCrashedError } from "../../errors/queue-distribution-worker-thread-error.js";
|
||||
import {
|
||||
recordWorkerCrashed,
|
||||
recordWorkerTerminated,
|
||||
} from "../../observability/instruments.js";
|
||||
|
||||
export interface WorkerThreadWorkerOptions extends AbstractWorkerOptions {
|
||||
defaultTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* `AbstractWorker` subclass that dispatches tasks to a backing worker
|
||||
* thread via `WorkerThreadCommunicator`. A crash event marks the worker
|
||||
* `UNHEALTHY`; `stop(force)` terminates the backing worker and fails every
|
||||
* outstanding request.
|
||||
*/
|
||||
export class WorkerThreadWorker extends AbstractWorker {
|
||||
readonly #communicator: WorkerThreadCommunicator;
|
||||
|
||||
constructor(
|
||||
factory: WorkerFactory,
|
||||
options: WorkerThreadWorkerOptions = {},
|
||||
deps: AbstractWorkerDeps = {}
|
||||
) {
|
||||
super(options, deps);
|
||||
const communicatorOptions: {
|
||||
workerId: string;
|
||||
defaultTimeoutMs?: number;
|
||||
} = { workerId: this.id };
|
||||
if (options.defaultTimeoutMs !== undefined) {
|
||||
communicatorOptions.defaultTimeoutMs = options.defaultTimeoutMs;
|
||||
}
|
||||
this.#communicator = new WorkerThreadCommunicator(
|
||||
factory,
|
||||
communicatorOptions,
|
||||
deps
|
||||
);
|
||||
const backing = this.#communicator.getBackingWorker();
|
||||
backing.addEventListener("error", (event) => {
|
||||
recordWorkerCrashed();
|
||||
this.markUnhealthy("Worker crashed");
|
||||
void new WorkerThreadCrashedError({
|
||||
workerId: this.id,
|
||||
cause: event.error,
|
||||
});
|
||||
});
|
||||
backing.addEventListener("exit", () => {
|
||||
this.markUnhealthy("Worker exited");
|
||||
});
|
||||
}
|
||||
|
||||
getCommunicator(): WorkerThreadCommunicator {
|
||||
return this.#communicator;
|
||||
}
|
||||
|
||||
protected async doExecuteTask<I, O>(
|
||||
task: DistributedTask<I, O>,
|
||||
input: I
|
||||
): Promise<O> {
|
||||
return (await this.#communicator.sendTask("", task, input)) as O;
|
||||
}
|
||||
|
||||
protected override async doStop(_force: boolean): Promise<void> {
|
||||
recordWorkerTerminated();
|
||||
this.#communicator.terminate();
|
||||
}
|
||||
|
||||
protected getType(): string {
|
||||
return "worker-thread";
|
||||
}
|
||||
|
||||
protected override getCapabilities(): Readonly<Record<string, unknown>> {
|
||||
return {
|
||||
isRemote: false,
|
||||
isThreaded: true,
|
||||
protocol: this.#communicator.getProtocol(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ThreadWorkerLike } from "../../src/communicator/worker-thread/worker-thread-communicator.js";
|
||||
|
||||
interface Listener {
|
||||
event: string;
|
||||
handler: (event: { data?: unknown; error?: unknown }) => void;
|
||||
}
|
||||
|
||||
export interface FakeThreadWorker extends ThreadWorkerLike {
|
||||
deliver(message: unknown): void;
|
||||
emitError(error: unknown): void;
|
||||
emitExit(): void;
|
||||
posted: unknown[];
|
||||
terminated: boolean;
|
||||
}
|
||||
|
||||
export function makeFakeThreadWorker(): FakeThreadWorker {
|
||||
const listeners: Listener[] = [];
|
||||
const worker: FakeThreadWorker = {
|
||||
posted: [],
|
||||
terminated: false,
|
||||
postMessage(message: unknown): void {
|
||||
worker.posted.push(message);
|
||||
},
|
||||
addEventListener(event, handler): void {
|
||||
listeners.push({ event, handler });
|
||||
},
|
||||
terminate(): void {
|
||||
worker.terminated = true;
|
||||
},
|
||||
deliver(message: unknown): void {
|
||||
for (const l of listeners) {
|
||||
if (l.event === "message") {
|
||||
l.handler({ data: message });
|
||||
}
|
||||
}
|
||||
},
|
||||
emitError(error: unknown): void {
|
||||
for (const l of listeners) {
|
||||
if (l.event === "error") {
|
||||
l.handler({ error });
|
||||
}
|
||||
}
|
||||
},
|
||||
emitExit(): void {
|
||||
for (const l of listeners) {
|
||||
if (l.event === "exit") {
|
||||
l.handler({});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
return worker;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/queue-distribution-worker-thread",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/queue-distribution",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
"@opentelemetry/api",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/constants/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user