Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
# @suaveplan/queue-distribution-http - HTTP transport plugin for @suaveplan/queue-distribution: HttpWorkerCommunicator (fetch-backed, Zod-validated wire envelope) and RemoteWorker (AbstractWorker subclass with @suaveplan/retry-backed connect / reconnect / health loop).
## Summary
HTTP transport plugin for `@suaveplan/queue-distribution`. Ships `HttpWorkerCommunicator` (a fetch-backed `WorkerCommunicator` with a Zod-validated wire envelope, injectable `fetch`, structured timeouts, and AbortSignal threading) and `RemoteWorker` (an `AbstractWorker` subclass driving the communicator with bounded exponential-backoff connect retries via `@suaveplan/retry`).
The plugin lives separately from the core distribution package so browser-only and worker-thread-only consumers don't have to carry HTTP transport code they never use.
<!-- begin:auto-docs -->
## Documentation tree
- [base](./src/communicator/base/base.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [base](./src/communicator/base/base.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [http](./src/communicator/http/http-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [http-communicator](./src/communicator/http/http-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/queue-distribution-http-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [queue-distribution-http-error](./src/errors/queue-distribution-http-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 -->
- [remote](./src/worker/remote/remote.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [remote](./src/worker/remote/remote.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [base](./src/communicator/base/base.md) — <!-- begin:auto-docs --> _(no submodules)_- [http](./src/communicator/http/http-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [http-communicator](./src/communicator/http/http-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/queue-distribution-http-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue-distribution-http-error](./src/errors/queue-distribution-http-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)_- [remote](./src/worker/remote/remote.md) — <!-- begin:auto-docs --> _(no submodules)_- [remote](./src/worker/remote/remote.md) — <!-- begin:auto-docs --> _(no submodules)_- [base](./src/communicator/base/base.md) — <!-- begin:auto-docs --> _(no submodules)_- [http](./src/communicator/http/http-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [http-communicator](./src/communicator/http/http-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/queue-distribution-http-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue-distribution-http-error](./src/errors/queue-distribution-http-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)_- [remote](./src/worker/remote/remote.md) — <!-- begin:auto-docs --> _(no submodules)_- [remote](./src/worker/remote/remote.md) — <!-- begin:auto-docs --> _(no submodules)_- [base](./src/communicator/base/base.md) — <!-- begin:auto-docs --> _(no submodules)_- [http](./src/communicator/http/http-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [http-communicator](./src/communicator/http/http-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/queue-distribution-http-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue-distribution-http-error](./src/errors/queue-distribution-http-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)_- [remote](./src/worker/remote/remote.md) — <!-- begin:auto-docs --> _(no submodules)_- [remote](./src/worker/remote/remote.md) — <!-- begin:auto-docs --> _(no submodules)_- [base](./src/communicator/base/base.md) — <!-- begin:auto-docs --> _(no submodules)_- [http](./src/communicator/http/http-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_ - [http-communicator](./src/communicator/http/http-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/queue-distribution-http-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [queue-distribution-http-error](./src/errors/queue-distribution-http-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)_- [remote](./src/worker/remote/remote.md) — <!-- begin:auto-docs --> _(no submodules)_ - [remote](./src/worker/remote/remote.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add @suaveplan/queue-distribution-http
```
## Quick start
```ts
import { WorkerPool, WorkerManager } from "@suaveplan/queue-distribution";
import {
HttpWorkerCommunicator,
RemoteWorker,
} from "@suaveplan/queue-distribution-http";
const communicator = new HttpWorkerCommunicator({
defaultTimeoutMs: 5_000,
headers: { Authorization: `Bearer ${token}` },
});
const pool = new WorkerPool();
pool.addWorker(new RemoteWorker("https://worker-a.example", communicator, { id: "a" }));
pool.addWorker(new RemoteWorker("https://worker-b.example", communicator, { id: "b" }));
await pool.startAllWorkers();
const manager = new WorkerManager(pool, {
autoHealthCheck: false,
maxTaskRetries: 3,
});
const result = await manager.assignTask(task, input);
```
## Modules
- [Errors](./src/errors/queue-distribution-http-error.md) — `QueueDistributionHttpError` + 6 stable `HTTP.*` codes.
- [WorkerCommunicator base](./src/communicator/base/base.md) — interface + `AbstractWorkerCommunicator`.
- [HttpWorkerCommunicator](./src/communicator/http/http-communicator.md) — fetch-backed transport.
- [RemoteWorker](./src/worker/remote/remote.md) — connect / reconnect / health loop.
- [Observability](./src/observability/instruments.md) — OTel counters, histograms, tracers.
## Design highlights
- 100% test coverage (statements + branches + functions + lines).
- Injectable `fetch` and `Clock` — deterministic under test, swappable for auth-wrapped fetch in production.
- Connect retries routed through `@suaveplan/retry`'s `sleep`; bounded loop, no recursion.
- Zod-validated wire envelope rejects malformed payloads at the boundary.
- Structured `QueueDistributionHttpError` subclasses with a frozen stable-code registry.
## Status
Part of the rebuild — version `0.1.0` while it stabilises.

View File

@@ -0,0 +1,95 @@
{
"name": "@suaveplan/queue-distribution-http",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "HTTP transport plugin for @suaveplan/queue-distribution: HttpWorkerCommunicator (fetch-backed, Zod-validated wire envelope) and RemoteWorker (AbstractWorker subclass with @suaveplan/retry-backed connect / reconnect / health loop).",
"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-http-error": {
"import": {
"types": "./dist/errors/queue-distribution-http-error.d.ts",
"default": "./dist/errors/queue-distribution-http-error.js"
}
},
"./communicator/base/base": {
"import": {
"types": "./dist/communicator/base/base.d.ts",
"default": "./dist/communicator/base/base.js"
}
},
"./communicator/http/http-communicator": {
"import": {
"types": "./dist/communicator/http/http-communicator.d.ts",
"default": "./dist/communicator/http/http-communicator.js"
}
},
"./worker/remote/remote": {
"import": {
"types": "./dist/worker/remote/remote.d.ts",
"default": "./dist/worker/remote/remote.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/retry": "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"
]
}

View File

@@ -0,0 +1,80 @@
# `WorkerCommunicator` + `AbstractWorkerCommunicator`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`WorkerCommunicator` is the structural contract every transport implementation in `@suaveplan/queue-distribution-http` (and future RPC transports outside this package) implements. `AbstractWorkerCommunicator` carries the shared bookkeeping — default-timeout handling, header carriage, JSON serialise/deserialise helpers, and the `withTimeout` race used by every send/check call — so concrete subclasses focus on wire-level details. Stateless transports get connect/disconnect no-op defaults.
## Features
- `sendTask<I, O>(endpoint, task, input, signal?)` — preserves `<I, O>` end-to-end.
- `checkStatus`, `checkHealth`, `ping`, `connect`, `disconnect` — full transport surface.
- `getProtocol(): string` — short identifier for logs and capabilities.
- `withTimeout(operation, promise, timeoutMs?)` — Promise.race against an injectable timeout that rejects with `HTTP.TIMEOUT`.
- Configurable `defaultTimeoutMs` and per-request `headers`.
- Injectable `Clock` for tests.
## Usage: Basic Example
```ts
import {
AbstractWorkerCommunicator,
type WorkerCommunicator,
} from "@suaveplan/queue-distribution-http/communicator/base/base";
import type {
DistributedTask,
WorkerHealth,
WorkerStatusValue,
} from "@suaveplan/queue-distribution";
class EchoCommunicator extends AbstractWorkerCommunicator {
async sendTask<I, O>(_e, _t, input: I): Promise<O> {
return input as unknown as O;
}
async checkStatus(): Promise<WorkerStatusValue> {
return "ONLINE";
}
async checkHealth(): Promise<WorkerHealth> {
return {
isHealthy: true,
metrics: { cpu: 0, memory: 0, tasksFailed: 0, tasksSucceeded: 0, responseTime: 0 },
};
}
async ping(): Promise<number> {
return 0;
}
getProtocol(): string {
return "echo";
}
}
```
## Usage: Advanced Example
Subclasses can ride on `withTimeout` to enforce a per-call deadline without duplicating timer plumbing.
```ts
import { AbstractWorkerCommunicator } from "@suaveplan/queue-distribution-http/communicator/base/base";
class SlowCommunicator extends AbstractWorkerCommunicator {
async sendTask(): Promise<unknown> {
return this.withTimeout("sendTask", longRunningRpc(), 2_000);
}
// … rest of interface …
}
```
## API Reference
- `interface WorkerCommunicator` — public surface.
- `abstract class AbstractWorkerCommunicator implements WorkerCommunicator`.
- `constructor(options?: { defaultTimeoutMs?, headers? }, deps?: { clock? })`.
- `protected async withTimeout<T>(operation, promise, timeoutMs?): Promise<T>`.
- `protected serialize(value): string`, `protected deserialize<T>(s): T`.
- `connect`, `disconnect` — overridable no-ops.
## Implementation Notes
`withTimeout` attaches a stateful `then` handler to the source promise so we can `clearTimeout` from the resolution path, and races against a deferred `timeoutSignal`. This avoids the unhandled-rejection trap of using a freestanding `setTimeout(..., reject)` Promise alongside `Promise.race`: the source promise's settlement always clears the timer before the rejection can fire late. Non-Error rejections from `promise` are wrapped in `new Error(String(err))` so the error chain stays well-typed for downstream `SuaveplanError` consumers.

View File

@@ -0,0 +1,110 @@
import type {
WorkerHealth,
WorkerStatusValue,
} from "@suaveplan/queue-distribution";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { HttpTimeoutError } from "../../errors/queue-distribution-http-error.js";
import { AbstractWorkerCommunicator } from "./base.js";
class TestCommunicator extends AbstractWorkerCommunicator {
async sendTask<_I, O>(): Promise<O> {
return undefined as never;
}
async checkStatus(): Promise<WorkerStatusValue> {
return "ONLINE";
}
async checkHealth(): Promise<WorkerHealth> {
return {
isHealthy: true,
metrics: {
cpu: 0,
memory: 0,
tasksFailed: 0,
tasksSucceeded: 0,
responseTime: 0,
},
};
}
async ping(): Promise<number> {
return 0;
}
getProtocol(): string {
return "test";
}
public async testTimeout<T>(promise: Promise<T>, ms?: number): Promise<T> {
return this.withTimeout("test", promise, ms);
}
public testSerde(value: unknown): unknown {
const s = this.serialize(value);
return this.deserialize(s);
}
}
describe("AbstractWorkerCommunicator", () => {
it("connect / disconnect have no-op defaults", {
timeout: 5000,
}, async () => {
const c = new TestCommunicator();
await expect(c.connect("ep")).resolves.toBeUndefined();
await expect(c.disconnect("ep")).resolves.toBeUndefined();
});
it("withTimeout resolves a fast promise before the deadline fires", {
timeout: 5000,
}, async () => {
const c = new TestCommunicator();
const result = await c.testTimeout(Promise.resolve(42), 100);
expect(result).toBe(42);
});
it("withTimeout rejects with HTTP.TIMEOUT after the deadline", {
timeout: 5000,
}, async () => {
const c = new TestCommunicator();
const pending = new Promise<never>(() => {});
const handled = c.testTimeout(pending, 50).catch((err) => err);
await expect(handled).resolves.toBeInstanceOf(HttpTimeoutError);
});
it("withTimeout uses defaultTimeoutMs when no override is provided", {
timeout: 5000,
}, async () => {
const c = new TestCommunicator({ defaultTimeoutMs: 50 });
const pending = new Promise<never>(() => {});
const handled = c.testTimeout(pending).catch((err) => err);
await expect(handled).resolves.toBeInstanceOf(HttpTimeoutError);
});
it("withTimeout coerces non-Error rejections to Error", {
timeout: 5000,
}, async () => {
const c = new TestCommunicator();
const handled = c
.testTimeout(Promise.reject("boom"), 1_000)
.catch((err) => err);
await expect(handled).resolves.toBeInstanceOf(Error);
});
it("withTimeout forwards thrown Error rejections", {
timeout: 5000,
}, async () => {
const c = new TestCommunicator();
const failure = new Error("nope");
const handled = c
.testTimeout(Promise.reject(failure), 1_000)
.catch((err) => err);
await expect(handled).resolves.toBe(failure);
});
it("serialise / deserialise round-trip JSON", { timeout: 5000 }, () => {
const c = new TestCommunicator();
expect(c.testSerde({ a: 1, b: ["x"] })).toEqual({ a: 1, b: ["x"] });
});
it("constructor defaults apply when no options or deps are passed", {
timeout: 5000,
}, () => {
const c = new TestCommunicator();
expect(c.getProtocol()).toBe("test");
});
});

View File

@@ -0,0 +1,124 @@
import type {
DistributedTask,
WorkerHealth,
WorkerStatusValue,
} from "@suaveplan/queue-distribution";
import type { Clock } from "@suaveplan/types";
import { HttpTimeoutError } from "../../errors/queue-distribution-http-error.js";
/**
* Pluggable contract for talking to a remote worker. Implementations can pick
* the wire format (HTTP, WebSocket, gRPC, …) and the transport layer.
*/
export interface WorkerCommunicator {
sendTask<I, O>(
endpoint: string,
task: DistributedTask<I, O>,
input: I,
signal?: AbortSignal
): Promise<O>;
checkStatus(
endpoint: string,
signal?: AbortSignal
): Promise<WorkerStatusValue>;
checkHealth(endpoint: string, signal?: AbortSignal): Promise<WorkerHealth>;
connect(endpoint: string, signal?: AbortSignal): Promise<void>;
disconnect(endpoint: string, signal?: AbortSignal): Promise<void>;
ping(
endpoint: string,
timeoutMs?: number,
signal?: AbortSignal
): Promise<number>;
getProtocol(): string;
}
export interface AbstractWorkerCommunicatorDeps {
clock?: Clock;
}
/**
* Base class for `WorkerCommunicator` implementations. Provides
* default-timeout bookkeeping, header carriage, JSON serialise/deserialise
* helpers, and a `withTimeout` race used by every sendTask/check.
*/
export abstract class AbstractWorkerCommunicator implements WorkerCommunicator {
protected readonly defaultTimeoutMs: number;
protected readonly headers: Readonly<Record<string, string>>;
protected readonly clock: Clock;
constructor(
options: {
defaultTimeoutMs?: number;
headers?: Readonly<Record<string, string>>;
} = {},
deps: AbstractWorkerCommunicatorDeps = {}
) {
this.defaultTimeoutMs = options.defaultTimeoutMs ?? 30_000;
this.headers = options.headers ?? {};
this.clock = deps.clock ?? Date.now;
}
abstract sendTask<I, O>(
endpoint: string,
task: DistributedTask<I, O>,
input: I,
signal?: AbortSignal
): Promise<O>;
abstract checkStatus(
endpoint: string,
signal?: AbortSignal
): Promise<WorkerStatusValue>;
abstract checkHealth(
endpoint: string,
signal?: AbortSignal
): Promise<WorkerHealth>;
abstract ping(
endpoint: string,
timeoutMs?: number,
signal?: AbortSignal
): Promise<number>;
abstract getProtocol(): string;
async connect(_endpoint: string, _signal?: AbortSignal): Promise<void> {
// Default: stateless transports have nothing to do at connect time.
}
async disconnect(_endpoint: string, _signal?: AbortSignal): Promise<void> {
// Default: stateless transports have nothing to do at disconnect time.
}
protected serialize(data: unknown): string {
return JSON.stringify(data);
}
protected deserialize<T>(data: string): T {
return JSON.parse(data) as T;
}
protected async withTimeout<T>(
operation: string,
promise: Promise<T>,
timeoutMs?: number
): Promise<T> {
const deadline = timeoutMs ?? this.defaultTimeoutMs;
let timer!: ReturnType<typeof setTimeout>;
const timeoutSignal = new Promise<T>((_, reject) => {
timer = setTimeout(() => {
reject(
new HttpTimeoutError({ operation, timeoutMs: deadline })
);
}, deadline);
});
const safeOriginal = promise.then(
(value) => {
clearTimeout(timer);
return value;
},
(err: unknown) => {
clearTimeout(timer);
throw err instanceof Error ? err : new Error(String(err));
}
);
return Promise.race([safeOriginal, timeoutSignal]);
}
}

View File

@@ -0,0 +1,65 @@
# `HttpWorkerCommunicator`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`HttpWorkerCommunicator` is the `WorkerCommunicator` implementation for talking to a remote worker over HTTP(S). It uses an injectable `fetch` (defaulting to `globalThis.fetch`) so tests can drive deterministic request/response sequences without an actual server. Every response is validated against a Zod envelope (`{ success: true, data }` | `{ success: false, error }`) so malformed payloads are rejected at the boundary rather than smuggled in as `unknown` data.
## Features
- Endpoints used: `POST /execute`, `GET /status`, `GET /health`, `GET /ping`.
- Zod-validated wire envelope (`workerApiResponseSchema(dataSchema)`).
- Structured error mapping: `RESPONSE_MALFORMED` for envelope mismatch, `PROTOCOL_ERROR` for upstream-reported failures and non-2xx with a successful envelope, `REQUEST_FAILED` for network rejections including `AbortError`.
- Configurable headers and `defaultTimeoutMs`; injectable `fetch` and `Clock`.
- OTel histogram + counter per request, dimensioned by `outcome`.
- AbortSignal threaded into `fetch.init.signal`.
- `getProtocol()` returns `"http"`.
## Usage: Basic Example
```ts
import { HttpWorkerCommunicator } from "@suaveplan/queue-distribution-http/communicator/http/http-communicator";
const communicator = new HttpWorkerCommunicator({
defaultTimeoutMs: 5_000,
headers: { "X-Tenant": "acme" },
});
const result = await communicator.sendTask("https://worker.example", task, input);
```
## Usage: Advanced Example
Inject a deterministic `fetch` for tests, or wrap it with a custom auth + retry layer in production.
```ts
import { HttpWorkerCommunicator } from "@suaveplan/queue-distribution-http/communicator/http/http-communicator";
const communicator = new HttpWorkerCommunicator(
{ defaultTimeoutMs: 10_000, headers: { Authorization: `Bearer ${token}` } },
{
fetch: async (url, init) => {
const response = await globalThis.fetch(url, {
...init,
credentials: "include",
});
return response;
},
},
);
```
## API Reference
- `class HttpWorkerCommunicator extends AbstractWorkerCommunicator`.
- `constructor(options?: HttpWorkerCommunicatorOptions, deps?: HttpWorkerCommunicatorDeps)`.
- `interface HttpWorkerCommunicatorOptions` — `defaultTimeoutMs?`, `headers?`.
- `interface HttpWorkerCommunicatorDeps` — `clock?`, `fetch?`.
- `type FetchLike` — minimal `fetch` shape (`ok`, `status`, `statusText`, `json()`).
- `function workerApiResponseSchema<T>(dataSchema)` — produces the Zod envelope for any payload shape.
## Implementation Notes
The wire envelope is a discriminated union (`success: true` carries `data`, `success: false` carries `error`). The communicator treats non-2xx HTTP status as a protocol error even when the envelope says `success: true` — that protects against misbehaving servers returning 500 with a "this somehow worked" payload. Headers are spread at request time so per-call overrides are not yet supported; if needed, subclass and override `#makeRequest`.

View File

@@ -0,0 +1,229 @@
import type { DistributedTask } from "@suaveplan/queue-distribution";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
HttpProtocolError,
HttpRequestFailedError,
HttpResponseMalformedError,
} from "../../errors/queue-distribution-http-error.js";
import { type FetchLike, HttpWorkerCommunicator } from "./http-communicator.js";
function makeTask<I, O>(id = "t"): DistributedTask<I, O> {
let status = "PENDING" as string;
return {
id,
execute: async () => undefined as never,
getStatus: () => status as never,
setStatus: (s) => {
status = s;
},
getMetadata: () => ({ createdAt: new Date(0), attempts: 0 }),
};
}
function fakeFetch(
impl: (
url: string,
init?: RequestInit
) => Promise<{
ok: boolean;
status: number;
statusText: string;
json: () => Promise<unknown>;
}>
): FetchLike {
return impl;
}
describe("HttpWorkerCommunicator", () => {
it("sendTask resolves with typed data on success", {
timeout: 5000,
}, async () => {
const fetcher = fakeFetch(async () => ({
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ success: true, data: "OK" }),
}));
const c = new HttpWorkerCommunicator({}, { fetch: fetcher });
expect(await c.sendTask("http://x", makeTask("t"), "in")).toBe("OK");
});
it("getProtocol returns 'http'", { timeout: 5000 }, () => {
expect(new HttpWorkerCommunicator().getProtocol()).toBe("http");
});
it("rejects with HTTP.RESPONSE_MALFORMED when envelope shape is wrong", {
timeout: 5000,
}, async () => {
const fetcher = fakeFetch(async () => ({
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ foo: 1 }),
}));
const c = new HttpWorkerCommunicator({}, { fetch: fetcher });
await expect(
c.sendTask("http://x", makeTask("t"), "in")
).rejects.toThrow(HttpResponseMalformedError);
});
it("rejects with HTTP.PROTOCOL_ERROR and carries upstream message on cause", {
timeout: 5000,
}, async () => {
const fetcher = fakeFetch(async () => ({
ok: false,
status: 500,
statusText: "Server Error",
json: async () => ({
success: false,
error: { code: "INTERNAL", message: "boom" },
}),
}));
const c = new HttpWorkerCommunicator({}, { fetch: fetcher });
await expect(
c.sendTask("http://x", makeTask("t"), "in")
).rejects.toMatchObject({ code: "HTTP.PROTOCOL_ERROR" });
});
it("rejects with HTTP.PROTOCOL_ERROR when envelope is success but HTTP is non-2xx", {
timeout: 5000,
}, async () => {
const fetcher = fakeFetch(async () => ({
ok: false,
status: 500,
statusText: "boom",
json: async () => ({ success: true, data: "OK" }),
}));
const c = new HttpWorkerCommunicator({}, { fetch: fetcher });
await expect(
c.sendTask("http://x", makeTask("t"), "in")
).rejects.toThrow(HttpProtocolError);
});
it("network failure surfaces as HTTP.REQUEST_FAILED", {
timeout: 5000,
}, async () => {
const fetcher = fakeFetch(async () => {
throw new Error("ECONNREFUSED");
});
const c = new HttpWorkerCommunicator({}, { fetch: fetcher });
await expect(
c.sendTask("http://x", makeTask("t"), "in")
).rejects.toThrow(HttpRequestFailedError);
});
it("forwards AbortSignal to the injected fetch", {
timeout: 5000,
}, async () => {
let sawAborted = false;
const fetcher = fakeFetch(async (_url, init) => {
sawAborted = init?.signal?.aborted ?? false;
throw Object.assign(new Error("aborted"), { name: "AbortError" });
});
const c = new HttpWorkerCommunicator({}, { fetch: fetcher });
const ctrl = new AbortController();
ctrl.abort();
await expect(
c.sendTask("http://x", makeTask("t"), "in", ctrl.signal)
).rejects.toThrow(HttpRequestFailedError);
expect(sawAborted).toBe(true);
});
it("checkStatus parses the typed enum", { timeout: 5000 }, async () => {
const fetcher = fakeFetch(async () => ({
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ success: true, data: "BUSY" }),
}));
const c = new HttpWorkerCommunicator({}, { fetch: fetcher });
expect(await c.checkStatus("http://x")).toBe("BUSY");
});
it("checkHealth parses the worker-health shape", {
timeout: 5000,
}, async () => {
const health = {
isHealthy: true,
metrics: {
cpu: 1,
memory: 2,
tasksFailed: 0,
tasksSucceeded: 1,
responseTime: 10,
},
};
const fetcher = fakeFetch(async () => ({
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ success: true, data: health }),
}));
const c = new HttpWorkerCommunicator({}, { fetch: fetcher });
expect(await c.checkHealth("http://x")).toEqual(health);
});
it("ping returns elapsed milliseconds from injected Clock", {
timeout: 5000,
}, async () => {
// Clock is consumed by ping() start, #makeRequest start + end, ping() end.
const ticks = [1000, 1000, 1200, 1500];
let i = 0;
const fetcher = fakeFetch(async () => ({
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ success: true, data: { timestamp: 1 } }),
}));
const c = new HttpWorkerCommunicator(
{},
{ fetch: fetcher, clock: () => ticks[i++ % ticks.length] ?? 0 }
);
expect(await c.ping("http://x", 1000)).toBe(500);
});
it("handles endpoints with trailing slash and path without leading slash", {
timeout: 5000,
}, async () => {
let observedUrl = "";
const fetcher = fakeFetch(async (url) => {
observedUrl = url;
return {
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ success: true, data: "ONLINE" }),
};
});
const c = new HttpWorkerCommunicator({}, { fetch: fetcher });
await c.checkStatus("http://x/");
expect(observedUrl).toBe("http://x/status");
});
it("propagates configured headers", { timeout: 5000 }, async () => {
let observedHeaders: Record<string, string> = {};
const fetcher = fakeFetch(async (_url, init) => {
observedHeaders = (init?.headers ?? {}) as Record<string, string>;
return {
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ success: true, data: "ONLINE" }),
};
});
const c = new HttpWorkerCommunicator(
{ headers: { "X-Auth": "secret" } },
{ fetch: fetcher }
);
await c.checkStatus("http://x");
expect(observedHeaders["X-Auth"]).toBe("secret");
});
it("defaults to globalThis.fetch when no fetch is injected", {
timeout: 5000,
}, () => {
// Just verify construction succeeds; the underlying global is not invoked here.
const c = new HttpWorkerCommunicator();
expect(c).toBeInstanceOf(HttpWorkerCommunicator);
});
});

