Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,147 @@
# @suaveplan/api-worker-rpc - MessagePort-based RPC protocol for worker-isolated API plugins. Provides Zod-validated wire frames, monotonic correlation ids, AbortSignal forwarding, streaming with backpressure, structured-clone safety guards, SuaveplanError reconstruction across the boundary, and paired client/server bindings. Universal; Stage-3 worker-proxy packages depend on this.
MessagePort-based RPC protocol for worker-isolated API plugins. Provides
the shared protocol — Zod-validated wire frames, monotonic correlation
ids, AbortSignal forwarding, streaming with backpressure, structured-
clone safety guards, SuaveplanError reconstruction across the boundary,
and paired client/server bindings — that every Stage-3 worker-proxy
package builds on.
The package is universal: it depends only on a structural
`PortLike` shape that matches both Node/Bun's `worker_threads`
`MessagePort` and the Web platform's `MessagePort`. No platform
globals are imported at module load.
<!-- begin:auto-docs -->
## Documentation tree
- [abort-forwarding](./src/abort-forwarding/abort-forwarding.md) — Wires caller-side `AbortSignal` instances across the `MessagePort` boundary so an in-flight RPC can be cancelled cooper…
- [abort-forwarding](./src/abort-forwarding/abort-forwarding.md) — Wires caller-side `AbortSignal` instances across the `MessagePort` boundary so an in-flight RPC can be cancelled cooper…
- [backpressure](./src/backpressure/backpressure.md) — Bounds memory growth on a busy RPC channel by exposing two primitives: the logical `CallBudget` for outstanding in-flig…
- [backpressure](./src/backpressure/backpressure.md) — Bounds memory growth on a busy RPC channel by exposing two primitives: the logical `CallBudget` for outstanding in-flig…
- [client](./src/client/client.md) — Caller-side binding for the worker-RPC protocol. `createRawWorkerRpcClient` returns a low-level object exposing `.call(…
- [client](./src/client/client.md) — Caller-side binding for the worker-RPC protocol. `createRawWorkerRpcClient` returns a low-level object exposing `.call(…
- [cloneable-guard](./src/client/cloneable-guard.md) — Walks a value (typically an RPC argument list or return value) and throws `WORKER_RPC.NOT_CLONEABLE` at the call site i…
- [correlation](./src/correlation/correlation.md) — Provides the monotonic correlation-id generator and the in-memory pending-call registry that backs every outstanding RP…
- [correlation](./src/correlation/correlation.md) — Provides the monotonic correlation-id generator and the in-memory pending-call registry that backs every outstanding RP…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `WORKER_RPC.*` code registry and the `WorkerRpcError` class. Every public failure path in `@suavepla…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `WORKER_RPC.*` code registry and the `WorkerRpcError` class. Every public failure path in `@suavepla…
- [error-reconstruction](./src/error-reconstruction/error-reconstruction.md) — Translates thrown values into wire-safe `ErrorEnvelope`s and back into typed `Error` instances on the receiving side. `…
- [error-reconstruction](./src/error-reconstruction/error-reconstruction.md) — Translates thrown values into wire-safe `ErrorEnvelope`s and back into typed `Error` instances on the receiving side. `…
- [protocol](./src/protocol/protocol.md) — Defines the wire-format of every message that crosses a `MessagePort` between an `api-host` and an isolated/sandboxed p…
- [protocol](./src/protocol/protocol.md) — Defines the wire-format of every message that crosses a `MessagePort` between an `api-host` and an isolated/sandboxed p…
- [server](./src/server/server.md) — Worker-side binding for the RPC protocol. `serveWorkerRpc(port, handlers)` mounts a handlers object on a `MessagePort`,…
- [server](./src/server/server.md) — Worker-side binding for the RPC protocol. `serveWorkerRpc(port, handlers)` mounts a handlers object on a `MessagePort`,…
- [streaming](./src/streaming/streaming.md) — Caller-side primitives for consuming streaming RPC methods. The server publishes a `stream-chunk` frame per yielded val…
- [streaming](./src/streaming/streaming.md) — Caller-side primitives for consuming streaming RPC methods. The server publishes a `stream-chunk` frame per yielded val…
- [testing](./src/testing/testing.md) — Reusable conformance harness and an in-process paired-port factory used by every Stage-3 worker-proxy package's test su…
- [conformance](./src/testing/conformance.md) — Builds a paired client/server harness over an in-process `MessageChannel` for conformance tests. The harness is intende…
- [testing](./src/testing/testing.md) — Reusable conformance harness and an in-process paired-port factory used by every Stage-3 worker-proxy package's test su…
<!-- end:auto-docs -->
## Why
Per the api-plugin system design (§2.2 isolation tiers, §5.2a
worker boundary), every capability adapter ships in two surfaces: a
direct in-process binding for `trusted` plugins and a `MessagePort`-
marshalled binding for `isolated` / `sandboxed` plugins. Hand-rolling
that marshalling once per capability is how plugin frameworks accrete
subtly-different RPC layers that diverge on cancellation, streaming,
and error reconstruction. `api-worker-rpc` is the single shared
protocol every worker-proxy package builds on; getting it right once
means every capability — `db`, `events`, `secrets`, `jobs`, `logger`,
future ones — gets cancellation, backpressure, streaming, and error
reconstruction for free.
## Install
```bash
bun add @suaveplan/api-worker-rpc
```
## Quick start
```ts
import { createWorkerRpcClient } from "@suaveplan/api-worker-rpc";
import { serveWorkerRpc } from "@suaveplan/api-worker-rpc";
import { createInProcessPortPair } from "@suaveplan/api-worker-rpc/testing";
interface Calc { add(a: number, b: number): number; }
const { clientPort, serverPort, close } = createInProcessPortPair();
const server = serveWorkerRpc(serverPort, {
add: ([a, b]) => (a as number) + (b as number),
});
const calc = createWorkerRpcClient<Calc>(clientPort);
console.log(await calc.add(2, 3)); // 5
calc.close();
server.close();
close();
```
## Modules
- [`protocol`](./src/protocol/protocol.md) — Zod-validated wire frames
(`request`, `response`, `cancel`, `progress`, `stream-open`,
`stream-chunk`, `stream-end`, `stream-ack`, `hello`, `goodbye`)
plus `parseFrame` and the `ErrorEnvelope` type.
- [`correlation`](./src/correlation/correlation.md) — monotonic id
generator + `PendingRegistry` (orphan detection, reuse detection,
timeout eviction).
- [`client`](./src/client/client.md) — `createWorkerRpcClient<T>` typed
proxy and `createRawWorkerRpcClient` low-level binding.
- [`client/cloneable-guard`](./src/client/cloneable-guard.md) —
pre-send structured-clone safety walk.
- [`server`](./src/server/server.md) — `serveWorkerRpc(port,
handlers)` mounts handler maps with automatic streaming and
cancel-via-`AbortController`.
- [`streaming`](./src/streaming/streaming.md) — caller-side
`streamRequest` returning an `AsyncIterable` + `StreamDispatcher`
for inbound-frame routing.
- [`abort-forwarding`](./src/abort-forwarding/abort-forwarding.md) —
`attachAbortSignal`, `createRemoteAbort`, and the platform-independent
`AbortError` class.
- [`backpressure`](./src/backpressure/backpressure.md) —
`CallBudget` (logical) and `BoundedQueue` (physical) with three
policies.
- [`error-reconstruction`](./src/error-reconstruction/error-reconstruction.md)
— `errorToEnvelope` / `envelopeToError` with depth-bounded cause
chains.
- [`error-codes`](./src/error-codes/error-codes.md) — the
`WORKER_RPC_CODES` registry and `WorkerRpcError` class.
- [`testing`](./src/testing/testing.md) — `createInProcessPortPair`
+ `runWorkerRpcConformance` harness.
## Error codes
Every code in `WORKER_RPC_CODES` is reachable from at least one
production path:
| Code | Raised from |
| --------------------------------- | ------------------------------------------- |
| `WORKER_RPC.PORT_CLOSED` | `client.close()` reject; post-close send |
| `WORKER_RPC.REQUEST_TIMEOUT` | per-call timeout eviction |
| `WORKER_RPC.RESPONSE_TIMEOUT` | (reserved for response-only timeout policy) |
| `WORKER_RPC.INVALID_FRAME` | malformed inbound frame |
| `WORKER_RPC.UNKNOWN_METHOD` | server dispatch of missing handler |
| `WORKER_RPC.CORRELATION_ID_REUSED`| double-register on same id |
| `WORKER_RPC.STREAM_BACKPRESSURE_EXCEEDED` | reserved for stream-explicit overflow |
| `WORKER_RPC.HANDLER_THREW` | server-side stream handler not iterable |
| `WORKER_RPC.CANCELLED` | registry cancel |
| `WORKER_RPC.VERSION_MISMATCH` | inbound `v !== PROTOCOL_VERSION` |
| `WORKER_RPC.ORPHAN_RESPONSE` | `settle()` on unknown id |
| `WORKER_RPC.NOT_CLONEABLE` | `assertCloneable` pre-send walk |
| `WORKER_RPC.REMOTE_THROW` | wrapping foreign throws on the wire |
| `WORKER_RPC.BACKPRESSURE` | `CallBudget` or fail-mode `BoundedQueue` |
## Verification
- 100% test coverage gated in CI (`bunx turbo test:coverage --filter=@suaveplan/api-worker-rpc`).
- Full lint+typecheck+test+build gate per package rules.
## License
MIT

View File

@@ -0,0 +1,122 @@
{
"name": "@suaveplan/api-worker-rpc",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "MessagePort-based RPC protocol for worker-isolated API plugins. Provides Zod-validated wire frames, monotonic correlation ids, AbortSignal forwarding, streaming with backpressure, structured-clone safety guards, SuaveplanError reconstruction across the boundary, and paired client/server bindings. Universal; Stage-3 worker-proxy packages depend on this.",
"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"
}
},
"./protocol": {
"import": {
"types": "./dist/protocol/protocol.d.ts",
"default": "./dist/protocol/protocol.js"
}
},
"./correlation": {
"import": {
"types": "./dist/correlation/correlation.d.ts",
"default": "./dist/correlation/correlation.js"
}
},
"./client": {
"import": {
"types": "./dist/client/client.d.ts",
"default": "./dist/client/client.js"
}
},
"./server": {
"import": {
"types": "./dist/server/server.d.ts",
"default": "./dist/server/server.js"
}
},
"./streaming": {
"import": {
"types": "./dist/streaming/streaming.d.ts",
"default": "./dist/streaming/streaming.js"
}
},
"./abort-forwarding": {
"import": {
"types": "./dist/abort-forwarding/abort-forwarding.d.ts",
"default": "./dist/abort-forwarding/abort-forwarding.js"
}
},
"./error-reconstruction": {
"import": {
"types": "./dist/error-reconstruction/error-reconstruction.d.ts",
"default": "./dist/error-reconstruction/error-reconstruction.js"
}
},
"./backpressure": {
"import": {
"types": "./dist/backpressure/backpressure.d.ts",
"default": "./dist/backpressure/backpressure.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./testing": {
"import": {
"types": "./dist/testing/testing.d.ts",
"default": "./dist/testing/testing.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/api-types": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "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,81 @@
# `abort-forwarding`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Wires caller-side `AbortSignal` instances across the `MessagePort`
boundary so an in-flight RPC can be cancelled cooperatively. The
caller passes its `AbortController.signal` to `client.call(...)`; this
module attaches a listener that, when the signal fires, sends a `cancel`
frame to the worker and rejects the local promise with an `AbortError`.
On the worker side, every handler receives a fresh `AbortSignal` whose
`abort` event fires when the cancel frame arrives. That gives user code
a uniform local-only API regardless of whether the plugin runs trusted
(direct call) or isolated (proxy call).
## Features
- `attachAbortSignal(signal, onAbort)` — one-shot listener with
synchronous fire when the signal is already aborted, idempotent
`dispose()` to detach, and aware-tracking via `aborted()`.
- `createRemoteAbort()` — worker-side controller exposing
`{ signal, trigger(reason?) }`; `trigger` is idempotent.
- `AbortError` class with `name === "AbortError"` matching the DOM
`DOMException` shape (so `err.name === "AbortError"` is true on
every platform, including Node where DOMException is sometimes
absent).
## Usage: Basic
```ts
import { attachAbortSignal } from "@suaveplan/api-worker-rpc/abort-forwarding";
const link = attachAbortSignal(signal, () => {
port.postMessage({ kind: "cancel", id });
});
// ... on settle:
link.dispose();
```
## Usage: Advanced
```ts
import {
AbortError,
createRemoteAbort,
} from "@suaveplan/api-worker-rpc/abort-forwarding";
// Inside the worker server's request dispatcher:
const remote = createRemoteAbort();
inflight.set(id, { abort: () => remote.trigger() });
try {
const result = await handler(params, remote.signal);
// ...
} catch (err) {
if (err instanceof AbortError) {
// handler honoured the signal
}
}
```
## API Reference
- `attachAbortSignal(signal: AbortSignal | undefined, onAbort: () => void): AbortLink`
— returns `{ dispose, aborted }`.
- `interface AbortLink { dispose(): void; aborted(): boolean; }`
- `createRemoteAbort(): RemoteAbort` — `{ signal, trigger }`.
- `class AbortError extends Error` — `name === "AbortError"`.
## Implementation Notes
- The internal listener is `{ once: true }` so a second `abort` event
cannot fire `onAbort` twice. `dispose()` is also safe to call after
fire because the listener has already detached itself.
- `attachAbortSignal(undefined, ...)` returns an inert link — callers
pass `undefined` when no signal was supplied.
- `createRemoteAbort` guards `trigger` against double-fire so a
cancel frame arriving twice (network duplicate, etc.) is a no-op.

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AbortError,
attachAbortSignal,
createRemoteAbort,
} from "./abort-forwarding.js";
describe("abort-forwarding", () => {
it("attach with undefined is inert", { timeout: 5000 }, () => {
let fired = 0;
const link = attachAbortSignal(undefined, () => fired++);
expect(link.aborted()).toBe(false);
link.dispose();
expect(fired).toBe(0);
});
it("attach to already-aborted signal fires synchronously", {
timeout: 5000,
}, () => {
const ac = new AbortController();
ac.abort();
let fired = 0;
const link = attachAbortSignal(ac.signal, () => fired++);
expect(fired).toBe(1);
expect(link.aborted()).toBe(true);
link.dispose();
});
it("attach fires once on abort", { timeout: 5000 }, () => {
const ac = new AbortController();
let fired = 0;
const link = attachAbortSignal(ac.signal, () => fired++);
ac.abort();
expect(fired).toBe(1);
link.dispose();
});
it("dispose before abort removes listener", { timeout: 5000 }, () => {
const ac = new AbortController();
let fired = 0;
const link = attachAbortSignal(ac.signal, () => fired++);
link.dispose();
ac.abort();
expect(fired).toBe(0);
});
it("RemoteAbort triggers signal once", { timeout: 5000 }, () => {
const r = createRemoteAbort();
let count = 0;
r.signal.addEventListener("abort", () => count++);
r.trigger("x");
r.trigger("y");
expect(r.signal.aborted).toBe(true);
expect(count).toBe(1);
});
it("aborted() returns signal state", { timeout: 5000 }, () => {
const ac = new AbortController();
const link = attachAbortSignal(ac.signal, () => {});
expect(link.aborted()).toBe(false);
ac.abort();
expect(link.aborted()).toBe(true);
});
it("AbortError name + message", { timeout: 5000 }, () => {
const err = new AbortError();
expect(err.name).toBe("AbortError");
expect(err.message).toContain("aborted");
const err2 = new AbortError("custom");
expect(err2.message).toBe("custom");
});
});

View File

@@ -0,0 +1,75 @@
/**
* Linkage between a caller-side `AbortSignal` and a wire-side cancel
* action. The returned dispose function detaches the listener so the
* signal does not hold the registry alive past settlement.
*/
export interface AbortLink {
readonly dispose: () => void;
readonly aborted: () => boolean;
}
/**
* Attach an external `AbortSignal` to a per-call cancel action. If the
* signal is already aborted, `onAbort` fires synchronously. Otherwise a
* one-shot listener is registered and removed on dispose (or first fire).
*
* Returns a stable {@link AbortLink}; callers store it on their pending
* record and call `.dispose()` on settle.
*/
export function attachAbortSignal(
signal: AbortSignal | undefined,
onAbort: () => void
): AbortLink {
if (signal === undefined) {
return { dispose: () => {}, aborted: () => false };
}
if (signal.aborted) {
onAbort();
return { dispose: () => {}, aborted: () => true };
}
const listener = (): void => {
cleanup();
onAbort();
};
const cleanup = (): void => {
signal.removeEventListener("abort", listener);
};
signal.addEventListener("abort", listener, { once: true });
return {
dispose: cleanup,
aborted: () => signal.aborted,
};
}
/**
* Build a fresh `AbortController` on the server side that can be triggered
* remotely via {@link RemoteAbort.trigger}. The handler implementation
* receives `controller.signal` so user code can `if (signal.aborted)` or
* `signal.addEventListener("abort", ...)` just like local code.
*/
export interface RemoteAbort {
readonly signal: AbortSignal;
readonly trigger: (reason?: unknown) => void;
}
export function createRemoteAbort(): RemoteAbort {
const controller = new AbortController();
return {
signal: controller.signal,
trigger: (reason?: unknown) => {
if (controller.signal.aborted) return;
controller.abort(reason);
},
};
}
/**
* Standard `AbortError` constructor — matches the DOMException shape
* (`name === "AbortError"`) without depending on browser globals.
*/
export class AbortError extends Error {
public override readonly name = "AbortError";
public constructor(message = "The operation was aborted.") {
super(message);
}
}

View File

@@ -0,0 +1,7 @@
export {
AbortError,
type AbortLink,
attachAbortSignal,
createRemoteAbort,
type RemoteAbort,
} from "./abort-forwarding.js";

View File

@@ -0,0 +1,82 @@
# `backpressure`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Bounds memory growth on a busy RPC channel by exposing two primitives:
the logical `CallBudget` for outstanding in-flight calls, and the
physical `BoundedQueue` for frames-in-transit and stream-chunk buffers.
Both reject overflow rather than allocating without bound — the failure
mode is a synchronous `WORKER_RPC.BACKPRESSURE` throw at the call site,
not an OOM kill on the worker thread.
The two layers are deliberately distinct: a call budget counts logical
requests (decremented on response receipt regardless of frame count),
while the bounded queue counts wire frames (decremented on dequeue).
Decoupling them lets streaming workloads — where one call yields many
frames — back-pressure on chunk delivery without blocking new calls.
## Features
- `CallBudget(max)` with `acquire()` / `release()` semantics and a
synchronous-throw exhaustion path.
- `BoundedQueue(policy)` with three modes:
- `"fail"` — throw `WORKER_RPC.BACKPRESSURE` on overflow.
- `"drop-oldest"` — evict head, push new (head-drop).
- `"drop-newest"` — refuse new, leaving the queue untouched.
- `DEFAULT_BACKPRESSURE` — high-water-mark 64, mode `"fail"`.
- Validation: both classes reject non-positive caps with
`WORKER_RPC.BACKPRESSURE`.
## Usage: Basic
```ts
import { CallBudget, DEFAULT_BACKPRESSURE, BoundedQueue } from "@suaveplan/api-worker-rpc/backpressure";
const budget = new CallBudget(1024);
budget.acquire(); // throws WORKER_RPC.BACKPRESSURE when full
budget.release();
```
## Usage: Advanced
```ts
import { BoundedQueue, type BackpressurePolicy } from "@suaveplan/api-worker-rpc/backpressure";
const lossy: BackpressurePolicy = { highWaterMark: 8, mode: "drop-oldest" };
const queue = new BoundedQueue<Frame>(lossy);
queue.enqueue(frame); // never throws; head evicted on overflow
const strict: BackpressurePolicy = { highWaterMark: 1024, mode: "fail" };
const strictQueue = new BoundedQueue<Frame>(strict);
try {
strictQueue.enqueue(frame);
} catch (e) {
if (e instanceof WorkerRpcError && e.code === "WORKER_RPC.BACKPRESSURE") {
// back off
}
}
```
## API Reference
- `interface BackpressurePolicy { highWaterMark: number; mode: BackpressureMode }`.
- `type BackpressureMode = "fail" | "drop-oldest" | "drop-newest"`.
- `DEFAULT_BACKPRESSURE: BackpressurePolicy`.
- `class CallBudget(max)` — `acquire()`, `release()`, `inFlight`, `capacity`.
- `class BoundedQueue<T>(policy?)` — `enqueue(v): boolean`,
`dequeue(): T|undefined`, `peek()`, `drain()`, `size`.
## Implementation Notes
- `release()` is tolerant of extra calls (never goes negative) so
late-arriving responses or duplicate acks cannot underflow the
budget.
- `drop-oldest` mutates in-place rather than allocating a new array
so the buffer never grows beyond `highWaterMark`.
- `drop-newest` returns `false` from `enqueue` so callers can
distinguish accepted from refused; `drop-oldest` returns `true`
unconditionally (the eviction is implicit).

View File

@@ -0,0 +1,109 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WorkerRpcError } from "../error-codes/error-codes.js";
import {
BoundedQueue,
CallBudget,
DEFAULT_BACKPRESSURE,
} from "./backpressure.js";
describe("backpressure", () => {
it("default policy exposes highWaterMark + fail mode", {
timeout: 5000,
}, () => {
expect(DEFAULT_BACKPRESSURE.highWaterMark).toBe(64);
expect(DEFAULT_BACKPRESSURE.mode).toBe("fail");
});
it("BoundedQueue default policy uses DEFAULT_BACKPRESSURE", {
timeout: 5000,
}, () => {
const q = new BoundedQueue<number>();
for (let i = 0; i < 64; i++) q.enqueue(i);
expect(q.size).toBe(64);
expect(() => q.enqueue(99)).toThrow(WorkerRpcError);
});
it("BoundedQueue fail mode throws on overflow", { timeout: 5000 }, () => {
const q = new BoundedQueue<number>({ highWaterMark: 2, mode: "fail" });
expect(q.enqueue(1)).toBe(true);
expect(q.enqueue(2)).toBe(true);
expect(() => q.enqueue(3)).toThrow(WorkerRpcError);
expect(q.size).toBe(2);
});
it("BoundedQueue drop-oldest evicts head", { timeout: 5000 }, () => {
const q = new BoundedQueue<number>({
highWaterMark: 2,
mode: "drop-oldest",
});
q.enqueue(1);
q.enqueue(2);
q.enqueue(3);
expect(q.dequeue()).toBe(2);
expect(q.dequeue()).toBe(3);
});
it("BoundedQueue drop-newest discards incoming", { timeout: 5000 }, () => {
const q = new BoundedQueue<number>({
highWaterMark: 2,
mode: "drop-newest",
});
q.enqueue(1);
q.enqueue(2);
expect(q.enqueue(3)).toBe(false);
expect(q.dequeue()).toBe(1);
expect(q.dequeue()).toBe(2);
});
it("BoundedQueue peek + drain", { timeout: 5000 }, () => {
const q = new BoundedQueue<number>({ highWaterMark: 3, mode: "fail" });
q.enqueue(10);
q.enqueue(20);
expect(q.peek()).toBe(10);
const all = q.drain();
expect(all).toEqual([10, 20]);
expect(q.size).toBe(0);
expect(q.peek()).toBeUndefined();
expect(q.dequeue()).toBeUndefined();
});
it("BoundedQueue rejects non-positive highWaterMark", {
timeout: 5000,
}, () => {
expect(
() => new BoundedQueue<number>({ highWaterMark: 0, mode: "fail" })
).toThrow(WorkerRpcError);
});
it("CallBudget acquire + release", { timeout: 5000 }, () => {
const b = new CallBudget(2);
expect(b.capacity).toBe(2);
b.acquire();
b.acquire();
expect(b.inFlight).toBe(2);
expect(() => b.acquire()).toThrow(WorkerRpcError);
b.release();
expect(b.inFlight).toBe(1);
b.release();
b.release(); // tolerant of extra release
expect(b.inFlight).toBe(0);
});
it("CallBudget rejects non-positive max", { timeout: 5000 }, () => {
expect(() => new CallBudget(0)).toThrow(WorkerRpcError);
});
it("CallBudget budget exceeded throws synchronously", {
timeout: 5000,
}, () => {
const b = new CallBudget(1024);
for (let i = 0; i < 1024; i++) b.acquire();
let thrown: unknown;
try {
b.acquire();
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(WorkerRpcError);
});
});

View File

@@ -0,0 +1,127 @@
import { WorkerRpcError } from "../error-codes/error-codes.js";
/**
* Drop or fail policy when the bounded queue is full. Controls how the
* caller-side outbound queue and the inbound stream chunk buffer behave
* when their high-water mark is exceeded.
*/
export type BackpressureMode = "fail" | "drop-oldest" | "drop-newest";
export interface BackpressurePolicy {
readonly highWaterMark: number;
readonly mode: BackpressureMode;
}
/** Default policy: 64 outstanding chunks, fail-fast on exceed. */
export const DEFAULT_BACKPRESSURE: BackpressurePolicy = {
highWaterMark: 64,
mode: "fail",
};
/**
* Bounded FIFO whose `enqueue` honours a {@link BackpressurePolicy}. Used
* by both the outbound queue (request frames) and the inbound stream chunk
* buffer; centralising the eviction logic means the three modes are tested
* in one place.
*/
export class BoundedQueue<T> {
private readonly buffer: T[] = [];
private readonly policy: BackpressurePolicy;
public constructor(policy: BackpressurePolicy = DEFAULT_BACKPRESSURE) {
if (policy.highWaterMark <= 0) {
throw new WorkerRpcError(
"WORKER_RPC.BACKPRESSURE",
`highWaterMark must be positive; got ${policy.highWaterMark}`
);
}
this.policy = policy;
}
public get size(): number {
return this.buffer.length;
}
/**
* Enqueue a value. Returns `true` when accepted, `false` when dropped
* under a drop policy. In `fail` mode throws `WORKER_RPC.BACKPRESSURE`
* synchronously when full.
*/
public enqueue(value: T): boolean {
if (this.buffer.length < this.policy.highWaterMark) {
this.buffer.push(value);
return true;
}
if (this.policy.mode === "fail") {
throw new WorkerRpcError(
"WORKER_RPC.BACKPRESSURE",
`queue full (highWaterMark=${this.policy.highWaterMark})`
);
}
if (this.policy.mode === "drop-oldest") {
this.buffer.shift();
this.buffer.push(value);
return true;
}
// drop-newest
return false;
}
public dequeue(): T | undefined {
return this.buffer.shift();
}
public peek(): T | undefined {
return this.buffer[0];
}
public drain(): T[] {
const out = this.buffer.slice();
this.buffer.length = 0;
return out;
}
}
/**
* Outbound-budget counter for in-flight calls. Distinct from
* {@link BoundedQueue} because it tracks logical request slots (not
* physical frames) and is decremented on response receipt rather than
* dequeue.
*/
export class CallBudget {
private readonly max: number;
private current = 0;
public constructor(max: number) {
if (max <= 0) {
throw new WorkerRpcError(
"WORKER_RPC.BACKPRESSURE",
`budget must be positive; got ${max}`
);
}
this.max = max;
}
public get inFlight(): number {
return this.current;
}
public get capacity(): number {
return this.max - this.current;
}
/** Reserve a slot; throws synchronously when exhausted. */
public acquire(): void {
if (this.current >= this.max) {
throw new WorkerRpcError(
"WORKER_RPC.BACKPRESSURE",
`call budget exhausted (max=${this.max})`
);
}
this.current++;
}
public release(): void {
if (this.current > 0) this.current--;
}
}

View File

@@ -0,0 +1,7 @@
export {
type BackpressureMode,
type BackpressurePolicy,
BoundedQueue,
CallBudget,
DEFAULT_BACKPRESSURE,
} from "./backpressure.js";

View File

@@ -0,0 +1,99 @@
# `client`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Caller-side binding for the worker-RPC protocol. `createRawWorkerRpcClient`
returns a low-level object exposing `.call(method, params, opts)` which
returns a `Promise<R>`; `createWorkerRpcClient<T>` wraps that in a typed
`Proxy<T>` so plugin authors invoke remote methods as if they were local
async methods on a typed handle.
The client handles every cross-cutting concern: monotonic correlation-id
allocation, request/response matching, optional `AbortSignal` forwarding
via cancel frames, optional per-call timeout, budget-enforced backpressure,
outbound queue management, structured-clone safety pre-checks, and
inbound frame validation with version negotiation.
## Features
- Typed `Proxy<T>` so `await client.method(arg)` works without a
generated stub.
- `AbortSignal` per call; aborting sends a `cancel` frame and rejects
the pending promise with an `AbortError`.
- Per-call timeout via `opts.timeoutMs` or `defaultTimeoutMs`.
- `callBudget` cap (default `1024`) — synchronous-throw exhaustion
with `WORKER_RPC.BACKPRESSURE`.
- Pre-send `assertCloneable` walk rejecting functions, class
instances, and symbols at the call site instead of letting the
platform throw a `DataCloneError` later.
- Inbound frames validated via the Zod protocol schema; invalid
payloads and version mismatches are surfaced through the
`onProtocolError` observer (never crash the listener).
- `onStreamFrame` hook to multiplex stream frames into the streaming
layer.
## Usage: Basic
```ts
import { createWorkerRpcClient } from "@suaveplan/api-worker-rpc/client";
interface Calc {
add(a: number, b: number): number;
}
const calc = createWorkerRpcClient<Calc>(workerPort);
const sum = await calc.add(2, 3);
calc.close();
```
## Usage: Advanced
```ts
import { createRawWorkerRpcClient } from "@suaveplan/api-worker-rpc/client";
const client = createRawWorkerRpcClient(port, {
callBudget: 256,
defaultTimeoutMs: 5_000,
onProtocolError: (err) => logger.warn({ err }, "rpc protocol error"),
});
const ac = new AbortController();
setTimeout(() => ac.abort(), 200);
try {
const result = await client.call("longRunning", [42], { signal: ac.signal });
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
// caller cancelled
}
}
client.close("shutdown");
```
## API Reference
- `createRawWorkerRpcClient(port, opts?): WorkerRpcClient` — low-level
binding.
- `createWorkerRpcClient<T>(port, opts?): WorkerProxyClient<T>` — typed
proxy whose `__raw__` field exposes the underlying client and whose
`close(reason?)` tears it down.
- `interface WorkerRpcClient { call, send, cancel, nextId, registry, inFlight, close }`.
- `interface WorkerRpcClientOptions { idGenerator?, callBudget?, outboundQueue?, defaultTimeoutMs?, onStreamFrame?, onProtocolError? }`.
- `assertCloneable(value, path?): void` — re-exported from
`./cloneable-guard`.
## Implementation Notes
- The proxy filters reserved keys (`__raw__`, `close`) so they
do NOT become remote methods.
- `call` acquires a budget slot synchronously before allocating
the id so exhaustion fails at the call site, not later in the
microtask queue.
- The inbound listener catches `parseFrame` errors and routes
them to `onProtocolError` rather than throwing — a malformed
frame should never crash the host loop.
- Cancel frames sent during abort do not require an ack from the
server (best-effort cooperative cancel).

View File

@@ -0,0 +1,406 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { WorkerRpcError } from "../error-codes/error-codes.js";
import { PROTOCOL_VERSION } from "../protocol/protocol.js";
import { serveWorkerRpc } from "../server/server.js";
import { createInProcessPortPair } from "../testing/testing.js";
import { createRawWorkerRpcClient, createWorkerRpcClient } from "./client.js";
interface Calc {
add(a: number, b: number): number;
boom(): never;
slow(ms: number): string;
}
function makePair() {
const ports = createInProcessPortPair();
const server = serveWorkerRpc(ports.serverPort, {
add: (p) => (p[0] as number) + (p[1] as number),
boom: () => {
throw new TypeError("explode");
},
slow: async (p, signal) => {
await new Promise<void>((resolve, reject) => {
const t = setTimeout(resolve, p[0] as number);
signal.addEventListener("abort", () => {
clearTimeout(t);
reject(new Error("aborted"));
});
});
return "done";
},
echo: (p) => p[0],
});
return { ports, server };
}
describe("client", () => {
it("typed proxy: add resolves with sum", { timeout: 5000 }, async () => {
const { ports, server } = makePair();
const c = createWorkerRpcClient<Calc>(ports.clientPort);
const out = await c.add(2, 3);
expect(out).toBe(5);
c.close();
server.close();
ports.close();
});
it("raw client returns a promise per call", { timeout: 5000 }, async () => {
const { ports, server } = makePair();
const c = createRawWorkerRpcClient(ports.clientPort);
await expect(c.call("add", [10, 5])).resolves.toBe(15);
c.close();
server.close();
ports.close();
});
it("rejects on handler throw with REMOTE_THROW", {
timeout: 5000,
}, async () => {
const { ports, server } = makePair();
const c = createRawWorkerRpcClient(ports.clientPort);
await expect(c.call("boom", [])).rejects.toMatchObject({
code: "WORKER_RPC.REMOTE_THROW",
});
c.close();
server.close();
ports.close();
});
it("unknown method rejects with UNKNOWN_METHOD", {
timeout: 5000,
}, async () => {
const { ports, server } = makePair();
const c = createRawWorkerRpcClient(ports.clientPort);
await expect(c.call("nope", [])).rejects.toMatchObject({
code: "WORKER_RPC.UNKNOWN_METHOD",
});
c.close();
server.close();
ports.close();
});
it("abort during in-flight call rejects with AbortError", {
timeout: 5000,
}, async () => {
const { ports, server } = makePair();
const c = createRawWorkerRpcClient(ports.clientPort);
const ac = new AbortController();
const p = c.call("slow", [200], { signal: ac.signal });
ac.abort();
await expect(p).rejects.toMatchObject({ name: "AbortError" });
c.close();
server.close();
ports.close();
});
it("close rejects pending with PORT_CLOSED", {
timeout: 5000,
}, async () => {
const { ports, server } = makePair();
const c = createRawWorkerRpcClient(ports.clientPort);
const p = c.call("slow", [500]);
c.close("test");
await expect(p).rejects.toMatchObject({
code: "WORKER_RPC.PORT_CLOSED",
});
server.close();
ports.close();
});
it("post-close call throws PORT_CLOSED", { timeout: 5000 }, () => {
const ports = createInProcessPortPair();
const c = createRawWorkerRpcClient(ports.clientPort);
c.close();
let captured: WorkerRpcError | undefined;
try {
c.send({
v: PROTOCOL_VERSION,
kind: "cancel",
id: 1,
});
} catch (e) {
captured = e as WorkerRpcError;
}
expect(captured?.code).toBe("WORKER_RPC.PORT_CLOSED");
ports.close();
});
it("abort after settle is no-op (no double-cancel)", {
timeout: 5000,
}, async () => {
const { ports, server } = makePair();
const c = createRawWorkerRpcClient(ports.clientPort);
const ac = new AbortController();
const result = await c.call("add", [1, 2], { signal: ac.signal });
expect(result).toBe(3);
ac.abort(); // post-settle abort
c.close();
server.close();
ports.close();
});
it("call respects timeoutMs option", { timeout: 5000 }, async () => {
const { ports, server } = makePair();
const c = createRawWorkerRpcClient(ports.clientPort);
await expect(
c.call("slow", [200], { timeoutMs: 30 })
).rejects.toMatchObject({ code: "WORKER_RPC.REQUEST_TIMEOUT" });
c.close();
server.close();
ports.close();
});
it("non-cloneable arg throws synchronously", { timeout: 5000 }, () => {
const { ports, server } = makePair();
const c = createRawWorkerRpcClient(ports.clientPort);
let captured: WorkerRpcError | undefined;
try {
c.call("echo", [() => {}]);
} catch (e) {
captured = e as WorkerRpcError;
}
expect(captured?.code).toBe("WORKER_RPC.NOT_CLONEABLE");
c.close();
server.close();
ports.close();
});
it("budget exhausted throws BACKPRESSURE", { timeout: 5000 }, async () => {
const ports = createInProcessPortPair();
// no server attached so calls hang; budget = 1
const c = createRawWorkerRpcClient(ports.clientPort, {
callBudget: 1,
});
c.call("x", []).catch(() => {});
let captured: WorkerRpcError | undefined;
try {
c.call("y", []);
} catch (e) {
captured = e as WorkerRpcError;
}
expect(captured?.code).toBe("WORKER_RPC.BACKPRESSURE");
c.close();
ports.close();
});
it("nextId + send exposed to streaming layer", { timeout: 5000 }, () => {
const ports = createInProcessPortPair();
const c = createRawWorkerRpcClient(ports.clientPort);
const id1 = c.nextId();
const id2 = c.nextId();
expect(id2).toBe(id1 + 1);
c.close();
ports.close();
});
it("registry is exposed", { timeout: 5000 }, () => {
const ports = createInProcessPortPair();
const c = createRawWorkerRpcClient(ports.clientPort);
expect(c.registry.size).toBe(0);
c.close();
ports.close();
});
it("inFlight tracks budget usage", { timeout: 5000 }, async () => {
const { ports, server } = makePair();
const c = createRawWorkerRpcClient(ports.clientPort);
expect(c.inFlight()).toBe(0);
const p = c.call("slow", [50]);
expect(c.inFlight()).toBe(1);
await p;
expect(c.inFlight()).toBe(0);
c.close();
server.close();
ports.close();
});
it("cancel() sends cancel frame for in-flight call", {
timeout: 5000,
}, async () => {
const { ports, server } = makePair();
const c = createRawWorkerRpcClient(ports.clientPort);
const id = c.nextId();
const p = new Promise<unknown>((resolve, reject) => {
c.registry.register({ id, resolve, reject });
});
c.send({
v: PROTOCOL_VERSION,
kind: "request",
id,
method: "slow",
params: [200],
});
c.cancel(id);
await expect(p).rejects.toMatchObject({ code: "WORKER_RPC.CANCELLED" });
c.close();
server.close();
ports.close();
});
it("cancel() of unknown id is no-op", { timeout: 5000 }, () => {
const ports = createInProcessPortPair();
const c = createRawWorkerRpcClient(ports.clientPort);
c.cancel(99);
c.close();
ports.close();
});
it("proxy returns undefined for symbol keys", { timeout: 5000 }, () => {
const ports = createInProcessPortPair();
const c = createWorkerRpcClient<Calc>(ports.clientPort);
const sym = Symbol("x");
expect((c as unknown as Record<symbol, unknown>)[sym]).toBeUndefined();
c.close();
ports.close();
});
it("proxy exposes raw + close on reserved keys", { timeout: 5000 }, () => {
const ports = createInProcessPortPair();
const c = createWorkerRpcClient<Calc>(ports.clientPort);
expect(c.__raw__).toBeDefined();
expect(typeof c.close).toBe("function");
c.close();
ports.close();
});
it("invalid frame inbound surfaces via onProtocolError", {
timeout: 5000,
}, async () => {
const ports = createInProcessPortPair();
const seen: WorkerRpcError[] = [];
const c = createRawWorkerRpcClient(ports.clientPort, {
onProtocolError: (e) => seen.push(e),
});
ports.serverPort.postMessage({ not: "a frame" });
await new Promise((r) => setTimeout(r, 30));
expect(seen[0]?.code).toBe("WORKER_RPC.INVALID_FRAME");
c.close();
ports.close();
});
it("version mismatch inbound surfaces via onProtocolError", {
timeout: 5000,
}, async () => {
const ports = createInProcessPortPair();
const seen: WorkerRpcError[] = [];
const c = createRawWorkerRpcClient(ports.clientPort, {
onProtocolError: (e) => seen.push(e),
});
ports.serverPort.postMessage({
v: "9.9.9",
kind: "response",
ok: true,
id: 1,
result: 1,
});
await new Promise((r) => setTimeout(r, 30));
expect(seen[0]?.code).toBe("WORKER_RPC.VERSION_MISMATCH");
c.close();
ports.close();
});
it("send error during call rejects pending and releases budget", {
timeout: 5000,
}, async () => {
// A faulty PortLike that throws on postMessage so sendRaw rethrows.
const seenListeners: Array<(e: { data: unknown }) => void> = [];
const port = {
postMessage: (_v: unknown) => {
throw new Error("port broken");
},
addEventListener: (
_t: "message",
l: (e: { data: unknown }) => void
) => {
seenListeners.push(l);
},
removeEventListener: () => {},
start: () => {},
};
const c = createRawWorkerRpcClient(port);
await expect(c.call("x", [])).rejects.toThrow("port broken");
expect(c.inFlight()).toBe(0);
c.close();
});
it("send error from non-Error value still rejects", {
timeout: 5000,
}, async () => {
const port = {
postMessage: ((_v: unknown) => {
const s: string = "raw string";
throw s as unknown as Error;
}) as (v: unknown) => void,
addEventListener: () => {},
removeEventListener: () => {},
start: () => {},
};
const c = createRawWorkerRpcClient(port);
await expect(c.call("x", [])).rejects.toThrow("raw string");
c.close();
});
it("inbound stream frame routed to onStreamFrame", {
timeout: 5000,
}, async () => {
const ports = createInProcessPortPair();
const seen: unknown[] = [];
const c = createRawWorkerRpcClient(ports.clientPort, {
onStreamFrame: (f) => seen.push(f),
});
ports.serverPort.postMessage({
v: PROTOCOL_VERSION,
kind: "stream-chunk",
id: 1,
chunk: "x",
});
ports.serverPort.postMessage({
v: PROTOCOL_VERSION,
kind: "stream-end",
ok: true,
id: 1,
});
ports.serverPort.postMessage({
v: PROTOCOL_VERSION,
kind: "stream-ack",
id: 1,
delta: 1,
});
ports.serverPort.postMessage({
v: PROTOCOL_VERSION,
kind: "progress",
id: 1,
chunk: "p",
});
await new Promise((r) => setTimeout(r, 30));
expect(seen).toHaveLength(4);
c.close();
ports.close();
});
it("ignores inbound non-relevant frames (hello/goodbye/cancel)", {
timeout: 5000,
}, async () => {
const ports = createInProcessPortPair();
const c = createRawWorkerRpcClient(ports.clientPort);
ports.serverPort.postMessage({
v: PROTOCOL_VERSION,
kind: "hello",
hostVersion: "1.0",
capabilities: [],
});
ports.serverPort.postMessage({
v: PROTOCOL_VERSION,
kind: "goodbye",
reason: "bye",
});
ports.serverPort.postMessage({
v: PROTOCOL_VERSION,
kind: "cancel",
id: 99,
});
await new Promise((r) => setTimeout(r, 30));
c.close();
ports.close();
});
});

View File

@@ -0,0 +1,265 @@
import {
AbortError,
attachAbortSignal,
} from "../abort-forwarding/abort-forwarding.js";
import {
type BackpressurePolicy,
BoundedQueue,
CallBudget,
DEFAULT_BACKPRESSURE,
} from "../backpressure/backpressure.js";
import {
type CorrelationIdGenerator,
createCorrelationIdGenerator,
PendingRegistry,
} from "../correlation/correlation.js";
import { WorkerRpcError } from "../error-codes/error-codes.js";
import { envelopeToError } from "../error-reconstruction/error-reconstruction.js";
import {
PROTOCOL_VERSION,
parseFrame,
type RequestFrame,
type WorkerRpcFrame,
} from "../protocol/protocol.js";
import type { PortLike } from "../testing/testing.js";
import { assertCloneable } from "./cloneable-guard.js";
export interface WorkerRpcClientOptions {
readonly idGenerator?: CorrelationIdGenerator;
readonly callBudget?: number;
readonly outboundQueue?: BackpressurePolicy;
/** Per-call timeout in ms; `0` disables. Default `0`. */
readonly defaultTimeoutMs?: number;
/**
* Hook invoked when a stream-chunk arrives for an open stream. Wired up
* by `streamRequest`; here so the client can multiplex without circular
* imports.
*/
readonly onStreamFrame?: (frame: WorkerRpcFrame) => void;
/** Observer invoked when a non-fatal protocol error is detected. */
readonly onProtocolError?: (err: WorkerRpcError) => void;
}
export interface WorkerRpcClient {
/** Invoke a method on the remote side. */
call<R = unknown>(
method: string,
params: ReadonlyArray<unknown>,
opts?: { signal?: AbortSignal; timeoutMs?: number }
): Promise<R>;
/** Send a `cancel` frame for an active stream/request id. */
cancel(id: number): void;
/** Allocate the next correlation id (used by streaming layer). */
nextId(): number;
/** Send an already-built frame; used by streaming layer. */
send(frame: WorkerRpcFrame): void;
/** Underlying registry — exposed for streaming + tests. */
readonly registry: PendingRegistry;
/** Active in-flight call count. */
readonly inFlight: () => number;
/** Tear down: rejects pending, removes listener. */
close(reason?: string): void;
}
/**
* Build a worker-RPC client over a {@link PortLike}. The returned object
* is callable via `.call(method, params)` or — through the
* {@link createWorkerRpcClient}-derived proxy — directly as
* `client.method(...args)`.
*/
export function createRawWorkerRpcClient(
port: PortLike,
opts: WorkerRpcClientOptions = {}
): WorkerRpcClient {
const idGen = opts.idGenerator ?? createCorrelationIdGenerator();
const registry = new PendingRegistry();
const budget = new CallBudget(opts.callBudget ?? 1024);
const queue = new BoundedQueue<WorkerRpcFrame>(
opts.outboundQueue ?? DEFAULT_BACKPRESSURE
);
let closed = false;
const listener = (event: { data: unknown }): void => {
let frame: WorkerRpcFrame;
try {
frame = parseFrame(event.data);
} catch (cause) {
opts.onProtocolError?.(
new WorkerRpcError(
"WORKER_RPC.INVALID_FRAME",
"received malformed RPC frame",
{ cause }
)
);
return;
}
if (frame.v !== PROTOCOL_VERSION) {
opts.onProtocolError?.(
new WorkerRpcError(
"WORKER_RPC.VERSION_MISMATCH",
`expected protocol ${PROTOCOL_VERSION}, got ${frame.v}`
)
);
return;
}
if (frame.kind === "response") {
if (!registry.has(frame.id)) {
opts.onProtocolError?.(
new WorkerRpcError(
"WORKER_RPC.ORPHAN_RESPONSE",
`no pending call for response id ${frame.id}`
)
);
return;
}
if (frame.ok) {
registry.settle(frame.id, { ok: true, value: frame.result });
budget.release();
} else {
registry.settle(frame.id, {
ok: false,
error: envelopeToError(frame.error),
});
budget.release();
}
return;
}
if (
frame.kind === "stream-chunk" ||
frame.kind === "stream-end" ||
frame.kind === "progress" ||
frame.kind === "stream-ack"
) {
opts.onStreamFrame?.(frame);
return;
}
// hello / goodbye / cancel arriving inbound are advisory here.
};
port.addEventListener("message", listener);
port.start?.();
const sendRaw = (frame: WorkerRpcFrame): void => {
if (closed) {
throw new WorkerRpcError(
"WORKER_RPC.PORT_CLOSED",
"cannot send on closed port"
);
}
queue.enqueue(frame);
const next = queue.dequeue();
// dequeue is always non-undefined here because enqueue just
// accepted (or threw) on overflow; the cast is for the type
// narrowing only.
port.postMessage(next as WorkerRpcFrame);
};
return {
registry,
nextId: () => idGen.next(),
send: sendRaw,
inFlight: () => budget.inFlight,
call<R = unknown>(
method: string,
params: ReadonlyArray<unknown>,
callOpts?: { signal?: AbortSignal; timeoutMs?: number }
): Promise<R> {
assertCloneable(params, "args");
budget.acquire();
const id = idGen.next();
const promise = new Promise<R>((resolve, reject) => {
registry.register({
id,
resolve: (v) => resolve(v as R),
reject,
});
const abortLink = attachAbortSignal(callOpts?.signal, () => {
if (registry.has(id)) {
sendRaw({
v: PROTOCOL_VERSION,
kind: "cancel",
id,
});
registry.settle(id, {
ok: false,
error: new AbortError(`call ${method} aborted`),
});
budget.release();
}
});
const timeout =
callOpts?.timeoutMs ?? opts.defaultTimeoutMs ?? 0;
if (timeout > 0) {
registry.attachTimeout(id, timeout);
}
const frame: RequestFrame = {
v: PROTOCOL_VERSION,
kind: "request",
id,
method,
params: [...params],
};
try {
sendRaw(frame);
} catch (err) {
abortLink.dispose();
registry.settle(id, {
ok: false,
error:
err instanceof Error ? err : new Error(String(err)),
});
budget.release();
}
});
return promise;
},
cancel(id: number): void {
if (registry.has(id)) {
sendRaw({ v: PROTOCOL_VERSION, kind: "cancel", id });
registry.cancel(id);
budget.release();
}
},
close(reason = "closed"): void {
if (closed) return;
closed = true;
port.removeEventListener("message", listener);
registry.clear(
new WorkerRpcError("WORKER_RPC.PORT_CLOSED", reason)
);
},
};
}
/**
* Typed proxy over {@link createRawWorkerRpcClient}. Every property access
* becomes a method invocation; the returned promise resolves with the
* remote result. Reserved fields (`close`, `__raw__`) expose lifecycle
* controls.
*/
export type WorkerProxyClient<T extends object> = {
[K in keyof T]: T[K] extends (...args: infer A) => infer R
? (...args: A) => Promise<Awaited<R>>
: T[K];
} & {
readonly __raw__: WorkerRpcClient;
close(reason?: string): void;
};
export function createWorkerRpcClient<T extends object>(
port: PortLike,
opts: WorkerRpcClientOptions = {}
): WorkerProxyClient<T> {
const raw = createRawWorkerRpcClient(port, opts);
const target: Record<string, unknown> = {
__raw__: raw,
close: (reason?: string) => raw.close(reason),
};
const proxy = new Proxy(target, {
get(t, prop) {
if (typeof prop !== "string") return undefined;
if (prop in t) return t[prop];
return (...args: ReadonlyArray<unknown>) => raw.call(prop, args);
},
});
return proxy as WorkerProxyClient<T>;
}

View File

@@ -0,0 +1,79 @@
# `cloneable-guard`
## Purpose
Walks a value (typically an RPC argument list or return value) and
throws `WORKER_RPC.NOT_CLONEABLE` at the call site if any element is
not structured-clone-safe. This eliminates one of the most painful
debugging traps in worker-based architectures: a method call that
returns "successfully" from the caller's perspective but corrupts
the wire because some inner property held a function reference that
the platform silently dropped.
The guard runs synchronously before any frame is enqueued. The thrown
`WorkerRpcError` carries a `details.path` field naming the offending
slot (`"args[0].onProgress"`, `"args[1].handler.callback"`, …) so the
fix is mechanical: replace the offending field with a structured-
clone-safe equivalent or pass it via a separate side-channel.
## Features
- Synchronous detection of: functions, symbols, and class instances
whose prototype is neither `Object.prototype` nor `null`.
- Permits the standard structured-clone built-ins: `Date`, `RegExp`,
`ArrayBuffer`, typed arrays, `Map`, `Set`, `Error`, `URL`, plain
objects, null-prototype objects, and arrays.
- Walks `Map` entries and `Set` members.
- Cycle-safe via a `WeakSet`.
- Reports the offending path in the thrown error's `context.path`.
## Usage: Basic
```ts
import { assertCloneable } from "@suaveplan/api-worker-rpc/client";
assertCloneable({ name: "alice", age: 30 }); // ok
assertCloneable({ onUpdate: () => {} }); // throws WORKER_RPC.NOT_CLONEABLE
```
## Usage: Advanced
```ts
import { assertCloneable } from "@suaveplan/api-worker-rpc/client";
import { WorkerRpcError } from "@suaveplan/api-worker-rpc/error-codes";
function safePost(args: unknown[]) {
try {
assertCloneable(args, "args");
} catch (err) {
if (err instanceof WorkerRpcError && err.code === "WORKER_RPC.NOT_CLONEABLE") {
console.error(`cannot send: ${err.context?.path}`);
throw err;
}
throw err;
}
workerPort.postMessage(args);
}
```
## API Reference
- `assertCloneable(value: unknown, path?: string): void` — throws
`WorkerRpcError` with code `WORKER_RPC.NOT_CLONEABLE` and
`context.path` set to the offending slot.
## Implementation Notes
- The class-instance check uses `Object.getPrototypeOf(obj) !==
Object.prototype && proto !== null`. This correctly accepts:
plain objects (`{ a: 1 }`), null-prototype objects
(`Object.create(null)`), and the standard built-ins listed in
Features. It rejects user-defined classes — the canonical
symptom of accidentally sending stateful objects.
- Typed arrays are detected via `ArrayBuffer.isView(obj)` (which
matches `Uint8Array`, `Int32Array`, …, including `DataView`).
- The walk is depth-first; the first offender throws and
short-circuits the rest of the traversal.
- The `WeakSet` cycle guard means a self-referential object passes
the walk (cycles are themselves structured-clone-safe in modern
runtimes).

View File

@@ -0,0 +1,84 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WorkerRpcError } from "../error-codes/error-codes.js";
import { assertCloneable } from "./cloneable-guard.js";
describe("cloneable-guard", () => {
it("accepts primitives", { timeout: 5000 }, () => {
for (const v of [null, undefined, 1, "x", true, false, 1n]) {
assertCloneable(v);
}
});
it("function argument throws NOT_CLONEABLE", { timeout: 5000 }, () => {
let captured: WorkerRpcError | undefined;
try {
assertCloneable({ onProgress: () => {} }, "args[0]");
} catch (e) {
captured = e as WorkerRpcError;
}
expect(captured).toBeInstanceOf(WorkerRpcError);
expect(captured?.code).toBe("WORKER_RPC.NOT_CLONEABLE");
expect(captured?.context?.["path"]).toBe("args[0].onProgress");
});
it("symbol throws NOT_CLONEABLE", { timeout: 5000 }, () => {
expect(() => assertCloneable(Symbol("x"))).toThrow(WorkerRpcError);
});
it("class instance throws NOT_CLONEABLE", { timeout: 5000 }, () => {
class Foo {
public a = 1;
}
let captured: WorkerRpcError | undefined;
try {
assertCloneable(new Foo(), "args[0]");
} catch (e) {
captured = e as WorkerRpcError;
}
expect(captured?.code).toBe("WORKER_RPC.NOT_CLONEABLE");
});
it("cloneable nested object passes", { timeout: 5000 }, () => {
assertCloneable({ a: { b: { c: 1 } }, d: [1, 2, "x"] });
});
it("array passes element check", { timeout: 5000 }, () => {
expect(() => assertCloneable([1, 2, () => 3])).toThrow(WorkerRpcError);
});
it("Date, RegExp, ArrayBuffer, URL, Error pass", { timeout: 5000 }, () => {
assertCloneable(new Date());
assertCloneable(/x/);
assertCloneable(new ArrayBuffer(4));
assertCloneable(new URL("https://example.com"));
assertCloneable(new Error("e"));
assertCloneable(new Uint8Array([1, 2]));
});
it("Map values walked", { timeout: 5000 }, () => {
const m = new Map<string, unknown>([["a", () => 1]]);
expect(() => assertCloneable(m)).toThrow(WorkerRpcError);
const ok = new Map<string, unknown>([["a", 1]]);
assertCloneable(ok);
});
it("Set values walked", { timeout: 5000 }, () => {
const s = new Set<unknown>([() => 1]);
expect(() => assertCloneable(s)).toThrow(WorkerRpcError);
const ok = new Set<unknown>([1, "x"]);
assertCloneable(ok);
});
it("null-prototype object passes class check", { timeout: 5000 }, () => {
const obj = Object.create(null) as { a?: number };
obj.a = 1;
assertCloneable(obj);
});
it("cycles do not infinite-loop", { timeout: 5000 }, () => {
type C = { self?: C; v: number };
const o: C = { v: 1 };
o.self = o;
assertCloneable(o);
});
});

View File

@@ -0,0 +1,87 @@
import { WorkerRpcError } from "../error-codes/error-codes.js";
/**
* Pre-send check that walks a value and rejects anything not
* structured-clone-safe. Saves callers from silent wire corruption AND
* from the opaque `DataCloneError` that browsers/Bun throw when a
* non-cloneable value reaches `postMessage`.
*
* Rejects: functions, symbols, class instances whose constructor is not
* `Object`/`Array`/`Map`/`Set`/`Date`/`Error`/`URL`/`RegExp`/typed-arrays.
* Accepts: primitives, plain objects, arrays, Map, Set, Date, Error,
* RegExp, ArrayBuffer + views, URL, structured-clonable wire built-ins.
*/
export function assertCloneable(value: unknown, path = "value"): void {
walk(value, path, new WeakSet<object>());
}
function walk(value: unknown, path: string, seen: WeakSet<object>): void {
const t = typeof value;
if (
value === null ||
t === "string" ||
t === "number" ||
t === "boolean" ||
t === "bigint" ||
t === "undefined"
) {
return;
}
if (t === "function") {
throw new WorkerRpcError(
"WORKER_RPC.NOT_CLONEABLE",
`non-cloneable function at ${path}`,
{ context: { path } }
);
}
if (t === "symbol") {
throw new WorkerRpcError(
"WORKER_RPC.NOT_CLONEABLE",
`non-cloneable symbol at ${path}`,
{ context: { path } }
);
}
const obj = value as object;
if (seen.has(obj)) return;
seen.add(obj);
if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) {
walk(obj[i], `${path}[${i}]`, seen);
}
return;
}
if (
obj instanceof Date ||
obj instanceof RegExp ||
obj instanceof ArrayBuffer ||
obj instanceof Error ||
obj instanceof URL ||
ArrayBuffer.isView(obj)
) {
return;
}
if (obj instanceof Map) {
for (const [k, v] of obj.entries()) {
walk(k, `${path}.<key>`, seen);
walk(v, `${path}.<value>`, seen);
}
return;
}
if (obj instanceof Set) {
for (const v of obj.values()) {
walk(v, `${path}.<entry>`, seen);
}
return;
}
const proto = Object.getPrototypeOf(obj);
if (proto !== Object.prototype && proto !== null) {
throw new WorkerRpcError(
"WORKER_RPC.NOT_CLONEABLE",
`non-cloneable class instance at ${path}`,
{ context: { path } }
);
}
for (const k of Object.keys(obj)) {
walk((obj as Record<string, unknown>)[k], `${path}.${k}`, seen);
}
}

