first commit
This commit is contained in:
147
docs/api-stack/api/api-worker-rpc/README.md
Normal file
147
docs/api-stack/api/api-worker-rpc/README.md
Normal 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
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
99
docs/api-stack/api/api-worker-rpc/src/client/client.md
Normal file
99
docs/api-stack/api/api-worker-rpc/src/client/client.md
Normal 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).
|
||||
@@ -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).
|
||||
@@ -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)`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
93
docs/api-stack/api/api-worker-rpc/src/protocol/protocol.md
Normal file
93
docs/api-stack/api/api-worker-rpc/src/protocol/protocol.md
Normal 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.
|
||||
101
docs/api-stack/api/api-worker-rpc/src/server/server.md
Normal file
101
docs/api-stack/api/api-worker-rpc/src/server/server.md
Normal 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.
|
||||
101
docs/api-stack/api/api-worker-rpc/src/streaming/streaming.md
Normal file
101
docs/api-stack/api/api-worker-rpc/src/streaming/streaming.md
Normal 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.
|
||||
75
docs/api-stack/api/api-worker-rpc/src/testing/conformance.md
Normal file
75
docs/api-stack/api/api-worker-rpc/src/testing/conformance.md
Normal 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.
|
||||
87
docs/api-stack/api/api-worker-rpc/src/testing/testing.md
Normal file
87
docs/api-stack/api/api-worker-rpc/src/testing/testing.md
Normal 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.
|
||||
Reference in New Issue
Block a user