View File

@@ -0,0 +1,236 @@
import type {
DistributedTask,
WorkerHealth,
WorkerStatusValue,
} from "@suaveplan/queue-distribution";
import z from "zod";
import {
HttpProtocolError,
HttpRequestFailedError,
HttpResponseMalformedError,
} from "../../errors/queue-distribution-http-error.js";
import {
recordHttpRequestDuration,
recordHttpRequestOutcome,
} from "../../observability/instruments.js";
import {
AbstractWorkerCommunicator,
type AbstractWorkerCommunicatorDeps,
} from "../base/base.js";
/** Zod schema for the standard wire envelope every endpoint returns. */
export function workerApiResponseSchema<T>(dataSchema: z.ZodType<T>) {
return z.union([
z.object({ success: z.literal(true), data: dataSchema }),
z.object({
success: z.literal(false),
error: z.object({
code: z.string(),
message: z.string(),
details: z.unknown().optional(),
}),
}),
]);
}
export type FetchLike = (
input: string,
init?: RequestInit
) => Promise<{
ok: boolean;
status: number;
statusText: string;
json(): Promise<unknown>;
}>;
export interface HttpWorkerCommunicatorOptions {
defaultTimeoutMs?: number;
headers?: Readonly<Record<string, string>>;
}
export interface HttpWorkerCommunicatorDeps
extends AbstractWorkerCommunicatorDeps {
fetch?: FetchLike;
}
const STATUS_SCHEMA = z.enum(["ONLINE", "BUSY", "OFFLINE", "UNHEALTHY"]);
const HEALTH_SCHEMA = z.object({
isHealthy: z.boolean(),
metrics: z
.object({
cpu: z.number(),
memory: z.number(),
tasksFailed: z.number(),
tasksSucceeded: z.number(),
responseTime: z.number(),
})
.passthrough(),
});
const PING_SCHEMA = z.object({ timestamp: z.number() });
const TASK_RESULT_SCHEMA = z.unknown();
/**
* `WorkerCommunicator` over `fetch`. The `fetch` implementation is
* injectable so tests can drive deterministic responses; production
* defaults to `globalThis.fetch`.
*/
export class HttpWorkerCommunicator extends AbstractWorkerCommunicator {
readonly #fetch: FetchLike;
constructor(
options: HttpWorkerCommunicatorOptions = {},
deps: HttpWorkerCommunicatorDeps = {}
) {
super(options, deps);
this.#fetch =
deps.fetch ?? (globalThis.fetch.bind(globalThis) as FetchLike);
}
override async sendTask<I, O>(
endpoint: string,
task: DistributedTask<I, O>,
input: I,
signal?: AbortSignal
): Promise<O> {
const url = buildUrl(endpoint, "/execute");
const payload = {
taskId: task.id,
taskMetadata: task.getMetadata(),
input,
};
return this.withTimeout(
"sendTask",
this.#makeRequest<O>(
url,
"POST",
TASK_RESULT_SCHEMA as z.ZodType<O>,
payload,
signal
)
);
}
override async checkStatus(
endpoint: string,
signal?: AbortSignal
): Promise<WorkerStatusValue> {
const url = buildUrl(endpoint, "/status");
return this.withTimeout(
"checkStatus",
this.#makeRequest(url, "GET", STATUS_SCHEMA, undefined, signal)
);
}
override async checkHealth(
endpoint: string,
signal?: AbortSignal
): Promise<WorkerHealth> {
const url = buildUrl(endpoint, "/health");
return this.withTimeout(
"checkHealth",
this.#makeRequest(
url,
"GET",
HEALTH_SCHEMA as z.ZodType<WorkerHealth>,
undefined,
signal
)
);
}
override async ping(
endpoint: string,
timeoutMs?: number,
signal?: AbortSignal
): Promise<number> {
const url = buildUrl(endpoint, "/ping");
const start = this.clock();
await this.withTimeout(
"ping",
this.#makeRequest(url, "GET", PING_SCHEMA, undefined, signal),
timeoutMs
);
return this.clock() - start;
}
override getProtocol(): string {
return "http";
}
async #makeRequest<T>(
url: string,
method: "GET" | "POST",
dataSchema: z.ZodType<T>,
body: unknown,
signal?: AbortSignal
): Promise<T> {
const init: RequestInit = {
method,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...this.headers,
},
};
if (body !== undefined) {
init.body = this.serialize(body);
}
if (signal !== undefined) {
init.signal = signal;
}
const start = this.clock();
try {
const response = await this.#fetch(url, init);
const json = await response.json();
const envelope =
workerApiResponseSchema(dataSchema).safeParse(json);
if (!envelope.success) {
recordHttpRequestOutcome("failure");
recordHttpRequestDuration(this.clock() - start, "failure");
throw new HttpResponseMalformedError({
url,
cause: envelope.error,
});
}
if (!envelope.data.success) {
recordHttpRequestOutcome("failure");
recordHttpRequestDuration(this.clock() - start, "failure");
throw new HttpProtocolError({
url,
status: response.status,
upstreamCode: envelope.data.error.code,
upstreamMessage: envelope.data.error.message,
});
}
if (!response.ok) {
recordHttpRequestOutcome("failure");
recordHttpRequestDuration(this.clock() - start, "failure");
throw new HttpProtocolError({
url,
status: response.status,
upstreamCode: "HTTP_NON_2XX",
upstreamMessage: response.statusText,
});
}
recordHttpRequestOutcome("success");
recordHttpRequestDuration(this.clock() - start, "success");
return envelope.data.data;
} catch (error: unknown) {
if (
error instanceof HttpResponseMalformedError ||
error instanceof HttpProtocolError
) {
throw error;
}
throw new HttpRequestFailedError({ url, cause: error });
}
}
}
function buildUrl(endpoint: string, path: string): string {
const base = endpoint.endsWith("/") ? endpoint.slice(0, -1) : endpoint;
return `${base}${path}`;
}