View File

@@ -0,0 +1,8 @@
export {
createRawWorkerRpcClient,
createWorkerRpcClient,
type WorkerProxyClient,
type WorkerRpcClient,
type WorkerRpcClientOptions,
} from "./client.js";
export { assertCloneable } from "./cloneable-guard.js";

View File

@@ -0,0 +1,90 @@
# `correlation`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the monotonic correlation-id generator and the in-memory
pending-call registry that backs every outstanding RPC request. The
generator yields strictly-increasing dense ids per port so two ports
cannot accidentally cross-talk and so orphan responses (those whose
id has no outstanding entry) can be detected via a single map lookup
with no false negatives.
The `PendingRegistry` is the locus of every cross-boundary observable
event for an in-flight call: registration on send, settlement on
response receipt, cancellation on caller-side abort, timeout eviction
on inactivity, and clear-on-close to release every pending promise
when the underlying port goes down.
## Features
- Per-port monotonic id generator with configurable start value.
- `O(1)` register / settle / cancel via a `Map<id, PendingCall>`.
- Orphan-response detection: `settle()` on an unknown id throws
`WORKER_RPC.ORPHAN_RESPONSE` so silent drift gets surfaced.
- Correlation-id-reuse detection: double-registration throws
`WORKER_RPC.CORRELATION_ID_REUSED`.
- Pluggable timeout-eviction via `attachTimeout`; the registry
drops the entry and rejects with `WORKER_RPC.REQUEST_TIMEOUT`.
- `clear(reason)` rejects every pending entry with a unified reason
when the port closes.
## Usage: Basic
```ts
import {
createCorrelationIdGenerator,
PendingRegistry,
} from "@suaveplan/api-worker-rpc/correlation";
const ids = createCorrelationIdGenerator();
const registry = new PendingRegistry();
const id = ids.next();
registry.register({ id, resolve, reject });
// On inbound response frame:
registry.settle(id, { ok: true, value: result });
```
## Usage: Advanced
```ts
const promise = new Promise((resolve, reject) => {
registry.register({
id,
resolve,
reject,
onCancel: () => port.postMessage({ kind: "cancel", id }),
});
registry.attachTimeout(id, 5_000);
});
// Tear down on close:
registry.clear(new WorkerRpcError("WORKER_RPC.PORT_CLOSED", "shutdown"));
```
## API Reference
- `createCorrelationIdGenerator(start?: number): CorrelationIdGenerator`
— `{ next(), peek() }`.
- `class PendingRegistry`:
- `register(call: PendingCall): void`
- `settle(id, outcome): void` (throws on orphan)
- `cancel(id): boolean`
- `clear(reason: Error): void`
- `attachTimeout(id, ms): void`
- `has(id): boolean`
- `size: number`
## Implementation Notes
- Ids are plain numbers (not bigint) so they survive `postMessage`
without `BigInt` clone-quirks across runtimes; `Number.MAX_SAFE_INTEGER`
is large enough for any realistic per-port call volume.
- The registry deliberately does NOT manage budget — that lives in the
separate `backpressure.CallBudget` so the two concerns evolve
independently.
- Cancel and timeout both clear the timer; the late-fire branch is a
no-op guarded by `this.map.has(id)`.

