first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 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,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,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,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,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,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,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,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,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,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,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,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,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.