View File

@@ -0,0 +1,80 @@
# `QueueDistributionHttpError` registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`QueueDistributionHttpError` is the base class for every structured error thrown by `@suaveplan/queue-distribution-http`. Each subclass carries a stable code from the registry produced by `defineCodes("HTTP", [...])`. Consumers programmatically match on `error.code` to distinguish network failures from upstream protocol errors from timeouts — the registry is exhaustive and every code is thrown by at least one production code path.
## Features
- 6 stable codes — `REQUEST_FAILED`, `PROTOCOL_ERROR`, `TIMEOUT`, `RESPONSE_MALFORMED`, `CONNECT_FAILED`, `UNREACHABLE`.
- One concrete subclass per code with a typed constructor signature.
- Optional `cause` narrowed to `Error` instances; non-Error causes drop silently.
- Compatible with `SuaveplanError` cause-chain traversal.
## Usage: Basic Example
```ts
import {
HTTP_CODES,
QueueDistributionHttpError,
} from "@suaveplan/queue-distribution-http/errors/queue-distribution-http-error";
try {
await communicator.sendTask("http://w", task, input);
} catch (error: unknown) {
if (
error instanceof QueueDistributionHttpError &&
error.code === HTTP_CODES.PROTOCOL_ERROR
) {
// Upstream worker reported a structured failure — surface it.
}
throw error;
}
```
## Usage: Advanced Example
Combine with `@suaveplan/error` cause-chain helpers to extract underlying network failures.
```ts
import {
HTTP_CODES,
HttpRequestFailedError,
QueueDistributionHttpError,
} from "@suaveplan/queue-distribution-http/errors/queue-distribution-http-error";
function classify(error: unknown): "network" | "protocol" | "unknown" {
if (!(error instanceof QueueDistributionHttpError)) {
return "unknown";
}
if (error.code === HTTP_CODES.REQUEST_FAILED) {
return "network";
}
if (
error.code === HTTP_CODES.PROTOCOL_ERROR ||
error.code === HTTP_CODES.RESPONSE_MALFORMED
) {
return "protocol";
}
return "unknown";
}
```
## API Reference
- `const HTTP_CODES` — frozen registry of `HTTP.*` codes.
- `type QueueDistributionHttpErrorCode` — union of every code.
- `class QueueDistributionHttpError extends SuaveplanError<QueueDistributionHttpErrorCode>`.
- `class HttpRequestFailedError({ url, cause? })`.
- `class HttpProtocolError({ url, status, upstreamCode, upstreamMessage, cause? })`.
- `class HttpTimeoutError({ operation, timeoutMs })`.
- `class HttpResponseMalformedError({ url, cause? })`.
- `class HttpConnectFailedError({ endpoint, attempts, cause? })`.
- `class HttpUnreachableError({ endpoint, cause? })`.
## Implementation Notes
`HttpProtocolError` carries the upstream `code` and `message` in its formatted message so logs and dashboards can group failures by the remote worker's own error vocabulary without parsing JSON. `HttpUnreachableError` exists as a documented "this is the failure-class" signal — the error is allocated and discarded inside `RemoteWorker.#handleCommunicationError` because the worker's reaction (markUnhealthy + reconnect) is the actual recovery; the error class is here so future call sites (e.g. observability dashboards) can match on the registered code.