View File

@@ -0,0 +1,197 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WorkerRpcError } from "../error-codes/error-codes.js";
import {
createCorrelationIdGenerator,
PendingRegistry,
} from "./correlation.js";
describe("correlation", () => {
it("ids are strictly monotonic per port", { timeout: 5000 }, () => {
const gen = createCorrelationIdGenerator();
const seq: number[] = [];
for (let i = 0; i < 1000; i++) seq.push(gen.next());
for (let i = 1; i < seq.length; i++) {
const prev = seq[i - 1] as number;
const cur = seq[i] as number;
expect(cur).toBe(prev + 1);
}
expect(gen.peek()).toBe(1001);
});
it("custom start value", { timeout: 5000 }, () => {
const gen = createCorrelationIdGenerator(100);
expect(gen.next()).toBe(100);
expect(gen.next()).toBe(101);
});
it("registers and settles a call", { timeout: 5000 }, async () => {
const reg = new PendingRegistry();
const promise = new Promise<unknown>((resolve, reject) => {
reg.register({ id: 1, resolve, reject });
});
reg.settle(1, { ok: true, value: "ok" });
await expect(promise).resolves.toBe("ok");
expect(reg.size).toBe(0);
});
it("orphan response throws WORKER_RPC.ORPHAN_RESPONSE", {
timeout: 5000,
}, () => {
const reg = new PendingRegistry();
expect(() => reg.settle(99, { ok: true, value: 1 })).toThrow(
WorkerRpcError
);
});
it("rejects correlation-id reuse", { timeout: 5000 }, () => {
const reg = new PendingRegistry();
reg.register({ id: 1, resolve: () => {}, reject: () => {} });
expect(() =>
reg.register({ id: 1, resolve: () => {}, reject: () => {} })
).toThrow(WorkerRpcError);
});
it("cancel rejects pending with CANCELLED", { timeout: 5000 }, async () => {
const reg = new PendingRegistry();
let observed: Error | undefined;
reg.register({
id: 7,
resolve: () => {},
reject: (e) => {
observed = e;
},
});
const cancelled = reg.cancel(7);
expect(cancelled).toBe(true);
expect((observed as WorkerRpcError).code).toBe("WORKER_RPC.CANCELLED");
expect(reg.cancel(7)).toBe(false);
});
it("clear rejects all pending with reason", { timeout: 5000 }, () => {
const reg = new PendingRegistry();
const errs: Error[] = [];
reg.register({
id: 1,
resolve: () => {},
reject: (e) => errs.push(e),
});
reg.register({
id: 2,
resolve: () => {},
reject: (e) => errs.push(e),
});
reg.clear(new Error("teardown"));
expect(errs).toHaveLength(2);
expect(reg.size).toBe(0);
});
it("settle with error rejects", { timeout: 5000 }, async () => {
const reg = new PendingRegistry();
const promise = new Promise<unknown>((resolve, reject) => {
reg.register({ id: 3, resolve, reject });
});
reg.settle(3, { ok: false, error: new Error("nope") });
await expect(promise).rejects.toThrow("nope");
});
it("attachTimeout rejects after delay", { timeout: 5000 }, async () => {
const reg = new PendingRegistry();
const promise = new Promise<unknown>((resolve, reject) => {
reg.register({ id: 4, resolve, reject });
});
reg.attachTimeout(4, 10);
await expect(promise).rejects.toMatchObject({
code: "WORKER_RPC.REQUEST_TIMEOUT",
});
});
it("attachTimeout on unknown id is a no-op", { timeout: 5000 }, () => {
const reg = new PendingRegistry();
reg.attachTimeout(999, 10);
expect(reg.size).toBe(0);
});
it("settle clears pending timer", { timeout: 5000 }, async () => {
const reg = new PendingRegistry();
const promise = new Promise<unknown>((resolve, reject) => {
reg.register({ id: 5, resolve, reject });
});
reg.attachTimeout(5, 30);
reg.settle(5, { ok: true, value: "done" });
await expect(promise).resolves.toBe("done");
await new Promise((r) => setTimeout(r, 60));
// No throw from late timer
});
it("cancel clears pending timer", { timeout: 5000 }, async () => {
const reg = new PendingRegistry();
let captured: Error | undefined;
reg.register({
id: 6,
resolve: () => {},
reject: (e) => {
captured = e;
},
});
reg.attachTimeout(6, 30);
reg.cancel(6);
await new Promise((r) => setTimeout(r, 60));
expect((captured as WorkerRpcError).code).toBe("WORKER_RPC.CANCELLED");
});
it("clear runs pending onCancel", { timeout: 5000 }, () => {
const reg = new PendingRegistry();
reg.register({ id: 1, resolve: () => {}, reject: () => {} });
reg.attachTimeout(1, 1000);
reg.clear(new Error("x"));
});
it("late-fire timer is no-op when entry already removed", {
timeout: 5000,
}, async () => {
const reg = new PendingRegistry();
const settled: unknown[] = [];
reg.register({
id: 100,
resolve: (v) => settled.push(v),
reject: (e) => settled.push(e),
});
// Race: schedule a 0ms timer, then delete the entry directly so
// the late-fire branch (live !== call) hits.
reg.attachTimeout(100, 0);
// Replace registration to simulate the race (settle clears the
// timer normally — we intentionally bypass it here to exercise
// the defensive guard).
const internalMap = (reg as unknown as { map: Map<number, unknown> })
.map;
internalMap.delete(100);
internalMap.set(100, {
id: 100,
resolve: () => {},
reject: () => {},
});
await new Promise((r) => setTimeout(r, 20));
// The defensive guard prevents double-settle.
expect(settled).toHaveLength(0);
});
it("late timer is no-op after settle", { timeout: 5000 }, async () => {
const reg = new PendingRegistry();
let count = 0;
reg.register({
id: 8,
resolve: () => count++,
reject: () => count++,
});
reg.attachTimeout(8, 5);
await new Promise((r) => setTimeout(r, 30));
expect(count).toBe(1);
});
it("has() reflects registration", { timeout: 5000 }, () => {
const reg = new PendingRegistry();
expect(reg.has(1)).toBe(false);
reg.register({ id: 1, resolve: () => {}, reject: () => {} });
expect(reg.has(1)).toBe(true);
});
});

View File

@@ -0,0 +1,128 @@
import { WorkerRpcError } from "../error-codes/error-codes.js";
/**
* Monotonic correlation-id generator. Per-port-scoped: each call to
* {@link createCorrelationIdGenerator} returns an independent counter,
* so two ports cannot accidentally collide.
*/
export interface CorrelationIdGenerator {
next(): number;
peek(): number;
}
/**
* Build an id generator with optional starting value (default `1`). Ids are
* strictly monotonic and dense (no gaps), making orphan-response detection
* a single map lookup.
*/
export function createCorrelationIdGenerator(
start = 1
): CorrelationIdGenerator {
let counter = start;
return {
next: () => counter++,
peek: () => counter,
};
}
/**
* Pending-call record stored in the registry. The settle callbacks are
* driven by inbound `response` frames; `onCancel` lets the registry
* transmit an external `cancel` frame when a caller-side `AbortSignal`
* fires.
*/
export interface PendingCall {
readonly id: number;
readonly resolve: (value: unknown) => void;
readonly reject: (error: Error) => void;
readonly onCancel?: () => void;
timer?: ReturnType<typeof setTimeout>;
}
/**
* Registry for outstanding calls. Implements timeout eviction and
* orphan-response detection — both stand on the same fast lookup-by-id
* path so neither costs more than `O(1)` per inbound frame.
*/
export class PendingRegistry {
private readonly map = new Map<number, PendingCall>();
public get size(): number {
return this.map.size;
}
public register(call: PendingCall): void {
if (this.map.has(call.id)) {
throw new WorkerRpcError(
"WORKER_RPC.CORRELATION_ID_REUSED",
"correlation id reused while previous call still in flight"
);
}
this.map.set(call.id, call);
}
public settle(
id: number,
outcome: { ok: true; value: unknown } | { ok: false; error: Error }
): void {
const call = this.map.get(id);
if (call === undefined) {
throw new WorkerRpcError(
"WORKER_RPC.ORPHAN_RESPONSE",
`no pending call for response id ${id}`
);
}
this.map.delete(id);
if (call.timer !== undefined) {
clearTimeout(call.timer);
}
if (outcome.ok) {
call.resolve(outcome.value);
} else {
call.reject(outcome.error);
}
}
public has(id: number): boolean {
return this.map.has(id);
}
public cancel(id: number): boolean {
const call = this.map.get(id);
if (call === undefined) return false;
this.map.delete(id);
if (call.timer !== undefined) clearTimeout(call.timer);
call.onCancel?.();
call.reject(
new WorkerRpcError(
"WORKER_RPC.CANCELLED",
`call ${id} cancelled before completion`
)
);
return true;
}
public clear(reason: Error): void {
for (const call of this.map.values()) {
if (call.timer !== undefined) clearTimeout(call.timer);
call.reject(reason);
}
this.map.clear();
}
public attachTimeout(id: number, ms: number): void {
const call = this.map.get(id);
if (call === undefined) return;
call.timer = setTimeout(() => {
const live = this.map.get(id);
if (live !== call) return;
this.map.delete(id);
call.reject(
new WorkerRpcError(
"WORKER_RPC.REQUEST_TIMEOUT",
`call ${id} timed out after ${ms}ms`
)
);
}, ms);
}
}

View File

@@ -0,0 +1,6 @@
export {
type CorrelationIdGenerator,
createCorrelationIdGenerator,
type PendingCall,
PendingRegistry,
} from "./correlation.js";

View File

@@ -0,0 +1,71 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable `WORKER_RPC.*` code registry and the `WorkerRpcError`
class. Every public failure path in `@suaveplan/api-worker-rpc` raises a
`WorkerRpcError` carrying one of these codes — never a bare `Error`, never
a string match. Consumers branch on `error.code === WORKER_RPC_CODES.X` or
on `error instanceof WorkerRpcError`; both are stable across minor versions.
The registry is the foundation of cross-boundary error reconstruction: when
a worker handler throws a `WorkerRpcError`, the wire envelope preserves the
code string and the receiver reconstructs an equivalent `WorkerRpcError` on
the caller side via `error-reconstruction`. Foreign errors (anything not
extending `SuaveplanError`) are wrapped into `WORKER_RPC.REMOTE_THROW` so
the caller always receives a typed error, never an opaque clone.
## Features
- 14 namespaced codes covering every protocol failure mode.
- `WorkerRpcError` extends `SuaveplanError<WorkerRpcCode>` so `instanceof`
narrowing AND code-string branching both work.
- Frozen literal-typed code union (`WorkerRpcCode`) for exhaustive
`switch`-statement coverage.
- `name === "WorkerRpcError"` for self-describing stack traces.
## Usage: Basic
```ts
import { WORKER_RPC_CODES, WorkerRpcError } from "@suaveplan/api-worker-rpc/error-codes";
throw new WorkerRpcError(WORKER_RPC_CODES.UNKNOWN_METHOD, "method 'foo' not found");
```
## Usage: Advanced
```ts
import { WorkerRpcError, type WorkerRpcCode } from "@suaveplan/api-worker-rpc/error-codes";
function handle(err: unknown) {
if (err instanceof WorkerRpcError) {
switch (err.code as WorkerRpcCode) {
case "WORKER_RPC.BACKPRESSURE":
return retryLater();
case "WORKER_RPC.PORT_CLOSED":
return reconnect();
default:
return rethrow(err);
}
}
}
```
## API Reference
- `WORKER_RPC_CODES` — frozen object literal mapping `KEY → "WORKER_RPC.KEY"`.
- `WorkerRpcCode` — union of every fully-qualified code string.
- `class WorkerRpcError extends SuaveplanError<WorkerRpcCode>` — constructor:
`(code: WorkerRpcCode, message: string, options?: SuaveplanErrorOptions)`.
## Implementation Notes
- Registry uses `defineCodes("WORKER_RPC", [...])` from `@suaveplan/error`
so the prefix is enforced at build time.
- `WorkerRpcError` overrides `name` so logs say `"WorkerRpcError: ..."`
rather than the inherited `"SuaveplanError"`.
- Adding a code is a minor (additive); removing one is a breaking change.

View File

@@ -0,0 +1,42 @@
import { SuaveplanError } from "@suaveplan/error";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WORKER_RPC_CODES, WorkerRpcError } from "./error-codes.js";
describe("error-codes", () => {
it("defines every documented code", { timeout: 5000 }, () => {
const codes = Object.values(WORKER_RPC_CODES);
expect(codes).toContain("WORKER_RPC.PORT_CLOSED");
expect(codes).toContain("WORKER_RPC.REQUEST_TIMEOUT");
expect(codes).toContain("WORKER_RPC.INVALID_FRAME");
expect(codes).toContain("WORKER_RPC.UNKNOWN_METHOD");
expect(codes).toContain("WORKER_RPC.CORRELATION_ID_REUSED");
expect(codes).toContain("WORKER_RPC.STREAM_BACKPRESSURE_EXCEEDED");
expect(codes).toContain("WORKER_RPC.HANDLER_THREW");
expect(codes).toContain("WORKER_RPC.CANCELLED");
expect(codes).toContain("WORKER_RPC.VERSION_MISMATCH");
expect(codes).toContain("WORKER_RPC.ORPHAN_RESPONSE");
expect(codes).toContain("WORKER_RPC.NOT_CLONEABLE");
expect(codes).toContain("WORKER_RPC.REMOTE_THROW");
expect(codes).toContain("WORKER_RPC.BACKPRESSURE");
expect(codes).toContain("WORKER_RPC.RESPONSE_TIMEOUT");
});
it("WorkerRpcError extends SuaveplanError", { timeout: 5000 }, () => {
const err = new WorkerRpcError("WORKER_RPC.PORT_CLOSED", "closed");
expect(err).toBeInstanceOf(SuaveplanError);
expect(err).toBeInstanceOf(WorkerRpcError);
expect(err.code).toBe("WORKER_RPC.PORT_CLOSED");
expect(err.name).toBe("WorkerRpcError");
});
it("preserves cause and context options", { timeout: 5000 }, () => {
const inner = new Error("inner");
const err = new WorkerRpcError(
"WORKER_RPC.HANDLER_THREW",
"handler failed",
{ cause: inner, context: { method: "x" } }
);
expect(err.cause).toBe(inner);
expect(err.context).toEqual({ method: "x" });
});
});