View File

@@ -0,0 +1,98 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
HTTP_CODES,
HttpConnectFailedError,
HttpProtocolError,
HttpRequestFailedError,
HttpResponseMalformedError,
HttpTimeoutError,
HttpUnreachableError,
QueueDistributionHttpError,
} from "./queue-distribution-http-error.js";
describe("QueueDistributionHttpError registry", () => {
it("registers exactly the expected codes", { timeout: 5000 }, () => {
expect(Object.values(HTTP_CODES).sort()).toEqual(
[
"HTTP.REQUEST_FAILED",
"HTTP.PROTOCOL_ERROR",
"HTTP.TIMEOUT",
"HTTP.RESPONSE_MALFORMED",
"HTTP.CONNECT_FAILED",
"HTTP.UNREACHABLE",
].sort()
);
});
it("every error subclass carries the right code", { timeout: 5000 }, () => {
const samples: { err: QueueDistributionHttpError; code: string }[] = [
{
err: new HttpRequestFailedError({ url: "x" }),
code: HTTP_CODES.REQUEST_FAILED,
},
{
err: new HttpProtocolError({
url: "x",
status: 500,
upstreamCode: "U",
upstreamMessage: "M",
}),
code: HTTP_CODES.PROTOCOL_ERROR,
},
{
err: new HttpTimeoutError({
operation: "ping",
timeoutMs: 100,
}),
code: HTTP_CODES.TIMEOUT,
},
{
err: new HttpResponseMalformedError({ url: "x" }),
code: HTTP_CODES.RESPONSE_MALFORMED,
},
{
err: new HttpConnectFailedError({ endpoint: "x", attempts: 3 }),
code: HTTP_CODES.CONNECT_FAILED,
},
{
err: new HttpUnreachableError({ endpoint: "x" }),
code: HTTP_CODES.UNREACHABLE,
},
];
for (const { err, code } of samples) {
expect(err).toBeInstanceOf(QueueDistributionHttpError);
expect(err.code).toBe(code);
}
});
it("preserves Error causes and drops non-Error causes", {
timeout: 5000,
}, () => {
const cause = new Error("upstream");
expect(new HttpRequestFailedError({ url: "x", cause }).cause).toBe(
cause
);
expect(
new HttpRequestFailedError({ url: "x", cause: "string" }).cause
).toBeUndefined();
expect(
new HttpProtocolError({
url: "x",
status: 500,
upstreamCode: "U",
upstreamMessage: "M",
cause,
}).cause
).toBe(cause);
expect(new HttpResponseMalformedError({ url: "x", cause }).cause).toBe(
cause
);
expect(
new HttpConnectFailedError({ endpoint: "x", attempts: 3, cause })
.cause
).toBe(cause);
expect(new HttpUnreachableError({ endpoint: "x", cause }).cause).toBe(
cause
);
});
});

View File

@@ -0,0 +1,95 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/** Stable error-code registry for `@suaveplan/queue-distribution-http`. */
export const HTTP_CODES = defineCodes("HTTP", [
"REQUEST_FAILED",
"PROTOCOL_ERROR",
"TIMEOUT",
"RESPONSE_MALFORMED",
"CONNECT_FAILED",
"UNREACHABLE",
] as const);
export type QueueDistributionHttpErrorCode =
(typeof HTTP_CODES)[keyof typeof HTTP_CODES];
export class QueueDistributionHttpError extends SuaveplanError<QueueDistributionHttpErrorCode> {}
function asErrorCause(cause: unknown): Error | undefined {
return cause instanceof Error ? cause : undefined;
}
/** Thrown when the network-layer `fetch` rejects (DNS, TCP, TLS, AbortError, etc.). */
export class HttpRequestFailedError extends QueueDistributionHttpError {
constructor(options: { url: string; cause?: unknown }) {
super(
HTTP_CODES.REQUEST_FAILED,
`HTTP request to ${options.url} failed`,
{ cause: asErrorCause(options.cause) }
);
}
}
/** Thrown when the wire-envelope reports success === false or HTTP status is non-2xx. */
export class HttpProtocolError extends QueueDistributionHttpError {
constructor(options: {
url: string;
status: number;
upstreamCode: string;
upstreamMessage: string;
cause?: unknown;
}) {
super(
HTTP_CODES.PROTOCOL_ERROR,
`HTTP ${options.status} from ${options.url}: ${options.upstreamCode} — ${options.upstreamMessage}`,
{ cause: asErrorCause(options.cause) }
);
}
}
/** Thrown when an HTTP operation exceeds its configured deadline. */
export class HttpTimeoutError extends QueueDistributionHttpError {
constructor(options: { operation: string; timeoutMs: number }) {
super(
HTTP_CODES.TIMEOUT,
`HTTP ${options.operation} exceeded ${options.timeoutMs}ms deadline`
);
}
}
/** Thrown when the response body cannot be parsed against the wire-envelope Zod schema. */
export class HttpResponseMalformedError extends QueueDistributionHttpError {
constructor(options: { url: string; cause?: unknown }) {
super(
HTTP_CODES.RESPONSE_MALFORMED,
`HTTP response from ${options.url} did not match the expected envelope`,
{ cause: asErrorCause(options.cause) }
);
}
}
/** Thrown when `RemoteWorker` exhausts its connect-attempt budget. */
export class HttpConnectFailedError extends QueueDistributionHttpError {
constructor(options: {
endpoint: string;
attempts: number;
cause?: unknown;
}) {
super(
HTTP_CODES.CONNECT_FAILED,
`Failed to connect to ${options.endpoint} after ${options.attempts} attempt(s)`,
{ cause: asErrorCause(options.cause) }
);
}
}
/** Emitted when a periodic ping shows the remote worker is unreachable. */
export class HttpUnreachableError extends QueueDistributionHttpError {
constructor(options: { endpoint: string; cause?: unknown }) {
super(
HTTP_CODES.UNREACHABLE,
`Remote worker at ${options.endpoint} is unreachable`,
{ cause: asErrorCause(options.cause) }
);
}
}

View File

@@ -0,0 +1,35 @@
export {
AbstractWorkerCommunicator,
type AbstractWorkerCommunicatorDeps,
type WorkerCommunicator,
} from "./communicator/base/base.js";
export {
type FetchLike,
HttpWorkerCommunicator,
type HttpWorkerCommunicatorDeps,
type HttpWorkerCommunicatorOptions,
workerApiResponseSchema,
} from "./communicator/http/http-communicator.js";
export {
HTTP_CODES,
HttpConnectFailedError,
HttpProtocolError,
HttpRequestFailedError,
HttpResponseMalformedError,
HttpTimeoutError,
HttpUnreachableError,
QueueDistributionHttpError,
type QueueDistributionHttpErrorCode,
} from "./errors/queue-distribution-http-error.js";
export {
getConnectTracer,
getSendTaskTracer,
recordConnectAttempt,
recordHttpRequestDuration,
recordHttpRequestOutcome,
resetInstrumentCache,
} from "./observability/instruments.js";
export {
RemoteWorker,
type RemoteWorkerOptions,
} from "./worker/remote/remote.js";

View File