View File

@@ -0,0 +1,46 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-worker-rpc`.
*
* Every public failure path raises a {@link WorkerRpcError} carrying one of
* these namespaced `WORKER_RPC.*` codes. Consumers branch on the literal
* value (e.g. `error.code === WORKER_RPC_CODES.PORT_CLOSED`).
*/
export const WORKER_RPC_CODES = defineCodes("WORKER_RPC", [
"PORT_CLOSED",
"REQUEST_TIMEOUT",
"RESPONSE_TIMEOUT",
"INVALID_FRAME",
"UNKNOWN_METHOD",
"CORRELATION_ID_REUSED",
"STREAM_BACKPRESSURE_EXCEEDED",
"HANDLER_THREW",
"CANCELLED",
"VERSION_MISMATCH",
"ORPHAN_RESPONSE",
"NOT_CLONEABLE",
"REMOTE_THROW",
"BACKPRESSURE",
] as const);
/** Union of every fully-qualified code in {@link WORKER_RPC_CODES}. */
export type WorkerRpcCode =
(typeof WORKER_RPC_CODES)[keyof typeof WORKER_RPC_CODES];
/**
* Typed error class for `@suaveplan/api-worker-rpc`. Extends
* {@link SuaveplanError} so callers may discriminate via either
* `instanceof WorkerRpcError` or by code string.
*/
export class WorkerRpcError extends SuaveplanError<WorkerRpcCode> {
public constructor(
code: WorkerRpcCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.name = "WorkerRpcError";
}
}

View File

@@ -0,0 +1,5 @@
export {
WORKER_RPC_CODES,
type WorkerRpcCode,
WorkerRpcError,
} from "./error-codes.js";

View File

@@ -0,0 +1,90 @@
# `error-reconstruction`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Translates thrown values into wire-safe `ErrorEnvelope`s and back into
typed `Error` instances on the receiving side. `SuaveplanError` subclasses
round-trip with their `code`, `name`, depth-bounded `cause` chain, and
context details intact; foreign throws (`TypeError`, plain `Error`, even
non-Error values like strings) are wrapped into a `WORKER_RPC.REMOTE_THROW`
envelope so the caller always observes a uniform typed error rather than
an opaque structured-clone of arbitrary user state.
Cause chains are bounded at five levels deep. A truncated tail becomes the
sentinel code `ERROR.CAUSE.TRUNCATED` so consumers can detect that the
chain was clipped rather than missing entirely.
## Features
- `errorToEnvelope(value, depth?)` — serialise any thrown value with
full cause-chain walk, stack capture, and context preservation.
- `envelopeToError(env)` — reconstruct on the receiving side:
`WORKER_RPC.*` codes rebuild as `WorkerRpcError`; everything else
rebuilds as a generic `SuaveplanError` subclass preserving the
original `code` and `name`.
- Cause-depth bounded at five levels with a sentinel truncation marker.
- `details` (a.k.a. `context`) survive the round-trip.
- `remoteStack` carries the original `.stack` string so server-side
diagnostics survive the boundary as a plain string (live `Error.stack`
cannot survive structured clone).
## Usage: Basic
```ts
import {
errorToEnvelope,
envelopeToError,
} from "@suaveplan/api-worker-rpc/error-reconstruction";
const env = errorToEnvelope(new TypeError("boom"));
// → { code: "WORKER_RPC.REMOTE_THROW", details: { remoteName: "TypeError" }, ... }
const back = envelopeToError(env);
console.log(back.code); // "WORKER_RPC.REMOTE_THROW"
```
## Usage: Advanced
```ts
import { SuaveplanError } from "@suaveplan/error";
import { errorToEnvelope, envelopeToError } from "@suaveplan/api-worker-rpc/error-reconstruction";
class BillingError extends SuaveplanError<"BILLING.PAYMENT.FAILED"> {
constructor(msg: string, opts?: { cause?: unknown }) {
super("BILLING.PAYMENT.FAILED", msg, opts);
this.name = "BillingError";
}
}
const env = errorToEnvelope(new BillingError("card declined", {
cause: new BillingError("retry exhausted"),
}));
const back = envelopeToError(env);
// back instanceof SuaveplanError === true
// back.code === "BILLING.PAYMENT.FAILED"
// (back.cause as SuaveplanError).code === "BILLING.PAYMENT.FAILED"
```
## API Reference
- `errorToEnvelope(value: unknown, depth?: number): ErrorEnvelope`
- `envelopeToError(env: ErrorEnvelope): Error`
- `ErrorEnvelope` is re-exported from `protocol`; see that module for
the schema.
## Implementation Notes
- `SuaveplanError` instances are preferred — their `code` and `cause`
chain are reconstructed exactly. Foreign errors become
`WORKER_RPC.REMOTE_THROW` with the original constructor name
recorded as `details.remoteName`.
- The generic reconstructed class is a private subclass of
`SuaveplanError`; consumers should match on `error.code` rather
than `instanceof` for foreign code values.
- Depth budget is fixed at 5 (matches `@suaveplan/error`'s default
`MAX_CAUSE_DEPTH`). Truncated tails become the
`ERROR.CAUSE.TRUNCATED` sentinel — checking for it lets callers
surface "more context elided" in error UIs.

View File

@@ -0,0 +1,204 @@
import { SuaveplanError } from "@suaveplan/error";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WorkerRpcError } from "../error-codes/error-codes.js";
import { envelopeToError, errorToEnvelope } from "./error-reconstruction.js";
class TestError extends SuaveplanError<"TEST.A" | "TEST.B"> {
public constructor(
code: "TEST.A" | "TEST.B",
msg: string,
options?: { cause?: unknown }
) {
super(code, msg, options);
this.name = "TestError";
}
}
describe("error-reconstruction", () => {
it("SuaveplanError round-trips with cause", { timeout: 5000 }, () => {
const inner = new TestError("TEST.B", "inner reason");
const outer = new TestError("TEST.A", "outer reason", { cause: inner });
const env = errorToEnvelope(outer);
expect(env.code).toBe("TEST.A");
expect(env.cause?.code).toBe("TEST.B");
const back = envelopeToError(env);
expect(back).toBeInstanceOf(SuaveplanError);
expect((back as SuaveplanError<string>).code).toBe("TEST.A");
expect(
((back as SuaveplanError<string>).cause as SuaveplanError<string>)
.code
).toBe("TEST.B");
});
it("WorkerRpcError envelope reconstructs as WorkerRpcError", {
timeout: 5000,
}, () => {
const err = new WorkerRpcError(
"WORKER_RPC.HANDLER_THREW",
"remote handler crashed"
);
const back = envelopeToError(errorToEnvelope(err));
expect(back).toBeInstanceOf(WorkerRpcError);
expect((back as WorkerRpcError).code).toBe("WORKER_RPC.HANDLER_THREW");
});
it("plain Error becomes WORKER_RPC.REMOTE_THROW", { timeout: 5000 }, () => {
const env = errorToEnvelope(new TypeError("boom"));
expect(env.code).toBe("WORKER_RPC.REMOTE_THROW");
expect(env.details?.["remoteName"]).toBe("TypeError");
const back = envelopeToError(env);
expect((back as WorkerRpcError).code).toBe("WORKER_RPC.REMOTE_THROW");
});
it("non-Error value becomes REMOTE_THROW", { timeout: 5000 }, () => {
const env = errorToEnvelope("plain string");
expect(env.code).toBe("WORKER_RPC.REMOTE_THROW");
expect(env.message).toBe("plain string");
const env2 = errorToEnvelope({ obscure: true });
expect(env2.code).toBe("WORKER_RPC.REMOTE_THROW");
});
it("depth-bounded cause chain truncates", { timeout: 5000 }, () => {
let last: TestError | undefined;
for (let i = 0; i < 10; i++) {
const next: TestError = new TestError("TEST.A", `level-${i}`, {
cause: last,
});
last = next;
}
const env = errorToEnvelope(last as TestError);
let depth = 0;
let cur: typeof env | undefined = env;
while (cur !== undefined) {
depth++;
cur = cur.cause;
}
// 5 real + 1 truncation marker
expect(depth).toBeLessThanOrEqual(6);
});
it("preserves stack as remoteStack", { timeout: 5000 }, () => {
const err = new WorkerRpcError("WORKER_RPC.CANCELLED", "cancel");
const env = errorToEnvelope(err);
expect(env.remoteStack).toBeDefined();
const back = envelopeToError(env);
expect(back.stack).toBe(env.remoteStack);
});
it("envelope-to-error preserves cause chain on foreign codes", {
timeout: 5000,
}, () => {
const env = {
code: "X.Y.Z",
name: "Outer",
message: "outer",
cause: { code: "X.Y.W", name: "Inner", message: "inner" },
};
const back = envelopeToError(env);
expect((back as SuaveplanError<string>).code).toBe("X.Y.Z");
expect(back.name).toBe("Outer");
expect(
((back as SuaveplanError<string>).cause as SuaveplanError<string>)
.code
).toBe("X.Y.W");
});
it("Error with cause chains via remoteThrow", { timeout: 5000 }, () => {
const inner = new RangeError("inner");
const outer = new Error("outer", { cause: inner });
const env = errorToEnvelope(outer);
expect(env.cause).toBeDefined();
expect(env.cause?.code).toBe("WORKER_RPC.REMOTE_THROW");
});
it("SuaveplanError without stack still envelopes", {
timeout: 5000,
}, () => {
const err = new WorkerRpcError("WORKER_RPC.CANCELLED", "x");
Object.defineProperty(err, "stack", { value: undefined });
const env = errorToEnvelope(err);
expect(env.remoteStack).toBeUndefined();
});
it("WORKER_RPC envelope without remoteStack reconstructs", {
timeout: 5000,
}, () => {
const back = envelopeToError({
code: "WORKER_RPC.CANCELLED",
name: "WorkerRpcError",
message: "x",
});
expect((back as WorkerRpcError).code).toBe("WORKER_RPC.CANCELLED");
});
it("generic foreign envelope without remoteStack reconstructs", {
timeout: 5000,
}, () => {
const back = envelopeToError({
code: "FOO.BAR",
name: "FooError",
message: "x",
});
expect((back as SuaveplanError<string>).code).toBe("FOO.BAR");
});
it("WorkerRpcError envelope reconstructs with cause + details", {
timeout: 5000,
}, () => {
const env = {
code: "WORKER_RPC.HANDLER_THREW",
name: "WorkerRpcError",
message: "x",
details: { foo: 1 },
cause: {
code: "WORKER_RPC.CANCELLED",
name: "WorkerRpcError",
message: "inner",
details: { bar: 2 },
},
};
const back = envelopeToError(env);
expect((back as WorkerRpcError).code).toBe("WORKER_RPC.HANDLER_THREW");
expect((back as WorkerRpcError).context).toEqual({ foo: 1 });
const c = (back as WorkerRpcError).cause as WorkerRpcError;
expect(c.code).toBe("WORKER_RPC.CANCELLED");
expect(c.context).toEqual({ bar: 2 });
});
it("generic envelope reconstructs with cause + details", {
timeout: 5000,
}, () => {
const env = {
code: "FOO.BAR",
name: "FooError",
message: "x",
details: { foo: 1 },
cause: {
code: "FOO.BAZ",
name: "FooError",
message: "inner",
details: { bar: 2 },
},
};
const back = envelopeToError(env);
expect((back as SuaveplanError<string>).code).toBe("FOO.BAR");
expect((back as SuaveplanError<string>).context).toEqual({ foo: 1 });
});
it("foreign Error without stack still envelopes", { timeout: 5000 }, () => {
const e = new Error("x");
Object.defineProperty(e, "stack", { value: undefined });
const env = errorToEnvelope(e);
expect(env.remoteStack).toBeUndefined();
});
it("details survive round-trip", { timeout: 5000 }, () => {
const err = new WorkerRpcError("WORKER_RPC.HANDLER_THREW", "x", {
context: { foo: 1 },
});
const env = errorToEnvelope(err);
expect(env.details).toEqual({ foo: 1 });
const back = envelopeToError(env);
expect((back as WorkerRpcError).context).toEqual({ foo: 1 });
});
});

View File

@@ -0,0 +1,152 @@
import { SuaveplanError } from "@suaveplan/error";
import { WorkerRpcError } from "../error-codes/error-codes.js";
import type { ErrorEnvelope } from "../protocol/protocol.js";
const MAX_CAUSE_DEPTH = 5;
/**
* Serialise any thrown value into an {@link ErrorEnvelope} suitable for
* the wire. `SuaveplanError` retains its `code`, `name`, and (depth-bounded)
* cause chain; foreign errors keep their constructor name in `details.remoteName`.
*/
export function errorToEnvelope(value: unknown, depth = 0): ErrorEnvelope {
if (depth >= MAX_CAUSE_DEPTH) {
return {
code: "ERROR.CAUSE.TRUNCATED",
name: "TruncatedCause",
message: "[cause chain truncated]",
};
}
if (value instanceof SuaveplanError) {
const env: {
code: string;
name: string;
message: string;
details?: Readonly<Record<string, unknown>>;
remoteStack?: string;
cause?: ErrorEnvelope;
} = {
code: value.code,
name: value.name,
message: value.message,
};
if (value.context !== undefined) {
env.details = value.context;
}
if (typeof value.stack === "string") {
env.remoteStack = value.stack;
}
if (value.cause !== undefined) {
env.cause = errorToEnvelope(value.cause, depth + 1);
}
return env;
}
if (value instanceof Error) {
const details: Record<string, unknown> = { remoteName: value.name };
const env: {
code: string;
name: string;
message: string;
details?: Readonly<Record<string, unknown>>;
remoteStack?: string;
cause?: ErrorEnvelope;
} = {
code: "WORKER_RPC.REMOTE_THROW",
name: "WorkerRpcError",
message: value.message,
details,
};
if (typeof value.stack === "string") {
env.remoteStack = value.stack;
}
if (value.cause !== undefined) {
env.cause = errorToEnvelope(value.cause, depth + 1);
}
return env;
}
return {
code: "WORKER_RPC.REMOTE_THROW",
name: "WorkerRpcError",
message: typeof value === "string" ? value : String(value),
details: { remoteName: "NonError" },
};
}
/**
* Reconstruct an `Error` from an envelope. `WORKER_RPC.*` codes become a
* fresh `WorkerRpcError`; every other code becomes a generic
* `SuaveplanError` subclass with the original code preserved (`name` and
* `code` are both stable, satisfying `instanceof SuaveplanError`).
*/
export function envelopeToError(env: ErrorEnvelope): Error {
const cause =
env.cause !== undefined ? envelopeToError(env.cause) : undefined;
if (env.code.startsWith("WORKER_RPC.")) {
return buildWorkerRpcError(env, cause);
}
return buildGenericReconstructed(env, cause);
}
function buildWorkerRpcError(
env: ErrorEnvelope,
cause: Error | undefined
): WorkerRpcError {
const opts: {
cause?: unknown;
context?: Readonly<Record<string, unknown>>;
} = {};
if (cause !== undefined) opts.cause = cause;
if (env.details !== undefined) opts.context = env.details;
const err = new WorkerRpcError(
env.code as ConstructorParameters<typeof WorkerRpcError>[0],
env.message,
opts
);
if (env.remoteStack !== undefined) {
Object.defineProperty(err, "stack", {
value: env.remoteStack,
configurable: true,
});
}
return err;
}
/**
* Generic concrete subclass of {@link SuaveplanError} used for foreign
* codes — preserves the code string + cause chain so callers can branch
* on `error.code`.
*/
class ReconstructedSuaveplanError extends SuaveplanError<string> {
public constructor(
code: string,
message: string,
options: {
cause?: unknown;
context?: Readonly<Record<string, unknown>>;
}
) {
super(code, message, options);
this.name = "ReconstructedSuaveplanError";
}
}
function buildGenericReconstructed(
env: ErrorEnvelope,
cause: Error | undefined
): SuaveplanError<string> {
const opts: {
cause?: unknown;
context?: Readonly<Record<string, unknown>>;
} = {};
if (cause !== undefined) opts.cause = cause;
if (env.details !== undefined) opts.context = env.details;
const err = new ReconstructedSuaveplanError(env.code, env.message, opts);
err.name = env.name;
if (env.remoteStack !== undefined) {
Object.defineProperty(err, "stack", {
value: env.remoteStack,
configurable: true,
});
}
return err;
}

View File

@@ -0,0 +1,4 @@
export {
envelopeToError,
errorToEnvelope,
} from "./error-reconstruction.js";

View File

@@ -0,0 +1,95 @@
// Error codes
// Abort forwarding
export {
AbortError,
type AbortLink,
attachAbortSignal,
createRemoteAbort,
type RemoteAbort,
} from "./abort-forwarding/index.js";
// Backpressure
export {
type BackpressureMode,
type BackpressurePolicy,
BoundedQueue,
CallBudget,
DEFAULT_BACKPRESSURE,
} from "./backpressure/index.js";
// Client
export {
assertCloneable,
createRawWorkerRpcClient,
createWorkerRpcClient,
type WorkerProxyClient,
type WorkerRpcClient,
type WorkerRpcClientOptions,
} from "./client/index.js";
// Correlation
export {
type CorrelationIdGenerator,
createCorrelationIdGenerator,
type PendingCall,
PendingRegistry,
} from "./correlation/index.js";
export {
WORKER_RPC_CODES,
type WorkerRpcCode,
WorkerRpcError,
} from "./error-codes/index.js";
// Error reconstruction
export {
envelopeToError,
errorToEnvelope,
} from "./error-reconstruction/index.js";
// Protocol
export {
type CancelFrame,
cancelFrameSchema,
type ErrorEnvelope,
errorEnvelopeSchema,
type GoodbyeFrame,
goodbyeFrameSchema,
type HelloFrame,
helloFrameSchema,
PROTOCOL_VERSION,
type ProgressFrame,
parseFrame,
progressFrameSchema,
type RequestFrame,
type ResponseFrame,
requestFrameSchema,
responseFrameSchema,
type StreamAckFrame,
type StreamChunkFrame,
type StreamEndFrame,
type StreamOpenFrame,
streamAckFrameSchema,
streamChunkFrameSchema,
streamEndFrameSchema,
streamOpenFrameSchema,
type WorkerRpcFrame,
workerRpcFrameSchema,
} from "./protocol/index.js";
// Server
export {
type ServeWorkerRpcOptions,
serveWorkerRpc,
type WorkerRpcHandler,
type WorkerRpcHandlers,
type WorkerRpcServer,
} from "./server/index.js";
// Streaming
export {
StreamDispatcher,
type StreamRequestOptions,
streamRequest,
} from "./streaming/index.js";
// Testing
export {
type ConformanceHarness,
createInProcessPortPair,
type InProcessPortPair,
type PortLike,
runWorkerRpcConformance,
} from "./testing/index.js";

View File

@@ -0,0 +1,28 @@
export {
type CancelFrame,
cancelFrameSchema,
type ErrorEnvelope,
errorEnvelopeSchema,
type GoodbyeFrame,
goodbyeFrameSchema,
type HelloFrame,
helloFrameSchema,
PROTOCOL_VERSION,
type ProgressFrame,
parseFrame,
progressFrameSchema,
type RequestFrame,
type ResponseFrame,
requestFrameSchema,
responseFrameSchema,
type StreamAckFrame,
type StreamChunkFrame,
type StreamEndFrame,
type StreamOpenFrame,
streamAckFrameSchema,
streamChunkFrameSchema,
streamEndFrameSchema,
streamOpenFrameSchema,
type WorkerRpcFrame,
workerRpcFrameSchema,
} from "./protocol.js";

View File

@@ -0,0 +1,93 @@
# `protocol`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the wire-format of every message that crosses a `MessagePort`
between an `api-host` and an isolated/sandboxed plugin worker. The
package exports a Zod-validated discriminated union (`WorkerRpcFrame`)
plus individual frame schemas so receivers fail loudly on malformed
input rather than silently corrupting state.
Every wire payload is JSON-clone-safe (no functions, classes, or
symbols at the top level) and carries an explicit `v` field so the host
and worker can negotiate version-skew at handshake time. The current
protocol version is `"1.0.0"`; future incompatible changes bump the
major.
## Features
- Ten frame kinds: `request`, `response` (ok/error), `cancel`,
`progress`, `stream-open`, `stream-chunk`, `stream-end` (ok/error),
`stream-ack`, `hello`, `goodbye`.
- Recursive Zod schema for the error envelope including bounded cause
chains (depth checked at the reconstruction layer, not the wire).
- `parseFrame(input)` performs full validation; the inferred return
type is the typed union usable in `switch (frame.kind)`.
- Branded correlation-id alignment with `@suaveplan/api-types`'s
`WorkerRpcCorrelationId` numeric brand.
## Usage: Basic
```ts
import { parseFrame, PROTOCOL_VERSION } from "@suaveplan/api-worker-rpc/protocol";
const frame = parseFrame(rawMessage);
if (frame.kind === "request") {
handleRequest(frame.id, frame.method, frame.params);
}
```
## Usage: Advanced
```ts
import {
type WorkerRpcFrame,
workerRpcFrameSchema,
requestFrameSchema,
} from "@suaveplan/api-worker-rpc/protocol";
// Pre-validate a specific kind without the full union:
const req = requestFrameSchema.parse(payload);
// Or guard via the union for exhaustive dispatch:
function dispatch(frame: WorkerRpcFrame) {
switch (frame.kind) {
case "request":
case "stream-open":
// ...
break;
case "response":
if (frame.ok) handleSuccess(frame.result);
else handleError(frame.error);
break;
// ...
}
}
```
## API Reference
- `PROTOCOL_VERSION: "1.0.0"` — the version literal embedded in every
outbound frame.
- `workerRpcFrameSchema` — the top-level discriminated union schema.
- `requestFrameSchema`, `responseFrameSchema`, etc. — individual
schemas for each frame kind.
- `parseFrame(input: unknown): WorkerRpcFrame` — validates and returns
the typed frame; throws `ZodError` on failure.
- `ErrorEnvelope` — interface for serialised errors carried over the
wire (used by `error-reconstruction`).
## Implementation Notes
- The error envelope schema is recursive (`z.lazy`) so cause chains
can nest arbitrarily — depth bounding is the receiver's job.
- The `v` field is a free-form string at the schema layer; clients
and servers reject mismatches via the `VERSION_MISMATCH` code so
the same frame can be parsed for diagnostic logging even when
it is too new to handle.
- `response` and `stream-end` are themselves discriminated by `ok`,
giving compilers exhaustive narrowing on success vs. error tails.

View File

@@ -0,0 +1,107 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
PROTOCOL_VERSION,
parseFrame,
workerRpcFrameSchema,
} from "./protocol.js";
describe("protocol", () => {
it("parses every documented frame kind", { timeout: 5000 }, () => {
const kinds = [
{
v: PROTOCOL_VERSION,
kind: "request",
id: 1,
method: "m",
params: [],
},
{
v: PROTOCOL_VERSION,
kind: "response",
ok: true,
id: 1,
result: 42,
},
{
v: PROTOCOL_VERSION,
kind: "response",
ok: false,
id: 1,
error: { code: "X.Y", name: "X", message: "boom" },
},
{ v: PROTOCOL_VERSION, kind: "cancel", id: 1 },
{ v: PROTOCOL_VERSION, kind: "progress", id: 1, chunk: "p" },
{
v: PROTOCOL_VERSION,
kind: "stream-open",
id: 1,
method: "s",
params: [],
},
{ v: PROTOCOL_VERSION, kind: "stream-chunk", id: 1, chunk: 1 },
{ v: PROTOCOL_VERSION, kind: "stream-end", ok: true, id: 1 },
{
v: PROTOCOL_VERSION,
kind: "stream-end",
ok: false,
id: 1,
error: { code: "X.Y", name: "X", message: "b" },
},
{ v: PROTOCOL_VERSION, kind: "stream-ack", id: 1, delta: 1 },
{
v: PROTOCOL_VERSION,
kind: "hello",
hostVersion: "1.0",
capabilities: ["db"],
},
{ v: PROTOCOL_VERSION, kind: "goodbye", reason: "shutdown" },
];
for (const frame of kinds) {
const parsed = parseFrame(frame);
expect(parsed.kind).toBe(frame.kind);
}
});
it("rejects malformed frames", { timeout: 5000 }, () => {
expect(() => parseFrame({})).toThrow();
expect(() =>
parseFrame({
v: "1.0.0",
kind: "request",
id: -1,
method: "m",
params: [],
})
).toThrow();
expect(() =>
parseFrame({
v: "1.0.0",
kind: "request",
id: 1,
method: "",
params: [],
})
).toThrow();
});
it("accepts nested cause in error envelope", { timeout: 5000 }, () => {
const env = {
v: PROTOCOL_VERSION,
kind: "response",
ok: false,
id: 1,
error: {
code: "A.B",
name: "A",
message: "outer",
cause: { code: "A.C", name: "A", message: "inner" },
},
};
const parsed = workerRpcFrameSchema.parse(env);
if (parsed.kind === "response" && !parsed.ok) {
expect(parsed.error.cause?.code).toBe("A.C");
} else {
throw new Error("expected error response");
}
});
});

View File

@@ -0,0 +1,154 @@
import z from "zod";
/**
* Wire protocol version embedded in every framed message. Receivers reject
* messages whose major version is incompatible with their own.
*/
export const PROTOCOL_VERSION = "1.0.0" as const;
/**
* Branded numeric correlation id. Aligns with `@suaveplan/api-types`'s
* `WorkerRpcCorrelationId` shape: a number with a phantom brand.
*/
export const correlationIdSchema = z
.number()
.int()
.nonnegative()
.finite() as unknown as z.ZodType<number>;
/**
* Serialised error envelope. Mirrors `@suaveplan/error`'s `WireError`
* shape closely enough for boundary use without re-exporting the schema.
*/
export interface ErrorEnvelope {
readonly code: string;
readonly name: string;
readonly message: string;
readonly details?: Readonly<Record<string, unknown>> | undefined;
readonly remoteStack?: string | undefined;
readonly cause?: ErrorEnvelope | undefined;
}
export const errorEnvelopeSchema: z.ZodType<ErrorEnvelope> = z.lazy(() =>
z.object({
code: z.string().min(1),
name: z.string().min(1),
message: z.string(),
details: z.record(z.string(), z.unknown()).optional(),
remoteStack: z.string().optional(),
cause: errorEnvelopeSchema.optional(),
})
);
const baseFrame = z.object({ v: z.string().min(1) });
export const requestFrameSchema = baseFrame.extend({
kind: z.literal("request"),
id: correlationIdSchema,
method: z.string().min(1),
params: z.array(z.unknown()),
});
export const responseFrameSchema = z.discriminatedUnion("ok", [
baseFrame.extend({
kind: z.literal("response"),
ok: z.literal(true),
id: correlationIdSchema,
result: z.unknown(),
}),
baseFrame.extend({
kind: z.literal("response"),
ok: z.literal(false),
id: correlationIdSchema,
error: errorEnvelopeSchema,
}),
]);
export const cancelFrameSchema = baseFrame.extend({
kind: z.literal("cancel"),
id: correlationIdSchema,
});
export const progressFrameSchema = baseFrame.extend({
kind: z.literal("progress"),
id: correlationIdSchema,
chunk: z.unknown(),
});
export const streamOpenFrameSchema = baseFrame.extend({
kind: z.literal("stream-open"),
id: correlationIdSchema,
method: z.string().min(1),
params: z.array(z.unknown()),
});
export const streamChunkFrameSchema = baseFrame.extend({
kind: z.literal("stream-chunk"),
id: correlationIdSchema,
chunk: z.unknown(),
});
export const streamEndFrameSchema = z.discriminatedUnion("ok", [
baseFrame.extend({
kind: z.literal("stream-end"),
ok: z.literal(true),
id: correlationIdSchema,
}),
baseFrame.extend({
kind: z.literal("stream-end"),
ok: z.literal(false),
id: correlationIdSchema,
error: errorEnvelopeSchema,
}),
]);
export const streamAckFrameSchema = baseFrame.extend({
kind: z.literal("stream-ack"),
id: correlationIdSchema,
delta: z.number().int().positive(),
});
export const helloFrameSchema = baseFrame.extend({
kind: z.literal("hello"),
hostVersion: z.string().min(1),
capabilities: z.array(z.string()),
});
export const goodbyeFrameSchema = baseFrame.extend({
kind: z.literal("goodbye"),
reason: z.string(),
});
export const workerRpcFrameSchema = z.union([
requestFrameSchema,
responseFrameSchema,
cancelFrameSchema,
progressFrameSchema,
streamOpenFrameSchema,
streamChunkFrameSchema,
streamEndFrameSchema,
streamAckFrameSchema,
helloFrameSchema,
goodbyeFrameSchema,
]);
export type RequestFrame = z.infer<typeof requestFrameSchema>;
export type ResponseFrame = z.infer<typeof responseFrameSchema>;
export type CancelFrame = z.infer<typeof cancelFrameSchema>;
export type ProgressFrame = z.infer<typeof progressFrameSchema>;
export type StreamOpenFrame = z.infer<typeof streamOpenFrameSchema>;
export type StreamChunkFrame = z.infer<typeof streamChunkFrameSchema>;
export type StreamEndFrame = z.infer<typeof streamEndFrameSchema>;
export type StreamAckFrame = z.infer<typeof streamAckFrameSchema>;
export type HelloFrame = z.infer<typeof helloFrameSchema>;
export type GoodbyeFrame = z.infer<typeof goodbyeFrameSchema>;
export type WorkerRpcFrame = z.infer<typeof workerRpcFrameSchema>;
/**
* Parse and validate an incoming wire frame. Returns a discriminated
* union; throws `ZodError` on malformed input (callers map to
* `WORKER_RPC.INVALID_FRAME`).
*/
export function parseFrame(input: unknown): WorkerRpcFrame {
return workerRpcFrameSchema.parse(input);
}

View File

@@ -0,0 +1,7 @@
export {
type ServeWorkerRpcOptions,
serveWorkerRpc,
type WorkerRpcHandler,
type WorkerRpcHandlers,
type WorkerRpcServer,
} from "./server.js";

View File

@@ -0,0 +1,101 @@
# `server`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Worker-side binding for the RPC protocol. `serveWorkerRpc(port, handlers)`
mounts a handlers object on a `MessagePort`, dispatching incoming `request`
and `stream-open` frames to the matching method, handling caller-side
cancel frames via per-call `AbortController`, and serialising every thrown
value via `errorToEnvelope` so the caller observes a typed `SuaveplanError`
on the other end.
Streaming methods are recognised structurally: a handler that returns an
`AsyncIterable` (or `async function*`) drives the `stream-chunk` /
`stream-end` cycle automatically. The server does not require the handler
author to know anything about the wire frames.
## Features
- Method dispatch by string lookup with a typed
`WorkerRpcHandler` signature.
- Automatic streaming when the handler returns an `AsyncIterable` —
each yielded value becomes a `stream-chunk` frame; completion is
`stream-end` ok; thrown errors become `stream-end` error.
- Per-call `AbortSignal` passed to every handler; `cancel` frames
trigger the controller.
- Malformed frames replied with a synthetic error envelope rather
than crashed listener.
- Version-mismatched frames replied with `WORKER_RPC.VERSION_MISMATCH`.
- Unknown methods replied with `WORKER_RPC.UNKNOWN_METHOD`.
- Non-iterable response from a stream handler becomes
`WORKER_RPC.HANDLER_THREW`.
- `onFrameOut` observer for telemetry.
- `close(reason)` triggers every in-flight handler's
`AbortController` and emits a final `goodbye` frame.
## Usage: Basic
```ts
import { serveWorkerRpc } from "@suaveplan/api-worker-rpc/server";
const server = serveWorkerRpc(workerPort, {
add: ([a, b]) => (a as number) + (b as number),
fetch: async ([url], signal) => {
const r = await fetch(url as string, { signal });
return r.text();
},
});
// ...
server.close("shutdown");
```
## Usage: Advanced
```ts
import { serveWorkerRpc } from "@suaveplan/api-worker-rpc/server";
const server = serveWorkerRpc(
workerPort,
{
list: async function* (_params, signal) {
for (let i = 0; i < 1_000_000; i++) {
if (signal.aborted) return;
yield { id: i };
}
},
compute: async ([input], signal) => {
const result = await expensive(input, signal);
return result;
},
},
{
onFrameOut: (frame) => telemetry.recordFrame(frame),
}
);
```
## API Reference
- `serveWorkerRpc(port, handlers, opts?): WorkerRpcServer`.
- `interface WorkerRpcHandlers { [method: string]: WorkerRpcHandler }`.
- `type WorkerRpcHandler = (params, signal) => unknown | Promise<unknown> | AsyncIterable<unknown>`.
- `interface ServeWorkerRpcOptions { onFrameOut?(frame): void }`.
- `interface WorkerRpcServer { close(reason?: string): void }`.
## Implementation Notes
- Streaming is detected structurally: any return value with a
`[Symbol.asyncIterator]` method is treated as a stream. Async
generators (`async function*`) work automatically; an object
implementing `AsyncIterable` also works.
- Cancel frames are routed by id; if the id has no in-flight
handler the cancel is dropped silently (the response or
stream-end already arrived).
- The server NEVER throws out of its message listener — every
protocol-level failure becomes an outbound error frame so the
caller's promise rejects properly.
- `close()` is idempotent.

View File

@@ -0,0 +1,430 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createRawWorkerRpcClient } from "../client/client.js";
import { PROTOCOL_VERSION } from "../protocol/protocol.js";
import { createInProcessPortPair } from "../testing/testing.js";
import { serveWorkerRpc } from "./server.js";
describe("server", () => {
it("dispatches request to handler", { timeout: 5000 }, async () => {
const ports = createInProcessPortPair();
const server = serveWorkerRpc(ports.serverPort, {
id: (p) => p[0],
});
const client = createRawWorkerRpcClient(ports.clientPort);
await expect(client.call("id", [99])).resolves.toBe(99);
client.close();
server.close();
ports.close();
});
it("unknown method returns UNKNOWN_METHOD", { timeout: 5000 }, async () => {
const ports = createInProcessPortPair();
const server = serveWorkerRpc(ports.serverPort, {});
const client = createRawWorkerRpcClient(ports.clientPort);
await expect(client.call("missing", [])).rejects.toMatchObject({
code: "WORKER_RPC.UNKNOWN_METHOD",
});
client.close();
server.close();
ports.close();
});
it("handler throw round-trips as REMOTE_THROW", {
timeout: 5000,
}, async () => {
const ports = createInProcessPortPair();
const server = serveWorkerRpc(ports.serverPort, {
x: () => {
throw new Error("nope");
},
});
const client = createRawWorkerRpcClient(ports.clientPort);
await expect(client.call("x", [])).rejects.toMatchObject({
code: "WORKER_RPC.REMOTE_THROW",
});
client.close();
server.close();
ports.close();
});
it("cancel triggers handler AbortSignal", { timeout: 5000 }, async () => {
const ports = createInProcessPortPair();
let observed = false;
const server = serveWorkerRpc(ports.serverPort, {
wait: (_p, signal) =>
new Promise((resolve) => {
signal.addEventListener("abort", () => {
observed = true;
resolve("aborted");
});
}),
});
const client = createRawWorkerRpcClient(ports.clientPort);
const ac = new AbortController();
const p = client.call("wait", [], { signal: ac.signal });
await new Promise((r) => setTimeout(r, 30));
ac.abort();
await expect(p).rejects.toMatchObject({ name: "AbortError" });
await new Promise((r) => setTimeout(r, 30));
expect(observed).toBe(true);
client.close();
server.close();
ports.close();
});
it("malformed inbound frame replies with error envelope", {
timeout: 5000,
}, async () => {
const ports = createInProcessPortPair();
const server = serveWorkerRpc(ports.serverPort, {});
const seen: unknown[] = [];
ports.clientPort.addEventListener("message", (e) => seen.push(e.data));
ports.serverPort.postMessage({ junk: true });
await new Promise((r) => setTimeout(r, 30));
// Server can't actually receive its own postMessage, so we need to
// simulate from the *client* side by posting through clientPort.
ports.clientPort.postMessage({ junk: true });
await new Promise((r) => setTimeout(r, 50));
const errFrame = seen.find(
(f): f is { kind: "response"; ok: false } =>
typeof f === "object" &&
f !== null &&
(f as { kind?: string }).kind === "response" &&
(f as { ok?: boolean }).ok === false
);
expect(errFrame).toBeDefined();
server.close();
ports.close();
});
it("version-mismatch frame replies with error envelope", {
timeout: 5000,
}, async () => {
const ports = createInProcessPortPair();
const server = serveWorkerRpc(ports.serverPort, {});
const seen: unknown[] = [];
ports.clientPort.addEventListener("message", (e) => seen.push(e.data));
ports.clientPort.postMessage({
v: "9.9.9",
kind: "request",
id: 1,
method: "x",
params: [],
});
await new Promise((r) => setTimeout(r, 50));
// The schema rejects v:"9.9.9" too (we no longer literal-match
// exact), so server replies with VERSION_MISMATCH or INVALID_FRAME.
const errFrame = seen.find(
(
f
): f is {
kind: "response";
ok: false;
error?: { code?: string };
} =>
typeof f === "object" &&
f !== null &&
(f as { kind?: string }).kind === "response" &&
(f as { ok?: boolean }).ok === false
);
expect(errFrame?.error?.code).toMatch(
/WORKER_RPC\.(VERSION_MISMATCH|INVALID_FRAME)/
);
server.close();
ports.close();
});
it("close drains in-flight handlers", { timeout: 5000 }, async () => {
const ports = createInProcessPortPair();
const server = serveWorkerRpc(ports.serverPort, {
wait: (_p, signal) =>
new Promise((resolve) => {
signal.addEventListener("abort", () => resolve("a"));
}),
});
const client = createRawWorkerRpcClient(ports.clientPort);
client.call("wait", []).catch(() => {});
await new Promise((r) => setTimeout(r, 30));
server.close("done");
await new Promise((r) => setTimeout(r, 30));
client.close();
ports.close();
});
it("stream method emits chunks then stream-end", {
timeout: 5000,
}, async () => {
const ports = createInProcessPortPair();
const server = serveWorkerRpc(ports.serverPort, {
counts: async function* () {
yield 1;
yield 2;
yield 3;
},
});
const seen: unknown[] = [];
ports.clientPort.addEventListener("message", (e) => seen.push(e.data));
ports.clientPort.postMessage({
v: PROTOCOL_VERSION,
kind: "stream-open",
id: 1,
method: "counts",
params: [],
});
await new Promise((r) => setTimeout(r, 80));
const chunks = seen.filter(
(f) =>
typeof f === "object" &&
f !== null &&
(f as { kind?: string }).kind === "stream-chunk"
);
expect(chunks).toHaveLength(3);
const end = seen.find(
(f) =>
typeof f === "object" &&
f !== null &&
(f as { kind?: string }).kind === "stream-end"
);
expect(end).toBeDefined();
server.close();
ports.close();
});
it("non-iterable stream method emits stream-end error", {
timeout: 5000,
}, async () => {
const ports = createInProcessPortPair();
const server = serveWorkerRpc(ports.serverPort, {
wrong: () => "not iterable",
});
const seen: unknown[] = [];
ports.clientPort.addEventListener("message", (e) => seen.push(e.data));
ports.clientPort.postMessage({
v: PROTOCOL_VERSION,
kind: "stream-open",
id: 2,
method: "wrong",
params: [],
});
await new Promise((r) => setTimeout(r, 50));
const end = seen.find(
(
f
): f is {
kind: "stream-end";
ok: false;
error: { code: string };
} =>
typeof f === "object" &&
f !== null &&
(f as { kind?: string }).kind === "stream-end" &&
(f as { ok?: boolean }).ok === false
);
expect(end?.error.code).toBe("WORKER_RPC.HANDLER_THREW");
server.close();
ports.close();
});
it("unknown stream method emits stream-end error", {
timeout: 5000,
}, async () => {
const ports = createInProcessPortPair();
const server = serveWorkerRpc(ports.serverPort, {});
const seen: unknown[] = [];
ports.clientPort.addEventListener("message", (e) => seen.push(e.data));
ports.clientPort.postMessage({
v: PROTOCOL_VERSION,
kind: "stream-open",
id: 3,
method: "missing",
params: [],
});
await new Promise((r) => setTimeout(r, 50));
const end = seen.find(
(
f
): f is {
kind: "stream-end";
ok: false;
error: { code: string };
} =>
typeof f === "object" &&
f !== null &&
(f as { kind?: string }).kind === "stream-end" &&
(f as { ok?: boolean }).ok === false
);
expect(end?.error.code).toBe("WORKER_RPC.UNKNOWN_METHOD");
server.close();
ports.close();
});
it("stream cancellation propagates to handler signal", {
timeout: 5000,
}, async () => {
const ports = createInProcessPortPair();
let aborted = false;
const server = serveWorkerRpc(ports.serverPort, {
slow: async function* (_p, signal) {
yield 1;
await new Promise((r) => setTimeout(r, 100));
if (signal.aborted) aborted = true;
yield 2;
},
});
ports.clientPort.postMessage({
v: PROTOCOL_VERSION,
kind: "stream-open",
id: 4,
method: "slow",
params: [],
});
await new Promise((r) => setTimeout(r, 30));
ports.clientPort.postMessage({
v: PROTOCOL_VERSION,
kind: "cancel",
id: 4,
});
await new Promise((r) => setTimeout(r, 150));
expect(aborted).toBe(true);
server.close();
ports.close();
});
it("server ignores inbound response/progress/etc", {
timeout: 5000,
}, async () => {
const ports = createInProcessPortPair();
const server = serveWorkerRpc(ports.serverPort, {});
ports.clientPort.postMessage({
v: PROTOCOL_VERSION,
kind: "response",
ok: true,
id: 1,
result: 1,
});
ports.clientPort.postMessage({
v: PROTOCOL_VERSION,
kind: "stream-chunk",
id: 1,
chunk: "x",
});
ports.clientPort.postMessage({
v: PROTOCOL_VERSION,
kind: "stream-ack",
id: 1,
delta: 1,
});
ports.clientPort.postMessage({
v: PROTOCOL_VERSION,
kind: "progress",
id: 1,
chunk: "x",
});
ports.clientPort.postMessage({
v: PROTOCOL_VERSION,
kind: "stream-end",
ok: true,
id: 1,
});
ports.clientPort.postMessage({
v: PROTOCOL_VERSION,
kind: "hello",
hostVersion: "1.0",
capabilities: [],
});
ports.clientPort.postMessage({
v: PROTOCOL_VERSION,
kind: "goodbye",
reason: "bye",
});
await new Promise((r) => setTimeout(r, 30));
server.close();
ports.close();
});
it("onFrameOut option observes every outbound", {
timeout: 5000,
}, async () => {
const ports = createInProcessPortPair();
const out: unknown[] = [];
const server = serveWorkerRpc(
ports.serverPort,
{
ping: () => "pong",
},
{ onFrameOut: (f) => out.push(f) }
);
const client = createRawWorkerRpcClient(ports.clientPort);
await client.call("ping", []);
expect(out.length).toBeGreaterThan(0);
client.close();
server.close();
ports.close();
});
it("post-close listener guard handles late inbound", {
timeout: 5000,
}, async () => {
// Race: post several messages then close in the same tick. With
// microtask scheduling, the async listener may begin processing a
// queued message after `closed = true` is set but before the
// removeEventListener has fully taken effect.
const ports = createInProcessPortPair();
const server = serveWorkerRpc(ports.serverPort, {
x: () => 1,
});
for (let i = 0; i < 10; i++) {
ports.clientPort.postMessage({
v: PROTOCOL_VERSION,
kind: "request",
id: i + 1,
method: "x",
params: [],
});
}
server.close();
await new Promise((r) => setTimeout(r, 30));
ports.close();
});
it("version-mismatch on hello frame replies with id 0", {
timeout: 5000,
}, async () => {
const ports = createInProcessPortPair();
const server = serveWorkerRpc(ports.serverPort, {});
const seen: unknown[] = [];
ports.clientPort.addEventListener("message", (e) => seen.push(e.data));
ports.clientPort.postMessage({
v: "9.9.9",
kind: "hello",
hostVersion: "1.0",
capabilities: [],
});
await new Promise((r) => setTimeout(r, 30));
const errFrame = seen.find(
(
f
): f is {
kind: "response";
ok: false;
id: number;
error: { code: string };
} =>
typeof f === "object" &&
f !== null &&
(f as { kind?: string }).kind === "response" &&
(f as { ok?: boolean }).ok === false
);
expect(errFrame?.id).toBe(0);
server.close();
ports.close();
});
it("close is idempotent", { timeout: 5000 }, () => {
const ports = createInProcessPortPair();
const server = serveWorkerRpc(ports.serverPort, {});
server.close();
server.close();
ports.close();
});
});

View File

@@ -0,0 +1,242 @@
import { createRemoteAbort } from "../abort-forwarding/abort-forwarding.js";
import { WorkerRpcError } from "../error-codes/error-codes.js";
import { errorToEnvelope } from "../error-reconstruction/error-reconstruction.js";
import {
PROTOCOL_VERSION,
parseFrame,
type WorkerRpcFrame,
} from "../protocol/protocol.js";
import type { PortLike } from "../testing/testing.js";
/**
* Server-side method handler. Receives positional `params`, an
* `AbortSignal` that fires on caller-side cancel, and may return
* a value, a Promise, or an `AsyncIterable` (for streaming).
*/
export type WorkerRpcHandler = (
params: ReadonlyArray<unknown>,
signal: AbortSignal
) => unknown | Promise<unknown> | AsyncIterable<unknown>;
export interface WorkerRpcHandlers {
readonly [method: string]: WorkerRpcHandler;
}
export interface WorkerRpcServer {
close(reason?: string): void;
}
export interface ServeWorkerRpcOptions {
/** Surfaces handler-internal frames (e.g. progress) to observers. */
readonly onFrameOut?: (frame: WorkerRpcFrame) => void;
}
/**
* Mount a handlers object on a `MessagePort`. Returns a disposer.
*
* For each incoming `request` frame:
* 1. Look up `handlers[method]`; unknown methods → `UNKNOWN_METHOD`.
* 2. Build a fresh {@link AbortController} for the handler.
* 3. Invoke the handler; await the result.
* 4. Send a `response` frame, ok-or-error.
*
* Incoming `stream-open` is dispatched identically except the handler
* is expected to return `AsyncIterable<unknown>`; each yielded value
* becomes a `stream-chunk` frame; completion is a `stream-end`.
*
* Incoming `cancel` triggers the per-call `AbortController`.
*/
export function serveWorkerRpc(
port: PortLike,
handlers: WorkerRpcHandlers,
opts: ServeWorkerRpcOptions = {}
): WorkerRpcServer {
const inflight = new Map<number, { abort: () => void }>();
let closed = false;
const send = (frame: WorkerRpcFrame): void => {
if (closed) return;
port.postMessage(frame);
opts.onFrameOut?.(frame);
};
const listener = async (event: { data: unknown }): Promise<void> => {
let frame: WorkerRpcFrame;
try {
frame = parseFrame(event.data);
} catch (cause) {
// We have no id to bind this to — best effort log only.
const env = errorToEnvelope(
new WorkerRpcError(
"WORKER_RPC.INVALID_FRAME",
"received malformed RPC frame",
{ cause }
)
);
send({
v: PROTOCOL_VERSION,
kind: "response",
ok: false,
id: 0,
error: env,
});
return;
}
if (frame.v !== PROTOCOL_VERSION) {
send({
v: PROTOCOL_VERSION,
kind: "response",
ok: false,
id: "id" in frame ? (frame.id as number) : 0,
error: errorToEnvelope(
new WorkerRpcError(
"WORKER_RPC.VERSION_MISMATCH",
`expected protocol ${PROTOCOL_VERSION}, got ${frame.v}`
)
),
});
return;
}
if (frame.kind === "cancel") {
inflight.get(frame.id)?.abort();
inflight.delete(frame.id);
return;
}
if (frame.kind === "request") {
await dispatchRequest(frame.id, frame.method, frame.params);
return;
}
if (frame.kind === "stream-open") {
await dispatchStream(frame.id, frame.method, frame.params);
return;
}
// hello / goodbye / stream-ack / progress / stream-chunk / stream-end /
// response: server side ignores (it never originates inbound responses).
};
const dispatchRequest = async (
id: number,
method: string,
params: ReadonlyArray<unknown>
): Promise<void> => {
const handler = handlers[method];
if (handler === undefined) {
send({
v: PROTOCOL_VERSION,
kind: "response",
ok: false,
id,
error: errorToEnvelope(
new WorkerRpcError(
"WORKER_RPC.UNKNOWN_METHOD",
`no handler for method ${method}`
)
),
});
return;
}
const remote = createRemoteAbort();
inflight.set(id, { abort: () => remote.trigger() });
try {
const result = await handler(params, remote.signal);
inflight.delete(id);
send({
v: PROTOCOL_VERSION,
kind: "response",
ok: true,
id,
result,
});
} catch (err) {
inflight.delete(id);
send({
v: PROTOCOL_VERSION,
kind: "response",
ok: false,
id,
error: errorToEnvelope(err),
});
}
};
const dispatchStream = async (
id: number,
method: string,
params: ReadonlyArray<unknown>
): Promise<void> => {
const handler = handlers[method];
if (handler === undefined) {
send({
v: PROTOCOL_VERSION,
kind: "stream-end",
ok: false,
id,
error: errorToEnvelope(
new WorkerRpcError(
"WORKER_RPC.UNKNOWN_METHOD",
`no handler for stream method ${method}`
)
),
});
return;
}
const remote = createRemoteAbort();
inflight.set(id, { abort: () => remote.trigger() });
try {
const result = await handler(params, remote.signal);
const iterable = result as AsyncIterable<unknown> | null;
if (
iterable === null ||
typeof iterable !== "object" ||
typeof (iterable as { [Symbol.asyncIterator]?: unknown })[
Symbol.asyncIterator
] !== "function"
) {
throw new WorkerRpcError(
"WORKER_RPC.HANDLER_THREW",
`stream method ${method} did not return AsyncIterable`
);
}
for await (const chunk of iterable) {
if (remote.signal.aborted) break;
send({
v: PROTOCOL_VERSION,
kind: "stream-chunk",
id,
chunk,
});
}
inflight.delete(id);
send({ v: PROTOCOL_VERSION, kind: "stream-end", ok: true, id });
} catch (err) {
inflight.delete(id);
send({
v: PROTOCOL_VERSION,
kind: "stream-end",
ok: false,
id,
error: errorToEnvelope(err),
});
}
};
port.addEventListener(
"message",
listener as (e: { data: unknown }) => void
);
port.start?.();
return {
close(reason = "closed"): void {
if (closed) return;
closed = true;
for (const entry of inflight.values()) entry.abort();
inflight.clear();
port.removeEventListener(
"message",
listener as (e: { data: unknown }) => void
);
send({ v: PROTOCOL_VERSION, kind: "goodbye", reason });
},
};
}

View File

@@ -0,0 +1,5 @@
export {
StreamDispatcher,
type StreamRequestOptions,
streamRequest,
} from "./streaming.js";

View File

@@ -0,0 +1,101 @@
# `streaming`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Caller-side primitives for consuming streaming RPC methods. The server
publishes a `stream-chunk` frame per yielded value plus a final
`stream-end`; on the caller side, `streamRequest(client, dispatcher,
method, params, opts)` returns an `AsyncIterable` whose
`Symbol.asyncIterator` pulls chunks lazily from a bounded internal
buffer. Consumer-side `for await` semantics drive the protocol: each
`next()` call pulls one buffered chunk, and a consumer-side `return()`
(triggered by `break` out of `for await`) sends a `cancel` frame so the
remote producer stops generating.
The `StreamDispatcher` is the small registry that routes inbound stream
frames to the right caller; the raw `WorkerRpcClient` accepts the
dispatcher's `dispatch` method as its `onStreamFrame` option.
## Features
- `AsyncIterable<unknown>` return type — composes with `for await` and
standard async-iteration helpers.
- Bounded backpressure via `BoundedQueue` — fail/drop-oldest/drop-newest
policies share the same primitive as `client`'s outbound queue.
- `AbortSignal` cancels the iterator with a name-`"AbortError"` throw;
pre-aborted signals cancel synchronously on first `next()`.
- Stream-end error frames reconstruct the original throw on the caller
via `envelopeToError`.
- Consumer-side `return()` (e.g. `break` out of `for await`) sends a
cancel frame so the remote producer can release resources.
- `StreamDispatcher` is a trivial id-keyed routing table.
## Usage: Basic
```ts
import { createRawWorkerRpcClient } from "@suaveplan/api-worker-rpc/client";
import { StreamDispatcher, streamRequest } from "@suaveplan/api-worker-rpc/streaming";
const dispatcher = new StreamDispatcher();
const client = createRawWorkerRpcClient(port, {
onStreamFrame: dispatcher.dispatch,
});
for await (const row of streamRequest(client, dispatcher, "rows", [])) {
process(row);
}
```
## Usage: Advanced
```ts
import { StreamDispatcher, streamRequest } from "@suaveplan/api-worker-rpc/streaming";
const ac = new AbortController();
setTimeout(() => ac.abort(), 5_000);
try {
for await (const chunk of streamRequest(client, dispatcher, "tail", [logId], {
signal: ac.signal,
queue: { highWaterMark: 256, mode: "drop-oldest" },
})) {
ui.append(chunk);
}
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
// intentional cancel
} else {
throw err;
}
}
```
## API Reference
- `streamRequest(client, dispatcher, method, params, opts?): AsyncIterable<unknown>`.
- `interface StreamRequestOptions { signal?: AbortSignal; queue?: BackpressurePolicy }`.
- `class StreamDispatcher`:
- `dispatch: (frame) => void` — pass to the client's `onStreamFrame`.
- `register(id, handler): void`
- `unregister(id): void`
- `clear(): void`
## Implementation Notes
- The internal buffer is a `BoundedQueue<unknown>`; the default
policy caps it at 64 chunks. Switch to `drop-oldest` for log-tail
workloads where the latest matters more than completeness.
- `next()` resolves either from the buffer (synchronously, via
microtask) or when the next `stream-chunk` arrives. The `tryFlush`
helper is the single state-transition point that satisfies a
pending pull.
- Iterator `return()` sends a cancel frame, marks the stream ended,
flushes any pending pull as `{ done: true }`, and unregisters from
the dispatcher.
- `AbortSignal` aborts behave identically to cancel except the
thrown error has `name === "AbortError"` to match the standard
cancellation contract.

View File

@@ -0,0 +1,197 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createRawWorkerRpcClient } from "../client/client.js";
import { serveWorkerRpc } from "../server/server.js";
import { createInProcessPortPair } from "../testing/testing.js";
import { StreamDispatcher, streamRequest } from "./streaming.js";
function setup() {
const ports = createInProcessPortPair();
const server = serveWorkerRpc(ports.serverPort, {
counts: async function* () {
yield 1;
yield 2;
yield 3;
},
big: async function* () {
for (let i = 0; i < 100; i++) yield i;
},
boom: async function* () {
yield 1;
throw new Error("explode");
},
slow: async function* (_p, signal) {
yield 1;
await new Promise((r) => setTimeout(r, 100));
if (signal.aborted) return;
yield 2;
},
});
const dispatcher = new StreamDispatcher();
const client = createRawWorkerRpcClient(ports.clientPort, {
onStreamFrame: dispatcher.dispatch,
});
return { ports, server, client, dispatcher };
}
describe("streaming", () => {
it("AsyncIterable yields all chunks", { timeout: 5000 }, async () => {
const { ports, server, client, dispatcher } = setup();
const it = streamRequest(client, dispatcher, "counts", []);
const out: unknown[] = [];
for await (const c of it) out.push(c);
expect(out).toEqual([1, 2, 3]);
client.close();
server.close();
ports.close();
});
it("large stream transfers in chunks", { timeout: 5000 }, async () => {
const { ports, server, client, dispatcher } = setup();
const it = streamRequest(client, dispatcher, "big", [], {
queue: { highWaterMark: 256, mode: "fail" },
});
const out: unknown[] = [];
for await (const c of it) out.push(c);
expect(out).toHaveLength(100);
expect(out[0]).toBe(0);
expect(out[99]).toBe(99);
client.close();
server.close();
ports.close();
});
it("stream-end error rejects iterator", { timeout: 5000 }, async () => {
const { ports, server, client, dispatcher } = setup();
const it = streamRequest(client, dispatcher, "boom", []);
const out: unknown[] = [];
let threw: Error | undefined;
try {
for await (const c of it) out.push(c);
} catch (e) {
threw = e as Error;
}
expect(out).toEqual([1]);
expect(threw?.message).toContain("explode");
client.close();
server.close();
ports.close();
});
it("consumer return cancels remote producer", {
timeout: 5000,
}, async () => {
const { ports, server, client, dispatcher } = setup();
const it = streamRequest(client, dispatcher, "slow", [])[
Symbol.asyncIterator
]();
const first = await it.next();
expect(first.value).toBe(1);
const done = await it.return?.();
expect(done?.done).toBe(true);
await new Promise((r) => setTimeout(r, 150));
client.close();
server.close();
ports.close();
});
it("AbortSignal cancels mid-stream", { timeout: 5000 }, async () => {
const { ports, server, client, dispatcher } = setup();
const ac = new AbortController();
const it = streamRequest(client, dispatcher, "slow", [], {
signal: ac.signal,
});
const iter = it[Symbol.asyncIterator]();
const first = await iter.next();
expect(first.value).toBe(1);
ac.abort();
let captured: Error | undefined;
try {
await iter.next();
} catch (e) {
captured = e as Error;
}
expect(captured?.name).toBe("AbortError");
client.close();
server.close();
ports.close();
});
it("pre-aborted signal cancels immediately", {
timeout: 5000,
}, async () => {
const { ports, server, client, dispatcher } = setup();
const ac = new AbortController();
ac.abort();
const it = streamRequest(client, dispatcher, "counts", [], {
signal: ac.signal,
});
let threw: Error | undefined;
try {
for await (const _c of it) {
// drain
}
} catch (e) {
threw = e as Error;
}
expect(threw?.name).toBe("AbortError");
client.close();
server.close();
ports.close();
});
it("non-chunk/end frames routed to a stream handler are ignored", {
timeout: 5000,
}, async () => {
// Inject an unrelated frame after stream-end to exercise the
// fall-through branch in the dispatcher callback.
const { ports, server, client, dispatcher } = setup();
const it = streamRequest(client, dispatcher, "counts", []);
// Pre-emptively dispatch a stream-ack frame for id=1 (will be the
// first allocated id since this is a fresh client).
dispatcher.dispatch({
v: "1.0.0",
kind: "stream-ack",
id: 1,
delta: 1,
});
const out: unknown[] = [];
for await (const c of it) out.push(c);
expect(out).toEqual([1, 2, 3]);
client.close();
server.close();
ports.close();
});
it("StreamDispatcher routes by id", { timeout: 5000 }, () => {
const d = new StreamDispatcher();
const seen: unknown[] = [];
d.register(7, (f) => seen.push(f));
d.dispatch({
v: "1.0.0",
kind: "stream-chunk",
id: 7,
chunk: "a",
});
d.dispatch({
v: "1.0.0",
kind: "stream-chunk",
id: 8,
chunk: "b",
});
expect(seen).toHaveLength(1);
d.unregister(7);
d.clear();
});
it("StreamDispatcher ignores frames without numeric id", {
timeout: 5000,
}, () => {
const d = new StreamDispatcher();
d.dispatch({
v: "1.0.0",
kind: "hello",
hostVersion: "1.0",
capabilities: [],
});
});
});

View File

@@ -0,0 +1,164 @@
import {
type BackpressurePolicy,
BoundedQueue,
DEFAULT_BACKPRESSURE,
} from "../backpressure/backpressure.js";
import type { WorkerRpcClient } from "../client/client.js";
import { envelopeToError } from "../error-reconstruction/error-reconstruction.js";
import { PROTOCOL_VERSION, type WorkerRpcFrame } from "../protocol/protocol.js";
export interface StreamRequestOptions {
readonly signal?: AbortSignal;
readonly queue?: BackpressurePolicy;
}
/**
* Issue a streaming request. Returns an `AsyncIterable<chunk>` that pulls
* `stream-chunk` frames as they arrive; on `stream-end` ok the iterator
* completes; on `stream-end` error it throws the reconstructed error.
*
* Backpressure is governed by {@link BoundedQueue}. The default policy
* caps the buffered chunks at 64; switch to `drop-oldest`/`drop-newest`
* for lossy consumers.
*
* Note: this helper attaches its own listener on the *raw* client, which
* routes inbound stream frames via the `onStreamFrame` option. Callers
* who want streaming MUST construct the client with that option wired to
* a dispatcher; the helper {@link createStreamingClient} below does this
* automatically.
*/
export function streamRequest(
client: WorkerRpcClient,
dispatcher: StreamDispatcher,
method: string,
params: ReadonlyArray<unknown>,
opts: StreamRequestOptions = {}
): AsyncIterable<unknown> {
const id = client.nextId();
const queue = new BoundedQueue<unknown>(opts.queue ?? DEFAULT_BACKPRESSURE);
let pendingResolver: ((value: IteratorResult<unknown>) => void) | undefined;
let pendingRejecter: ((reason: Error) => void) | undefined;
let endError: Error | undefined;
let ended = false;
const tryFlush = (): void => {
if (pendingResolver === undefined) return;
const next = queue.dequeue();
if (next !== undefined) {
const r = pendingResolver;
pendingResolver = undefined;
pendingRejecter = undefined;
r({ value: next, done: false });
return;
}
if (ended) {
const r = pendingResolver;
pendingResolver = undefined;
if (endError !== undefined) {
const rej = pendingRejecter;
pendingRejecter = undefined;
rej?.(endError);
return;
}
r({ value: undefined, done: true });
}
};
dispatcher.register(id, (frame: WorkerRpcFrame) => {
if (frame.kind === "stream-chunk") {
queue.enqueue(frame.chunk);
tryFlush();
return;
}
if (frame.kind === "stream-end") {
ended = true;
if (!frame.ok) {
endError = envelopeToError(frame.error);
}
tryFlush();
dispatcher.unregister(id);
}
});
if (opts.signal !== undefined) {
const onAbort = (): void => {
ended = true;
endError = new Error("stream cancelled");
endError.name = "AbortError";
client.send({ v: PROTOCOL_VERSION, kind: "cancel", id });
tryFlush();
dispatcher.unregister(id);
};
if (opts.signal.aborted) onAbort();
else opts.signal.addEventListener("abort", onAbort, { once: true });
}
client.send({
v: PROTOCOL_VERSION,
kind: "stream-open",
id,
method,
params: [...params],
});
return {
[Symbol.asyncIterator](): AsyncIterator<unknown> {
return {
next(): Promise<IteratorResult<unknown>> {
return new Promise<IteratorResult<unknown>>(
(resolve, reject) => {
pendingResolver = resolve;
pendingRejecter = reject;
tryFlush();
}
);
},
return(): Promise<IteratorResult<unknown>> {
ended = true;
client.send({
v: PROTOCOL_VERSION,
kind: "cancel",
id,
});
dispatcher.unregister(id);
return Promise.resolve({ value: undefined, done: true });
},
};
},
};
}
/**
* Multiplexer for stream frames. Pass its `dispatch` method into the
* raw client's `onStreamFrame` option; the dispatcher routes inbound
* stream-chunk / stream-end frames back to the correct {@link streamRequest}
* caller.
*/
export class StreamDispatcher {
private readonly handlers = new Map<
number,
(frame: WorkerRpcFrame) => void
>();
public dispatch = (frame: WorkerRpcFrame): void => {
if ("id" in frame && typeof frame.id === "number") {
const h = this.handlers.get(frame.id);
h?.(frame);
}
};
public register(
id: number,
handler: (frame: WorkerRpcFrame) => void
): void {
this.handlers.set(id, handler);
}
public unregister(id: number): void {
this.handlers.delete(id);
}
public clear(): void {
this.handlers.clear();
}
}

View File

@@ -0,0 +1,75 @@
# `conformance`
## Purpose
Builds a paired client/server harness over an in-process `MessageChannel`
for conformance tests. The harness is intended for use by every Stage-3
worker-proxy package (`api-db-worker-proxy`, `api-events-worker-proxy`,
`api-secrets-worker-proxy`, `api-jobs-worker-proxy`,
`api-logger-worker-proxy`, and any successor) so each proxy can verify
its capability semantics over a real `MessagePort` round-trip without
re-implementing setup boilerplate.
The harness exposes the live `client`, `server`, and `ports` handles
plus a `dispose()` that tears them all down in the correct order
(client first, then server, then port). Tests typically wrap it in a
`finally`-style block or per-test setup/teardown.
## Features
- Single-call paired construction: pass a handlers factory, get a
fully wired client/server/ports triple back.
- Idempotent `dispose()` — safe to call inside a test cleanup hook
even if the test already closed parts of the harness.
- Exposes the underlying `RawWorkerRpcClient` so streaming tests can
attach a `StreamDispatcher` via the client's `onStreamFrame`
option.
## Usage: Basic
```ts
import { runWorkerRpcConformance } from "@suaveplan/api-worker-rpc/testing";
const h = runWorkerRpcConformance(() => ({
ping: () => "pong",
}));
await expect(h.client.call("ping", [])).resolves.toBe("pong");
h.dispose();
```
## Usage: Advanced
```ts
import { StreamDispatcher, streamRequest } from "@suaveplan/api-worker-rpc/streaming";
import { runWorkerRpcConformance } from "@suaveplan/api-worker-rpc/testing";
const h = runWorkerRpcConformance(() => ({
rows: async function* () {
for (let i = 0; i < 5; i++) yield i;
},
}));
const dispatcher = new StreamDispatcher();
// Re-wire onStreamFrame on the underlying client — note: in practice you'd
// construct the client with this option from the start. The harness ships
// without it so test authors can choose whether streaming is exercised.
h.dispose();
```
## API Reference
- `interface ConformanceHarness { client, server, ports, dispose }`.
- `runWorkerRpcConformance(factory: () => WorkerRpcHandlers): ConformanceHarness`.
## Implementation Notes
- The default harness does NOT install a `StreamDispatcher` — streaming
tests construct their own client with the dispatcher's `dispatch`
method wired into `onStreamFrame`, then use the harness's ports
directly. This keeps the non-streaming path one-line.
- The `client` field is a `RawWorkerRpcClient`, not the typed proxy —
conformance tests address methods by string and inspect raw frames
rather than relying on TypeScript inference.
- `dispose()` tolerates partial-prior-closure so per-test `afterEach`
can call it unconditionally.

View File

@@ -0,0 +1,36 @@
import { createRawWorkerRpcClient } from "../client/client.js";
import { serveWorkerRpc } from "../server/server.js";
import { createInProcessPortPair, type InProcessPortPair } from "./testing.js";
/**
* Spin up a paired client/server harness for conformance tests. Returns
* the live handles plus a `dispose` that tears everything down — exists
* so downstream worker-proxy packages (db, events, secrets, jobs,
* logger) can plug their handler maps into a real `MessagePort` round
* trip without re-implementing setup.
*/
export interface ConformanceHarness {
readonly client: ReturnType<typeof createRawWorkerRpcClient>;
readonly server: ReturnType<typeof serveWorkerRpc>;
readonly ports: InProcessPortPair;
dispose(): void;
}
export function runWorkerRpcConformance(
factory: () => Parameters<typeof serveWorkerRpc>[1]
): ConformanceHarness {
const ports = createInProcessPortPair();
const handlers = factory();
const server = serveWorkerRpc(ports.serverPort, handlers);
const client = createRawWorkerRpcClient(ports.clientPort);
return {
client,
server,
ports,
dispose: () => {
client.close();
server.close();
ports.close();
},
};
}

View File

@@ -0,0 +1,9 @@
export {
type ConformanceHarness,
runWorkerRpcConformance,
} from "./conformance.js";
export {
createInProcessPortPair,
type InProcessPortPair,
type PortLike,
} from "./testing.js";

View File

@@ -0,0 +1,87 @@
# `testing`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Reusable conformance harness and an in-process paired-port factory used
by every Stage-3 worker-proxy package's test suite. The harness wraps
`MessageChannel` (available natively in Node 18+, Bun, and browsers) so
suites do not import platform globals directly; the conformance helper
spins up a `serveWorkerRpc` on one end and a `createRawWorkerRpcClient`
on the other in a single call.
This module is the only place outside the test suites themselves that
calls `MessageChannel`; isolating it here keeps the protocol implementation
strictly universal (it depends only on the structural `PortLike`
interface).
## Features
- Structural `PortLike` interface independent of any platform global.
- `createInProcessPortPair()` returns a bidirectional pair of
`PortLike` ports backed by a single `MessageChannel`.
- `runWorkerRpcConformance(factory)` wires a fresh server (built from
the factory's handler map) to a fresh client; returns the handles
plus a `dispose()` for teardown.
- `WeakMap`-backed listener tracking so `removeEventListener(message,
listener)` correctly detaches the wrapper registered by the
matching `addEventListener` call.
## Usage: Basic
```ts
import { runWorkerRpcConformance } from "@suaveplan/api-worker-rpc/testing";
const h = runWorkerRpcConformance(() => ({
echo: ([v]) => v,
}));
const result = await h.client.call("echo", [42]);
h.dispose();
```
## Usage: Advanced
```ts
import {
createInProcessPortPair,
type PortLike,
} from "@suaveplan/api-worker-rpc/testing";
function withPortPair<T>(test: (client: PortLike, server: PortLike) => Promise<T>) {
const pair = createInProcessPortPair();
return test(pair.clientPort, pair.serverPort).finally(() => pair.close());
}
await withPortPair(async (client, server) => {
server.addEventListener("message", (e) => server.postMessage(e.data));
const replies = [] as unknown[];
client.addEventListener("message", (e) => replies.push(e.data));
client.postMessage("hi");
// ...
});
```
## API Reference
- `interface PortLike` — `postMessage`, `addEventListener`,
`removeEventListener`, optional `start`, optional `close`.
- `interface InProcessPortPair { clientPort, serverPort, close }`.
- `createInProcessPortPair(): InProcessPortPair`.
- `interface ConformanceHarness { client, server, ports, dispose }`.
- `runWorkerRpcConformance(factory): ConformanceHarness`.
## Implementation Notes
- The wrapping `addEventListener` registers an internal closure with
the underlying `MessagePort` and stores the closure in a `WeakMap`
keyed by the user's listener. `removeEventListener` looks up the
closure by user-listener identity so detachment works correctly.
- The `start?.()` shim handles ports that need explicit
`start()` (Web `MessagePort`) AND ports that auto-start (Node's
worker_threads variant emits `"message"` immediately).
- Closing the pair closes both ends; tests should always call
`pair.close()` to release the channel descriptors.

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { serveWorkerRpc } from "../server/server.js";
import { runWorkerRpcConformance } from "./conformance.js";
import { createInProcessPortPair } from "./testing.js";
describe("testing/port-pair", () => {
it("ports are bidirectional", { timeout: 5000 }, async () => {
const pair = createInProcessPortPair();
const received: unknown[] = [];
const l = (e: { data: unknown }): void => {
received.push(e.data);
};
pair.serverPort.addEventListener("message", l);
pair.clientPort.postMessage("hello");
await new Promise((r) => setTimeout(r, 10));
expect(received).toEqual(["hello"]);
pair.serverPort.removeEventListener("message", l);
pair.close();
});
it("removeEventListener detaches the wrapper", {
timeout: 5000,
}, async () => {
const pair = createInProcessPortPair();
const received: unknown[] = [];
const l = (e: { data: unknown }): void => {
received.push(e.data);
};
pair.serverPort.addEventListener("message", l);
pair.serverPort.removeEventListener("message", l);
pair.clientPort.postMessage("x");
await new Promise((r) => setTimeout(r, 10));
expect(received).toEqual([]);
pair.close();
});
it("removeEventListener tolerates unknown listener", {
timeout: 5000,
}, () => {
const pair = createInProcessPortPair();
const l = (): void => {};
pair.serverPort.removeEventListener("message", l);
pair.close();
});
it("start() is exposed and callable", { timeout: 5000 }, () => {
const pair = createInProcessPortPair();
pair.serverPort.start?.();
pair.clientPort.start?.();
pair.close();
});
it("port.close() forwards to underlying port", { timeout: 5000 }, () => {
const pair = createInProcessPortPair();
pair.serverPort.close?.();
pair.clientPort.close?.();
});
});
describe("testing/conformance harness", () => {
it("runs a paired round-trip", { timeout: 5000 }, async () => {
const h = runWorkerRpcConformance(() => ({
echo: (params: ReadonlyArray<unknown>) => params[0],
}));
const result = await h.client.call("echo", [42]);
expect(result).toBe(42);
h.dispose();
});
it("dispose tears down ports + listeners", { timeout: 5000 }, () => {
const h = runWorkerRpcConformance(() => ({}));
h.dispose();
// close is idempotent
h.client.close();
h.server.close();
});
it("explicit serveWorkerRpc + ports also works", {
timeout: 5000,
}, async () => {
const ports = createInProcessPortPair();
const server = serveWorkerRpc(ports.serverPort, {
ping: () => "pong",
});
const h = runWorkerRpcConformance(() => ({}));
h.dispose();
server.close();
ports.close();
});
});

View File

@@ -0,0 +1,77 @@
/**
* Minimal `MessagePort`-shaped surface this package needs. Aligns with
* the Web `MessagePort` AND Node's `worker_threads` `MessagePort` (both
* accept `postMessage(value)` and dispatch `"message"` events).
*
* Using a structural type keeps the package universal — neither
* `worker_threads` nor browser globals are imported at module load.
*/
export interface PortLike {
postMessage(value: unknown): void;
addEventListener(
type: "message",
listener: (event: { data: unknown }) => void
): void;
removeEventListener(
type: "message",
listener: (event: { data: unknown }) => void
): void;
start?: () => void;
close?: () => void;
}
/**
* A paired-port factory used by conformance suites. Wraps the platform
* `MessageChannel` so suites do not import platform globals directly.
*/
export interface InProcessPortPair {
readonly clientPort: PortLike;
readonly serverPort: PortLike;
close(): void;
}
/**
* Build an in-process linked pair of {@link PortLike} ports via
* `MessageChannel`. Both Node 18+ and Bun expose `MessageChannel` as a
* global; browsers expose it natively. The pair is fully bidirectional
* and zero-copy for `Transferable`s.
*/
export function createInProcessPortPair(): InProcessPortPair {
const channel = new MessageChannel();
const a = wrapPort(channel.port1);
const b = wrapPort(channel.port2);
return {
clientPort: a,
serverPort: b,
close: () => {
channel.port1.close();
channel.port2.close();
},
};
}
function wrapPort(port: MessagePort): PortLike {
port.start?.();
const wrappers = new WeakMap<
(event: { data: unknown }) => void,
EventListener
>();
return {
postMessage: (value: unknown) => port.postMessage(value),
addEventListener: (_type, listener) => {
const wrapper: EventListener = (event) =>
listener({ data: (event as MessageEvent).data });
wrappers.set(listener, wrapper);
port.addEventListener("message", wrapper);
},
removeEventListener: (_type, listener) => {
const wrapper = wrappers.get(listener);
if (wrapper !== undefined) {
port.removeEventListener("message", wrapper);
wrappers.delete(listener);
}
},
start: () => port.start?.(),
close: () => port.close(),
};
}

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", "dist", "node_modules"]
}

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/api-worker-rpc",
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\//, /^node:/, "tslib", "zod"],
},
},
});

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
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/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});