@@ -0,0 +1,59 @@
# HTTP 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-http`. Instruments are registered under the meter / tracer name `"@suaveplan/queue-distribution-http"` so consumers configuring an SDK can route them with a single rule. Tracer / meter handles are cached behind `resetInstrumentCache()` so tests can swap providers between assertions.
## Features
- Counters: `queue_dist_http.request_total{outcome}`, `queue_dist_http.connect.attempts_total{outcome}`.
- Histogram: `queue_dist_http.request.duration_ms{outcome}`.
- Tracers: `queue_dist_http.sendTask`, `queue_dist_http.connect`.
- `resetInstrumentCache()` for tests that install a new provider mid-suite.
## Usage: Basic Example
Production code calls the instruments automatically. Consumers add 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 {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { metrics } from "@opentelemetry/api";
import { resetInstrumentCache } from "@suaveplan/queue-distribution-http/observability/instruments";
const exporter = new InMemoryMetricExporter(AggregationTemporality.DELTA);
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 100 });
metrics.setGlobalMeterProvider(new MeterProvider({ readers: [reader] }));
resetInstrumentCache();
// … run system under test, then await reader.forceFlush() …
```
## API Reference
- `function resetInstrumentCache(): void`.
- `function recordHttpRequestOutcome(outcome: "success" | "failure"): void`.
- `function recordHttpRequestDuration(durationMs, outcome): void`.
- `function recordConnectAttempt(outcome: "success" | "failure" | "exhausted"): void`.
- `function getSendTaskTracer(): Tracer`, `function getConnectTracer(): Tracer`.
## Implementation Notes
The instruments share a single meter handle so the per-call overhead in the hot path is a single map lookup. `resetInstrumentCache()` is wired exclusively for tests; production code never calls it because the underlying global provider is set once at startup. The `outcome` attribute is constrained to the three documented values for connect attempts and two for requests — keeping the cardinality bounded for backend dashboards.

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
getConnectTracer,
getSendTaskTracer,
recordConnectAttempt,
recordHttpRequestDuration,
recordHttpRequestOutcome,
resetInstrumentCache,
} from "./instruments.js";
describe("HTTP-plugin observability", () => {
it("every recorder is callable without a provider", {
timeout: 5000,
}, () => {
expect(() => {
recordHttpRequestOutcome("success");
recordHttpRequestOutcome("failure");
recordHttpRequestDuration(100, "success");
recordHttpRequestDuration(150, "failure");
recordConnectAttempt("success");
recordConnectAttempt("failure");
recordConnectAttempt("exhausted");
}).not.toThrow();
});
it("tracers are cached across calls", { timeout: 5000 }, () => {
resetInstrumentCache();
const t1 = getSendTaskTracer();
const t2 = getSendTaskTracer();
expect(t1).toBe(t2);
expect(typeof getConnectTracer().startSpan).toBe("function");
});
});

View File

@@ -0,0 +1,58 @@
import { metrics, type Tracer, trace } from "@opentelemetry/api";
const METER_NAME = "@suaveplan/queue-distribution-http";
const TRACER_NAME = "@suaveplan/queue-distribution-http";
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;
}
/** Reset cached handles. Tests call this after swapping a global provider. */
export function resetInstrumentCache(): void {
cachedMeter = undefined;
cachedTracer = undefined;
}
export function recordHttpRequestOutcome(outcome: "success" | "failure"): void {
getMeter()
.createCounter("queue_dist_http.request_total")
.add(1, { outcome });
}
export function recordHttpRequestDuration(
durationMs: number,
outcome: "success" | "failure"
): void {
getMeter()
.createHistogram("queue_dist_http.request.duration_ms")
.record(durationMs, { outcome });
}
export function recordConnectAttempt(
outcome: "success" | "failure" | "exhausted"
): void {
getMeter()
.createCounter("queue_dist_http.connect.attempts_total")
.add(1, { outcome });
}
export function getSendTaskTracer(): Tracer {
return getTracer();
}
export function getConnectTracer(): Tracer {
return getTracer();
}

View File

@@ -0,0 +1,82 @@
# `RemoteWorker`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`RemoteWorker` extends `AbstractWorker` and delegates execution to a remote endpoint via an injected `WorkerCommunicator`. It manages the connect / reconnect lifecycle (with `@suaveplan/retry`-style exponential backoff routed through `sleep`), runs a periodic health-check loop, and reacts to communication failures by pinging and either reporting the worker still-reachable or marking it `UNHEALTHY`. The connect loop is bounded — no recursion — and every retry attempt is captured in the OTel `queue_dist_http.connect.attempts_total` counter.
## Features
- `start()` calls `#connect` (exponential backoff between attempts) then starts a `setInterval` health-check loop.
- `stop(force, signal?)` halts the loop, disconnects the communicator, and flips `#isConnected` to `false`.
- `doExecuteTask` refuses when not connected (`HTTP.CONNECT_FAILED`).
- Communication-error recovery: ping with a 5-second timeout; reachable → log + propagate; unreachable → `markUnhealthy("Unreachable")` + `#isConnected = false`.
- `getHealth()` proxies to the remote and refreshes `lastHealthCheck` / `lastHealthStatus`; on failure returns a fallback unhealthy payload.
- Configurable `healthCheckIntervalMs`, `maxConnectionAttempts`, `initialBackoffMs`, `maxBackoffMs`.
- `getCapabilities()` advertises `{ isRemote: true, protocol, endpoint }` so strategies can route work.
## Usage: Basic Example
```ts
import { HttpWorkerCommunicator } from "@suaveplan/queue-distribution-http";
import { RemoteWorker } from "@suaveplan/queue-distribution-http/worker/remote/remote";
const communicator = new HttpWorkerCommunicator();
const worker = new RemoteWorker("https://worker.example", communicator, {
healthCheckIntervalMs: 15_000,
maxConnectionAttempts: 5,
});
await worker.start();
const result = await worker.executeTask(task, input);
await worker.stop();
```
## Usage: Advanced Example
For deterministic property-tests, inject a custom communicator and watch connect retries with a fake clock.
```ts
import { RemoteWorker } from "@suaveplan/queue-distribution-http/worker/remote/remote";
import type { WorkerCommunicator } from "@suaveplan/queue-distribution-http";
let connectAttempts = 0;
const flakyCommunicator: WorkerCommunicator = {
sendTask: async (_e, _t, input) => input as never,
connect: async () => {
connectAttempts += 1;
if (connectAttempts < 3) throw new Error("refused");
},
checkStatus: async () => "ONLINE",
checkHealth: async () => ({
isHealthy: true,
metrics: { cpu: 0, memory: 0, tasksFailed: 0, tasksSucceeded: 0, responseTime: 0 },
}),
disconnect: async () => undefined,
ping: async () => 5,
getProtocol: () => "http",
};
const worker = new RemoteWorker("https://worker", flakyCommunicator, {
maxConnectionAttempts: 5,
initialBackoffMs: 0,
healthCheckIntervalMs: 60_000,
});
await worker.start();
console.log(connectAttempts); // 3
```
## API Reference
- `class RemoteWorker extends AbstractWorker`.
- `constructor(endpoint, communicator, options?: RemoteWorkerOptions, deps?: AbstractWorkerDeps)`.
- `interface RemoteWorkerOptions extends AbstractWorkerOptions` — `healthCheckIntervalMs?`, `maxConnectionAttempts?`, `initialBackoffMs?`, `maxBackoffMs?`.
- `getEndpoint(): string`, `getCommunicator(): WorkerCommunicator`, `isConnected(): boolean`.
- `getHealth(signal?): Promise<WorkerHealth>` — overrides the abstract default.
- Protected: `doExecuteTask`, `doStart`, `doStop`, `getType()`, `getCapabilities()`.
## Implementation Notes
The connect loop iterates up to `maxConnectionAttempts` times, doubling the delay each iteration (capped at `maxBackoffMs`). On every retry, the loop also re-checks the remote status — so a worker that responds OK on the third attempt connects without further delay. Communication errors during sendTask are NOT silently swallowed: the worker rethrows so the calling `WorkerManager` can retry against a different worker via its load-balancing strategy. The ping-based reachability probe runs in parallel with the rethrow so the worker's `status` is up-to-date by the time the next strategy invocation observes the pool.

View File

@@ -0,0 +1,366 @@
import type {
DistributedTask,
WorkerHealth,
WorkerStatusValue,
} from "@suaveplan/queue-distribution";
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import type { WorkerCommunicator } from "../../communicator/base/base.js";
import { HttpConnectFailedError } from "../../errors/queue-distribution-http-error.js";
import { RemoteWorker } from "./remote.js";
function fakeCommunicator(
overrides: Partial<WorkerCommunicator> = {}
): WorkerCommunicator {
return {
sendTask: overrides.sendTask ?? (async () => undefined as never),
checkStatus:
overrides.checkStatus ??
(async () => "ONLINE" as WorkerStatusValue),
checkHealth:
overrides.checkHealth ??
(async () => ({
isHealthy: true,
metrics: {
cpu: 0,
memory: 0,
tasksFailed: 0,
tasksSucceeded: 0,
responseTime: 0,
},
})),
connect: overrides.connect ?? (async () => undefined),
disconnect: overrides.disconnect ?? (async () => undefined),
ping: overrides.ping ?? (async () => 0),
getProtocol: overrides.getProtocol ?? (() => "http"),
};
}
function makeTask<I, O>(): DistributedTask<I, O> {
let status = "PENDING" as string;
return {
id: "t",
execute: async () => undefined as never,
getStatus: () => status as never,
setStatus: (s) => {
status = s;
},
getMetadata: () => ({ createdAt: new Date(0), attempts: 0 }),
};
}
describe("RemoteWorker", () => {
it("start resolves with ONLINE on first connect", {
timeout: 5000,
}, async () => {
const worker = new RemoteWorker("http://x", fakeCommunicator(), {
healthCheckIntervalMs: 60_000,
});
await worker.start();
expect(worker.isConnected()).toBe(true);
expect(worker.status).toBe("ONLINE");
await worker.stop(true);
});
it("getEndpoint and getCommunicator expose constructor inputs", {
timeout: 5000,
}, async () => {
const comm = fakeCommunicator();
const worker = new RemoteWorker("http://y", comm);
expect(worker.getEndpoint()).toBe("http://y");
expect(worker.getCommunicator()).toBe(comm);
expect(worker.getInfo().capabilities).toEqual({
isRemote: true,
protocol: "http",
endpoint: "http://y",
});
expect(worker.getInfo().type).toBe("remote");
});
it("doExecuteTask throws HTTP.CONNECT_FAILED when not connected", {
timeout: 5000,
}, async () => {
const worker = new RemoteWorker("http://x", fakeCommunicator(), {
healthCheckIntervalMs: 60_000,
});
// Don't start — isConnected stays false. status is OFFLINE so executeTask
// throws WORKER_NOT_AVAILABLE before reaching doExecuteTask. Drive the
// status to ONLINE manually so doExecuteTask is the gating layer.
(worker as unknown as { status: string }).status = "ONLINE";
await expect(worker.executeTask(makeTask(), "in")).rejects.toThrow(
HttpConnectFailedError
);
});
it("communicator throw during sendTask routes through ping for diagnosis", {
timeout: 5000,
}, async () => {
const comm = fakeCommunicator({
sendTask: async () => {
throw new Error("transient");
},
ping: async () => 5, // reachable
});
const worker = new RemoteWorker("http://x", comm, {
healthCheckIntervalMs: 60_000,
});
await worker.start();
await expect(worker.executeTask(makeTask(), "in")).rejects.toThrow(
"transient"
);
// Worker still reachable; not marked UNHEALTHY.
expect(worker.status).toBe("ONLINE");
await worker.stop(true);
});
it("ping failure during sendTask marks UNHEALTHY and disconnects", {
timeout: 5000,
}, async () => {
const comm = fakeCommunicator({
sendTask: async () => {
throw new Error("comm broken");
},
ping: async () => {
throw new Error("unreachable");
},
});
const worker = new RemoteWorker("http://x", comm, {
healthCheckIntervalMs: 60_000,
});
await worker.start();
await expect(worker.executeTask(makeTask(), "in")).rejects.toThrow(
"comm broken"
);
expect(worker.status).toBe("UNHEALTHY");
expect(worker.isConnected()).toBe(false);
});
it("getHealth pass-through from communicator", {
timeout: 5000,
}, async () => {
const remoteHealth: WorkerHealth = {
isHealthy: true,
metrics: {
cpu: 50,
memory: 60,
tasksFailed: 2,
tasksSucceeded: 8,
responseTime: 100,
},
};
const comm = fakeCommunicator({
checkHealth: async () => remoteHealth,
});
const worker = new RemoteWorker("http://x", comm);
expect(await worker.getHealth()).toEqual(remoteHealth);
});
it("getHealth fallback shape when communicator throws and ping fails", {
timeout: 5000,
}, async () => {
const comm = fakeCommunicator({
checkHealth: async () => {
throw new Error("comm broken");
},
ping: async () => {
throw new Error("unreachable");
},
});
const worker = new RemoteWorker("http://x", comm);
const health = await worker.getHealth();
expect(health.isHealthy).toBe(false);
expect(worker.status).toBe("UNHEALTHY");
});
it("exhausts retries and throws HTTP.CONNECT_FAILED", {
timeout: 5000,
}, async () => {
let attempts = 0;
const comm = fakeCommunicator({
connect: async () => {
attempts += 1;
throw new Error("refused");
},
});
const worker = new RemoteWorker("http://x", comm, {
maxConnectionAttempts: 3,
initialBackoffMs: 0,
maxBackoffMs: 0,
healthCheckIntervalMs: 60_000,
});
await expect(worker.start()).rejects.toThrow(HttpConnectFailedError);
expect(attempts).toBe(3);
});
it("retries when checkStatus reports a non-online state", {
timeout: 5000,
}, async () => {
let checks = 0;
const comm = fakeCommunicator({
checkStatus: async () => {
checks += 1;
return checks < 2 ? "OFFLINE" : "ONLINE";
},
});
const worker = new RemoteWorker("http://x", comm, {
maxConnectionAttempts: 3,
initialBackoffMs: 0,
healthCheckIntervalMs: 60_000,
});
await worker.start();
expect(worker.isConnected()).toBe(true);
await worker.stop(true);
});
it("BUSY remote status is also accepted at connect time", {
timeout: 5000,
}, async () => {
const comm = fakeCommunicator({ checkStatus: async () => "BUSY" });
const worker = new RemoteWorker("http://x", comm, {
healthCheckIntervalMs: 60_000,
});
await worker.start();
expect(worker.isConnected()).toBe(true);
await worker.stop(true);
});
it("stop swallows disconnect errors", { timeout: 5000 }, async () => {
const comm = fakeCommunicator({
disconnect: async () => {
throw new Error("nope");
},
});
const worker = new RemoteWorker("http://x", comm, {
healthCheckIntervalMs: 60_000,
});
await worker.start();
await expect(worker.stop(true)).resolves.toBeUndefined();
expect(worker.isConnected()).toBe(false);
});
it("health-check interval is non-recursive and idempotent on stop", {
timeout: 5000,
}, async () => {
const worker = new RemoteWorker("http://x", fakeCommunicator(), {
healthCheckIntervalMs: 60_000,
});
await worker.start();
// Calling stop twice is safe.
await worker.stop(true);
await worker.stop(true);
});
it("periodic health cycle promotes UNHEALTHY when remote returns unhealthy", {
timeout: 5000,
}, async () => {
vi.useFakeTimers();
try {
const comm = fakeCommunicator({
checkHealth: async () => ({
isHealthy: false,
metrics: {
cpu: 0,
memory: 0,
tasksFailed: 0,
tasksSucceeded: 0,
responseTime: 0,
},
}),
ping: async () => 5,
});
const worker = new RemoteWorker("http://x", comm, {
healthCheckIntervalMs: 100,
});
await worker.start();
await vi.advanceTimersByTimeAsync(150);
expect(worker.status).toBe("UNHEALTHY");
await worker.stop(true);
} finally {
vi.useRealTimers();
}
});
it("periodic health cycle recovers UNHEALTHY worker when remote returns healthy", {
timeout: 5000,
}, async () => {
vi.useFakeTimers();
try {
const worker = new RemoteWorker("http://x", fakeCommunicator(), {
healthCheckIntervalMs: 100,
});
await worker.start();
(worker as unknown as { status: string }).status = "UNHEALTHY";
await vi.advanceTimersByTimeAsync(150);
expect(worker.status).toBe("ONLINE");
await worker.stop(true);
} finally {
vi.useRealTimers();
}
});
it("periodic health cycle swallows thrown errors from getHealth", {
timeout: 5000,
}, async () => {
vi.useFakeTimers();
try {
let calls = 0;
const comm = fakeCommunicator({
checkHealth: async () => {
calls += 1;
if (calls > 1) {
throw new Error("boom");
}
return {
isHealthy: true,
metrics: {
cpu: 0,
memory: 0,
tasksFailed: 0,
tasksSucceeded: 0,
responseTime: 0,
},
};
},
ping: async () => 5,
});
const worker = new RemoteWorker("http://x", comm, {
healthCheckIntervalMs: 100,
});
await worker.start();
await vi.advanceTimersByTimeAsync(150);
// Should not throw — the catch in #runHealthCycle swallows it.
await worker.stop(true);
} finally {
vi.useRealTimers();
}
});
it("recovers to BUSY status when activeTasks > 0", {
timeout: 5000,
}, async () => {
vi.useFakeTimers();
try {
const worker = new RemoteWorker("http://x", fakeCommunicator(), {
healthCheckIntervalMs: 100,
});
await worker.start();
(worker as unknown as { status: string }).status = "UNHEALTHY";
(worker as unknown as { activeTasks: number }).activeTasks = 2;
await vi.advanceTimersByTimeAsync(150);
expect(worker.status).toBe("BUSY");
await worker.stop(true);
} finally {
vi.useRealTimers();
}
});
it("start path can be called twice safely (loop is idempotent)", {
timeout: 5000,
}, async () => {
const worker = new RemoteWorker("http://x", fakeCommunicator(), {
healthCheckIntervalMs: 60_000,
});
await worker.start();
await worker.start(); // AbstractWorker.start short-circuits when already ONLINE
await worker.stop(true);
});
});

View File

@@ -0,0 +1,247 @@
import {
AbstractWorker,
type AbstractWorkerDeps,
type AbstractWorkerOptions,
type DistributedTask,
type WorkerHealth,
WorkerStatus,
} from "@suaveplan/queue-distribution";
import { sleep } from "@suaveplan/retry";
import type { WorkerCommunicator } from "../../communicator/base/base.js";
import {
HttpConnectFailedError,
HttpUnreachableError,
} from "../../errors/queue-distribution-http-error.js";
import { recordConnectAttempt } from "../../observability/instruments.js";
export interface RemoteWorkerOptions extends AbstractWorkerOptions {
healthCheckIntervalMs?: number;
maxConnectionAttempts?: number;
initialBackoffMs?: number;
maxBackoffMs?: number;
}
const DEFAULT_HEALTH_INTERVAL_MS = 30_000;
const DEFAULT_MAX_ATTEMPTS = 3;
const DEFAULT_INITIAL_BACKOFF_MS = 1_000;
const DEFAULT_MAX_BACKOFF_MS = 10_000;
const PING_TIMEOUT_MS = 5_000;
/**
* Worker that delegates execution to a remote endpoint via a
* `WorkerCommunicator`. Connect retries use exponential backoff routed
* through `@suaveplan/retry`'s `sleep`. Periodic health checks drive
* UNHEALTHY transitions; a failed ping during normal operation schedules a
* non-blocking reconnect.
*/
export class RemoteWorker extends AbstractWorker {
readonly #endpoint: string;
readonly #communicator: WorkerCommunicator;
readonly #healthCheckIntervalMs: number;
readonly #maxConnectionAttempts: number;
readonly #initialBackoffMs: number;
readonly #maxBackoffMs: number;
#isConnected = false;
#healthCheckHandle: ReturnType<typeof setInterval> | undefined;
constructor(
endpoint: string,
communicator: WorkerCommunicator,
options: RemoteWorkerOptions = {},
deps: AbstractWorkerDeps = {}
) {
super(options, deps);
this.#endpoint = endpoint;
this.#communicator = communicator;
this.#healthCheckIntervalMs =
options.healthCheckIntervalMs ?? DEFAULT_HEALTH_INTERVAL_MS;
this.#maxConnectionAttempts =
options.maxConnectionAttempts ?? DEFAULT_MAX_ATTEMPTS;
this.#initialBackoffMs =
options.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF_MS;
this.#maxBackoffMs = options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
}
getEndpoint(): string {
return this.#endpoint;
}
getCommunicator(): WorkerCommunicator {
return this.#communicator;
}
isConnected(): boolean {
return this.#isConnected;
}
protected async doExecuteTask<I, O>(
task: DistributedTask<I, O>,
input: I
): Promise<O> {
if (!this.#isConnected) {
throw new HttpConnectFailedError({
endpoint: this.#endpoint,
attempts: 0,
});
}
try {
return await this.#communicator.sendTask(
this.#endpoint,
task,
input
);
} catch (error: unknown) {
await this.#handleCommunicationError(error);
throw error;
}
}
protected override async doStart(signal?: AbortSignal): Promise<void> {
await this.#connect(signal);
this.#startHealthLoop();
}
protected override async doStop(
_force: boolean,
signal?: AbortSignal
): Promise<void> {
this.#stopHealthLoop();
try {
await this.#communicator.disconnect(this.#endpoint, signal);
} catch (error: unknown) {
this.logger.warn(`Disconnect from ${this.#endpoint} failed`, {
error: String(error),
});
}
this.#isConnected = false;
}
override async getHealth(signal?: AbortSignal): Promise<WorkerHealth> {
try {
const remoteHealth = await this.#communicator.checkHealth(
this.#endpoint,
signal
);
(this as unknown as { lastHealthCheck: Date }).lastHealthCheck =
new Date((this as unknown as { clock: () => number }).clock());
(
this as unknown as { lastHealthStatus: WorkerHealth }
).lastHealthStatus = remoteHealth;
return remoteHealth;
} catch (error: unknown) {
this.logger.warn(`getHealth failed for ${this.#endpoint}`, {
error: String(error),
});
await this.#handleCommunicationError(error);
return {
isHealthy: false,
metrics: {
cpu: 0,
memory: 0,
tasksFailed: 0,
tasksSucceeded: 0,
responseTime: 0,
},
};
}
}
protected getType(): string {
return "remote";
}
protected override getCapabilities(): Readonly<Record<string, unknown>> {
return {
isRemote: true,
protocol: this.#communicator.getProtocol(),
endpoint: this.#endpoint,
};
}
async #connect(signal?: AbortSignal): Promise<void> {
let lastError: unknown;
for (
let attempt = 1;
attempt <= this.#maxConnectionAttempts;
attempt += 1
) {
try {
await this.#communicator.connect(this.#endpoint, signal);
const status = await this.#communicator.checkStatus(
this.#endpoint,
signal
);
if (
status === WorkerStatus.ONLINE ||
status === WorkerStatus.BUSY
) {
this.#isConnected = true;
recordConnectAttempt("success");
this.logger.info(`Connected to ${this.#endpoint}`);
return;
}
lastError = new Error(`Remote reports status ${status}`);
} catch (error: unknown) {
lastError = error;
recordConnectAttempt("failure");
}
if (attempt < this.#maxConnectionAttempts) {
const delay = Math.min(
this.#initialBackoffMs * 2 ** (attempt - 1),
this.#maxBackoffMs
);
await sleep(delay, signal);
}
}
recordConnectAttempt("exhausted");
throw new HttpConnectFailedError({
endpoint: this.#endpoint,
attempts: this.#maxConnectionAttempts,
cause: lastError,
});
}
#startHealthLoop(): void {
this.#healthCheckHandle = setInterval(() => {
void this.#runHealthCycle();
}, this.#healthCheckIntervalMs);
}
#stopHealthLoop(): void {
clearInterval(this.#healthCheckHandle);
this.#healthCheckHandle = undefined;
}
async #runHealthCycle(): Promise<void> {
const health = await this.getHealth();
if (!health.isHealthy && this.status !== WorkerStatus.UNHEALTHY) {
this.markUnhealthy("Failed health check");
} else if (health.isHealthy && this.status === WorkerStatus.UNHEALTHY) {
this.status =
this.activeTasks > 0 ? WorkerStatus.BUSY : WorkerStatus.ONLINE;
this.logger.info(`Recovered from UNHEALTHY: ${this.#endpoint}`);
}
}
async #handleCommunicationError(error: unknown): Promise<void> {
try {
await this.#communicator.ping(this.#endpoint, PING_TIMEOUT_MS);
this.logger.warn(
`Comm error but ${this.#endpoint} still reachable`,
{
error: String(error),
}
);
} catch (pingError: unknown) {
this.logger.error(`Remote ${this.#endpoint} unreachable`, {
error: String(pingError),
});
this.markUnhealthy("Unreachable");
this.#isConnected = false;
void new HttpUnreachableError({
endpoint: this.#endpoint,
cause: pingError,
});
}
}
}

View File

@@ -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;
}

View File

@@ -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"]
}

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/queue-distribution-http",
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/retry",
"@suaveplan/types",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -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,
},
});