Initial Commit
This commit is contained in:
147
packages/api-stack/api/api-worker-rpc/README.md
Normal file
147
packages/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
|
||||
|
||||
122
packages/api-stack/api/api-worker-rpc/package.json
Normal file
122
packages/api-stack/api/api-worker-rpc/package.json
Normal file
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"name": "@suaveplan/api-worker-rpc",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "MessagePort-based RPC protocol for worker-isolated API plugins. Provides Zod-validated wire frames, monotonic correlation ids, AbortSignal forwarding, streaming with backpressure, structured-clone safety guards, SuaveplanError reconstruction across the boundary, and paired client/server bindings. Universal; Stage-3 worker-proxy packages depend on this.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./protocol": {
|
||||
"import": {
|
||||
"types": "./dist/protocol/protocol.d.ts",
|
||||
"default": "./dist/protocol/protocol.js"
|
||||
}
|
||||
},
|
||||
"./correlation": {
|
||||
"import": {
|
||||
"types": "./dist/correlation/correlation.d.ts",
|
||||
"default": "./dist/correlation/correlation.js"
|
||||
}
|
||||
},
|
||||
"./client": {
|
||||
"import": {
|
||||
"types": "./dist/client/client.d.ts",
|
||||
"default": "./dist/client/client.js"
|
||||
}
|
||||
},
|
||||
"./server": {
|
||||
"import": {
|
||||
"types": "./dist/server/server.d.ts",
|
||||
"default": "./dist/server/server.js"
|
||||
}
|
||||
},
|
||||
"./streaming": {
|
||||
"import": {
|
||||
"types": "./dist/streaming/streaming.d.ts",
|
||||
"default": "./dist/streaming/streaming.js"
|
||||
}
|
||||
},
|
||||
"./abort-forwarding": {
|
||||
"import": {
|
||||
"types": "./dist/abort-forwarding/abort-forwarding.d.ts",
|
||||
"default": "./dist/abort-forwarding/abort-forwarding.js"
|
||||
}
|
||||
},
|
||||
"./error-reconstruction": {
|
||||
"import": {
|
||||
"types": "./dist/error-reconstruction/error-reconstruction.d.ts",
|
||||
"default": "./dist/error-reconstruction/error-reconstruction.js"
|
||||
}
|
||||
},
|
||||
"./backpressure": {
|
||||
"import": {
|
||||
"types": "./dist/backpressure/backpressure.d.ts",
|
||||
"default": "./dist/backpressure/backpressure.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./testing": {
|
||||
"import": {
|
||||
"types": "./dist/testing/testing.d.ts",
|
||||
"default": "./dist/testing/testing.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "bunx biome check src/ tests/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/api-types": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -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,72 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AbortError,
|
||||
attachAbortSignal,
|
||||
createRemoteAbort,
|
||||
} from "./abort-forwarding.js";
|
||||
|
||||
describe("abort-forwarding", () => {
|
||||
it("attach with undefined is inert", { timeout: 5000 }, () => {
|
||||
let fired = 0;
|
||||
const link = attachAbortSignal(undefined, () => fired++);
|
||||
expect(link.aborted()).toBe(false);
|
||||
link.dispose();
|
||||
expect(fired).toBe(0);
|
||||
});
|
||||
|
||||
it("attach to already-aborted signal fires synchronously", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
let fired = 0;
|
||||
const link = attachAbortSignal(ac.signal, () => fired++);
|
||||
expect(fired).toBe(1);
|
||||
expect(link.aborted()).toBe(true);
|
||||
link.dispose();
|
||||
});
|
||||
|
||||
it("attach fires once on abort", { timeout: 5000 }, () => {
|
||||
const ac = new AbortController();
|
||||
let fired = 0;
|
||||
const link = attachAbortSignal(ac.signal, () => fired++);
|
||||
ac.abort();
|
||||
expect(fired).toBe(1);
|
||||
link.dispose();
|
||||
});
|
||||
|
||||
it("dispose before abort removes listener", { timeout: 5000 }, () => {
|
||||
const ac = new AbortController();
|
||||
let fired = 0;
|
||||
const link = attachAbortSignal(ac.signal, () => fired++);
|
||||
link.dispose();
|
||||
ac.abort();
|
||||
expect(fired).toBe(0);
|
||||
});
|
||||
|
||||
it("RemoteAbort triggers signal once", { timeout: 5000 }, () => {
|
||||
const r = createRemoteAbort();
|
||||
let count = 0;
|
||||
r.signal.addEventListener("abort", () => count++);
|
||||
r.trigger("x");
|
||||
r.trigger("y");
|
||||
expect(r.signal.aborted).toBe(true);
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it("aborted() returns signal state", { timeout: 5000 }, () => {
|
||||
const ac = new AbortController();
|
||||
const link = attachAbortSignal(ac.signal, () => {});
|
||||
expect(link.aborted()).toBe(false);
|
||||
ac.abort();
|
||||
expect(link.aborted()).toBe(true);
|
||||
});
|
||||
|
||||
it("AbortError name + message", { timeout: 5000 }, () => {
|
||||
const err = new AbortError();
|
||||
expect(err.name).toBe("AbortError");
|
||||
expect(err.message).toContain("aborted");
|
||||
const err2 = new AbortError("custom");
|
||||
expect(err2.message).toBe("custom");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Linkage between a caller-side `AbortSignal` and a wire-side cancel
|
||||
* action. The returned dispose function detaches the listener so the
|
||||
* signal does not hold the registry alive past settlement.
|
||||
*/
|
||||
export interface AbortLink {
|
||||
readonly dispose: () => void;
|
||||
readonly aborted: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach an external `AbortSignal` to a per-call cancel action. If the
|
||||
* signal is already aborted, `onAbort` fires synchronously. Otherwise a
|
||||
* one-shot listener is registered and removed on dispose (or first fire).
|
||||
*
|
||||
* Returns a stable {@link AbortLink}; callers store it on their pending
|
||||
* record and call `.dispose()` on settle.
|
||||
*/
|
||||
export function attachAbortSignal(
|
||||
signal: AbortSignal | undefined,
|
||||
onAbort: () => void
|
||||
): AbortLink {
|
||||
if (signal === undefined) {
|
||||
return { dispose: () => {}, aborted: () => false };
|
||||
}
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return { dispose: () => {}, aborted: () => true };
|
||||
}
|
||||
const listener = (): void => {
|
||||
cleanup();
|
||||
onAbort();
|
||||
};
|
||||
const cleanup = (): void => {
|
||||
signal.removeEventListener("abort", listener);
|
||||
};
|
||||
signal.addEventListener("abort", listener, { once: true });
|
||||
return {
|
||||
dispose: cleanup,
|
||||
aborted: () => signal.aborted,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fresh `AbortController` on the server side that can be triggered
|
||||
* remotely via {@link RemoteAbort.trigger}. The handler implementation
|
||||
* receives `controller.signal` so user code can `if (signal.aborted)` or
|
||||
* `signal.addEventListener("abort", ...)` just like local code.
|
||||
*/
|
||||
export interface RemoteAbort {
|
||||
readonly signal: AbortSignal;
|
||||
readonly trigger: (reason?: unknown) => void;
|
||||
}
|
||||
|
||||
export function createRemoteAbort(): RemoteAbort {
|
||||
const controller = new AbortController();
|
||||
return {
|
||||
signal: controller.signal,
|
||||
trigger: (reason?: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
controller.abort(reason);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard `AbortError` constructor — matches the DOMException shape
|
||||
* (`name === "AbortError"`) without depending on browser globals.
|
||||
*/
|
||||
export class AbortError extends Error {
|
||||
public override readonly name = "AbortError";
|
||||
public constructor(message = "The operation was aborted.") {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
AbortError,
|
||||
type AbortLink,
|
||||
attachAbortSignal,
|
||||
createRemoteAbort,
|
||||
type RemoteAbort,
|
||||
} from "./abort-forwarding.js";
|
||||
@@ -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).
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { WorkerRpcError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
BoundedQueue,
|
||||
CallBudget,
|
||||
DEFAULT_BACKPRESSURE,
|
||||
} from "./backpressure.js";
|
||||
|
||||
describe("backpressure", () => {
|
||||
it("default policy exposes highWaterMark + fail mode", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(DEFAULT_BACKPRESSURE.highWaterMark).toBe(64);
|
||||
expect(DEFAULT_BACKPRESSURE.mode).toBe("fail");
|
||||
});
|
||||
|
||||
it("BoundedQueue default policy uses DEFAULT_BACKPRESSURE", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const q = new BoundedQueue<number>();
|
||||
for (let i = 0; i < 64; i++) q.enqueue(i);
|
||||
expect(q.size).toBe(64);
|
||||
expect(() => q.enqueue(99)).toThrow(WorkerRpcError);
|
||||
});
|
||||
|
||||
it("BoundedQueue fail mode throws on overflow", { timeout: 5000 }, () => {
|
||||
const q = new BoundedQueue<number>({ highWaterMark: 2, mode: "fail" });
|
||||
expect(q.enqueue(1)).toBe(true);
|
||||
expect(q.enqueue(2)).toBe(true);
|
||||
expect(() => q.enqueue(3)).toThrow(WorkerRpcError);
|
||||
expect(q.size).toBe(2);
|
||||
});
|
||||
|
||||
it("BoundedQueue drop-oldest evicts head", { timeout: 5000 }, () => {
|
||||
const q = new BoundedQueue<number>({
|
||||
highWaterMark: 2,
|
||||
mode: "drop-oldest",
|
||||
});
|
||||
q.enqueue(1);
|
||||
q.enqueue(2);
|
||||
q.enqueue(3);
|
||||
expect(q.dequeue()).toBe(2);
|
||||
expect(q.dequeue()).toBe(3);
|
||||
});
|
||||
|
||||
it("BoundedQueue drop-newest discards incoming", { timeout: 5000 }, () => {
|
||||
const q = new BoundedQueue<number>({
|
||||
highWaterMark: 2,
|
||||
mode: "drop-newest",
|
||||
});
|
||||
q.enqueue(1);
|
||||
q.enqueue(2);
|
||||
expect(q.enqueue(3)).toBe(false);
|
||||
expect(q.dequeue()).toBe(1);
|
||||
expect(q.dequeue()).toBe(2);
|
||||
});
|
||||
|
||||
it("BoundedQueue peek + drain", { timeout: 5000 }, () => {
|
||||
const q = new BoundedQueue<number>({ highWaterMark: 3, mode: "fail" });
|
||||
q.enqueue(10);
|
||||
q.enqueue(20);
|
||||
expect(q.peek()).toBe(10);
|
||||
const all = q.drain();
|
||||
expect(all).toEqual([10, 20]);
|
||||
expect(q.size).toBe(0);
|
||||
expect(q.peek()).toBeUndefined();
|
||||
expect(q.dequeue()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("BoundedQueue rejects non-positive highWaterMark", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
() => new BoundedQueue<number>({ highWaterMark: 0, mode: "fail" })
|
||||
).toThrow(WorkerRpcError);
|
||||
});
|
||||
|
||||
it("CallBudget acquire + release", { timeout: 5000 }, () => {
|
||||
const b = new CallBudget(2);
|
||||
expect(b.capacity).toBe(2);
|
||||
b.acquire();
|
||||
b.acquire();
|
||||
expect(b.inFlight).toBe(2);
|
||||
expect(() => b.acquire()).toThrow(WorkerRpcError);
|
||||
b.release();
|
||||
expect(b.inFlight).toBe(1);
|
||||
b.release();
|
||||
b.release(); // tolerant of extra release
|
||||
expect(b.inFlight).toBe(0);
|
||||
});
|
||||
|
||||
it("CallBudget rejects non-positive max", { timeout: 5000 }, () => {
|
||||
expect(() => new CallBudget(0)).toThrow(WorkerRpcError);
|
||||
});
|
||||
|
||||
it("CallBudget budget exceeded throws synchronously", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const b = new CallBudget(1024);
|
||||
for (let i = 0; i < 1024; i++) b.acquire();
|
||||
let thrown: unknown;
|
||||
try {
|
||||
b.acquire();
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(WorkerRpcError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { WorkerRpcError } from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Drop or fail policy when the bounded queue is full. Controls how the
|
||||
* caller-side outbound queue and the inbound stream chunk buffer behave
|
||||
* when their high-water mark is exceeded.
|
||||
*/
|
||||
export type BackpressureMode = "fail" | "drop-oldest" | "drop-newest";
|
||||
|
||||
export interface BackpressurePolicy {
|
||||
readonly highWaterMark: number;
|
||||
readonly mode: BackpressureMode;
|
||||
}
|
||||
|
||||
/** Default policy: 64 outstanding chunks, fail-fast on exceed. */
|
||||
export const DEFAULT_BACKPRESSURE: BackpressurePolicy = {
|
||||
highWaterMark: 64,
|
||||
mode: "fail",
|
||||
};
|
||||
|
||||
/**
|
||||
* Bounded FIFO whose `enqueue` honours a {@link BackpressurePolicy}. Used
|
||||
* by both the outbound queue (request frames) and the inbound stream chunk
|
||||
* buffer; centralising the eviction logic means the three modes are tested
|
||||
* in one place.
|
||||
*/
|
||||
export class BoundedQueue<T> {
|
||||
private readonly buffer: T[] = [];
|
||||
private readonly policy: BackpressurePolicy;
|
||||
|
||||
public constructor(policy: BackpressurePolicy = DEFAULT_BACKPRESSURE) {
|
||||
if (policy.highWaterMark <= 0) {
|
||||
throw new WorkerRpcError(
|
||||
"WORKER_RPC.BACKPRESSURE",
|
||||
`highWaterMark must be positive; got ${policy.highWaterMark}`
|
||||
);
|
||||
}
|
||||
this.policy = policy;
|
||||
}
|
||||
|
||||
public get size(): number {
|
||||
return this.buffer.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a value. Returns `true` when accepted, `false` when dropped
|
||||
* under a drop policy. In `fail` mode throws `WORKER_RPC.BACKPRESSURE`
|
||||
* synchronously when full.
|
||||
*/
|
||||
public enqueue(value: T): boolean {
|
||||
if (this.buffer.length < this.policy.highWaterMark) {
|
||||
this.buffer.push(value);
|
||||
return true;
|
||||
}
|
||||
if (this.policy.mode === "fail") {
|
||||
throw new WorkerRpcError(
|
||||
"WORKER_RPC.BACKPRESSURE",
|
||||
`queue full (highWaterMark=${this.policy.highWaterMark})`
|
||||
);
|
||||
}
|
||||
if (this.policy.mode === "drop-oldest") {
|
||||
this.buffer.shift();
|
||||
this.buffer.push(value);
|
||||
return true;
|
||||
}
|
||||
// drop-newest
|
||||
return false;
|
||||
}
|
||||
|
||||
public dequeue(): T | undefined {
|
||||
return this.buffer.shift();
|
||||
}
|
||||
|
||||
public peek(): T | undefined {
|
||||
return this.buffer[0];
|
||||
}
|
||||
|
||||
public drain(): T[] {
|
||||
const out = this.buffer.slice();
|
||||
this.buffer.length = 0;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Outbound-budget counter for in-flight calls. Distinct from
|
||||
* {@link BoundedQueue} because it tracks logical request slots (not
|
||||
* physical frames) and is decremented on response receipt rather than
|
||||
* dequeue.
|
||||
*/
|
||||
export class CallBudget {
|
||||
private readonly max: number;
|
||||
private current = 0;
|
||||
|
||||
public constructor(max: number) {
|
||||
if (max <= 0) {
|
||||
throw new WorkerRpcError(
|
||||
"WORKER_RPC.BACKPRESSURE",
|
||||
`budget must be positive; got ${max}`
|
||||
);
|
||||
}
|
||||
this.max = max;
|
||||
}
|
||||
|
||||
public get inFlight(): number {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
public get capacity(): number {
|
||||
return this.max - this.current;
|
||||
}
|
||||
|
||||
/** Reserve a slot; throws synchronously when exhausted. */
|
||||
public acquire(): void {
|
||||
if (this.current >= this.max) {
|
||||
throw new WorkerRpcError(
|
||||
"WORKER_RPC.BACKPRESSURE",
|
||||
`call budget exhausted (max=${this.max})`
|
||||
);
|
||||
}
|
||||
this.current++;
|
||||
}
|
||||
|
||||
public release(): void {
|
||||
if (this.current > 0) this.current--;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
type BackpressureMode,
|
||||
type BackpressurePolicy,
|
||||
BoundedQueue,
|
||||
CallBudget,
|
||||
DEFAULT_BACKPRESSURE,
|
||||
} from "./backpressure.js";
|
||||
99
packages/api-stack/api/api-worker-rpc/src/client/client.md
Normal file
99
packages/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).
|
||||
406
packages/api-stack/api/api-worker-rpc/src/client/client.test.ts
Normal file
406
packages/api-stack/api/api-worker-rpc/src/client/client.test.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { WorkerRpcError } from "../error-codes/error-codes.js";
|
||||
import { PROTOCOL_VERSION } from "../protocol/protocol.js";
|
||||
import { serveWorkerRpc } from "../server/server.js";
|
||||
import { createInProcessPortPair } from "../testing/testing.js";
|
||||
import { createRawWorkerRpcClient, createWorkerRpcClient } from "./client.js";
|
||||
|
||||
interface Calc {
|
||||
add(a: number, b: number): number;
|
||||
boom(): never;
|
||||
slow(ms: number): string;
|
||||
}
|
||||
|
||||
function makePair() {
|
||||
const ports = createInProcessPortPair();
|
||||
const server = serveWorkerRpc(ports.serverPort, {
|
||||
add: (p) => (p[0] as number) + (p[1] as number),
|
||||
boom: () => {
|
||||
throw new TypeError("explode");
|
||||
},
|
||||
slow: async (p, signal) => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const t = setTimeout(resolve, p[0] as number);
|
||||
signal.addEventListener("abort", () => {
|
||||
clearTimeout(t);
|
||||
reject(new Error("aborted"));
|
||||
});
|
||||
});
|
||||
return "done";
|
||||
},
|
||||
echo: (p) => p[0],
|
||||
});
|
||||
return { ports, server };
|
||||
}
|
||||
|
||||
describe("client", () => {
|
||||
it("typed proxy: add resolves with sum", { timeout: 5000 }, async () => {
|
||||
const { ports, server } = makePair();
|
||||
const c = createWorkerRpcClient<Calc>(ports.clientPort);
|
||||
const out = await c.add(2, 3);
|
||||
expect(out).toBe(5);
|
||||
c.close();
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("raw client returns a promise per call", { timeout: 5000 }, async () => {
|
||||
const { ports, server } = makePair();
|
||||
const c = createRawWorkerRpcClient(ports.clientPort);
|
||||
await expect(c.call("add", [10, 5])).resolves.toBe(15);
|
||||
c.close();
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("rejects on handler throw with REMOTE_THROW", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { ports, server } = makePair();
|
||||
const c = createRawWorkerRpcClient(ports.clientPort);
|
||||
await expect(c.call("boom", [])).rejects.toMatchObject({
|
||||
code: "WORKER_RPC.REMOTE_THROW",
|
||||
});
|
||||
c.close();
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("unknown method rejects with UNKNOWN_METHOD", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { ports, server } = makePair();
|
||||
const c = createRawWorkerRpcClient(ports.clientPort);
|
||||
await expect(c.call("nope", [])).rejects.toMatchObject({
|
||||
code: "WORKER_RPC.UNKNOWN_METHOD",
|
||||
});
|
||||
c.close();
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("abort during in-flight call rejects with AbortError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { ports, server } = makePair();
|
||||
const c = createRawWorkerRpcClient(ports.clientPort);
|
||||
const ac = new AbortController();
|
||||
const p = c.call("slow", [200], { signal: ac.signal });
|
||||
ac.abort();
|
||||
await expect(p).rejects.toMatchObject({ name: "AbortError" });
|
||||
c.close();
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("close rejects pending with PORT_CLOSED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { ports, server } = makePair();
|
||||
const c = createRawWorkerRpcClient(ports.clientPort);
|
||||
const p = c.call("slow", [500]);
|
||||
c.close("test");
|
||||
await expect(p).rejects.toMatchObject({
|
||||
code: "WORKER_RPC.PORT_CLOSED",
|
||||
});
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("post-close call throws PORT_CLOSED", { timeout: 5000 }, () => {
|
||||
const ports = createInProcessPortPair();
|
||||
const c = createRawWorkerRpcClient(ports.clientPort);
|
||||
c.close();
|
||||
let captured: WorkerRpcError | undefined;
|
||||
try {
|
||||
c.send({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "cancel",
|
||||
id: 1,
|
||||
});
|
||||
} catch (e) {
|
||||
captured = e as WorkerRpcError;
|
||||
}
|
||||
expect(captured?.code).toBe("WORKER_RPC.PORT_CLOSED");
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("abort after settle is no-op (no double-cancel)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { ports, server } = makePair();
|
||||
const c = createRawWorkerRpcClient(ports.clientPort);
|
||||
const ac = new AbortController();
|
||||
const result = await c.call("add", [1, 2], { signal: ac.signal });
|
||||
expect(result).toBe(3);
|
||||
ac.abort(); // post-settle abort
|
||||
c.close();
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("call respects timeoutMs option", { timeout: 5000 }, async () => {
|
||||
const { ports, server } = makePair();
|
||||
const c = createRawWorkerRpcClient(ports.clientPort);
|
||||
await expect(
|
||||
c.call("slow", [200], { timeoutMs: 30 })
|
||||
).rejects.toMatchObject({ code: "WORKER_RPC.REQUEST_TIMEOUT" });
|
||||
c.close();
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("non-cloneable arg throws synchronously", { timeout: 5000 }, () => {
|
||||
const { ports, server } = makePair();
|
||||
const c = createRawWorkerRpcClient(ports.clientPort);
|
||||
let captured: WorkerRpcError | undefined;
|
||||
try {
|
||||
c.call("echo", [() => {}]);
|
||||
} catch (e) {
|
||||
captured = e as WorkerRpcError;
|
||||
}
|
||||
expect(captured?.code).toBe("WORKER_RPC.NOT_CLONEABLE");
|
||||
c.close();
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("budget exhausted throws BACKPRESSURE", { timeout: 5000 }, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
// no server attached so calls hang; budget = 1
|
||||
const c = createRawWorkerRpcClient(ports.clientPort, {
|
||||
callBudget: 1,
|
||||
});
|
||||
c.call("x", []).catch(() => {});
|
||||
let captured: WorkerRpcError | undefined;
|
||||
try {
|
||||
c.call("y", []);
|
||||
} catch (e) {
|
||||
captured = e as WorkerRpcError;
|
||||
}
|
||||
expect(captured?.code).toBe("WORKER_RPC.BACKPRESSURE");
|
||||
c.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("nextId + send exposed to streaming layer", { timeout: 5000 }, () => {
|
||||
const ports = createInProcessPortPair();
|
||||
const c = createRawWorkerRpcClient(ports.clientPort);
|
||||
const id1 = c.nextId();
|
||||
const id2 = c.nextId();
|
||||
expect(id2).toBe(id1 + 1);
|
||||
c.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("registry is exposed", { timeout: 5000 }, () => {
|
||||
const ports = createInProcessPortPair();
|
||||
const c = createRawWorkerRpcClient(ports.clientPort);
|
||||
expect(c.registry.size).toBe(0);
|
||||
c.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("inFlight tracks budget usage", { timeout: 5000 }, async () => {
|
||||
const { ports, server } = makePair();
|
||||
const c = createRawWorkerRpcClient(ports.clientPort);
|
||||
expect(c.inFlight()).toBe(0);
|
||||
const p = c.call("slow", [50]);
|
||||
expect(c.inFlight()).toBe(1);
|
||||
await p;
|
||||
expect(c.inFlight()).toBe(0);
|
||||
c.close();
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("cancel() sends cancel frame for in-flight call", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { ports, server } = makePair();
|
||||
const c = createRawWorkerRpcClient(ports.clientPort);
|
||||
const id = c.nextId();
|
||||
const p = new Promise<unknown>((resolve, reject) => {
|
||||
c.registry.register({ id, resolve, reject });
|
||||
});
|
||||
c.send({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "request",
|
||||
id,
|
||||
method: "slow",
|
||||
params: [200],
|
||||
});
|
||||
c.cancel(id);
|
||||
await expect(p).rejects.toMatchObject({ code: "WORKER_RPC.CANCELLED" });
|
||||
c.close();
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("cancel() of unknown id is no-op", { timeout: 5000 }, () => {
|
||||
const ports = createInProcessPortPair();
|
||||
const c = createRawWorkerRpcClient(ports.clientPort);
|
||||
c.cancel(99);
|
||||
c.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("proxy returns undefined for symbol keys", { timeout: 5000 }, () => {
|
||||
const ports = createInProcessPortPair();
|
||||
const c = createWorkerRpcClient<Calc>(ports.clientPort);
|
||||
const sym = Symbol("x");
|
||||
expect((c as unknown as Record<symbol, unknown>)[sym]).toBeUndefined();
|
||||
c.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("proxy exposes raw + close on reserved keys", { timeout: 5000 }, () => {
|
||||
const ports = createInProcessPortPair();
|
||||
const c = createWorkerRpcClient<Calc>(ports.clientPort);
|
||||
expect(c.__raw__).toBeDefined();
|
||||
expect(typeof c.close).toBe("function");
|
||||
c.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("invalid frame inbound surfaces via onProtocolError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
const seen: WorkerRpcError[] = [];
|
||||
const c = createRawWorkerRpcClient(ports.clientPort, {
|
||||
onProtocolError: (e) => seen.push(e),
|
||||
});
|
||||
ports.serverPort.postMessage({ not: "a frame" });
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(seen[0]?.code).toBe("WORKER_RPC.INVALID_FRAME");
|
||||
c.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("version mismatch inbound surfaces via onProtocolError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
const seen: WorkerRpcError[] = [];
|
||||
const c = createRawWorkerRpcClient(ports.clientPort, {
|
||||
onProtocolError: (e) => seen.push(e),
|
||||
});
|
||||
ports.serverPort.postMessage({
|
||||
v: "9.9.9",
|
||||
kind: "response",
|
||||
ok: true,
|
||||
id: 1,
|
||||
result: 1,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(seen[0]?.code).toBe("WORKER_RPC.VERSION_MISMATCH");
|
||||
c.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("send error during call rejects pending and releases budget", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// A faulty PortLike that throws on postMessage so sendRaw rethrows.
|
||||
const seenListeners: Array<(e: { data: unknown }) => void> = [];
|
||||
const port = {
|
||||
postMessage: (_v: unknown) => {
|
||||
throw new Error("port broken");
|
||||
},
|
||||
addEventListener: (
|
||||
_t: "message",
|
||||
l: (e: { data: unknown }) => void
|
||||
) => {
|
||||
seenListeners.push(l);
|
||||
},
|
||||
removeEventListener: () => {},
|
||||
start: () => {},
|
||||
};
|
||||
const c = createRawWorkerRpcClient(port);
|
||||
await expect(c.call("x", [])).rejects.toThrow("port broken");
|
||||
expect(c.inFlight()).toBe(0);
|
||||
c.close();
|
||||
});
|
||||
|
||||
it("send error from non-Error value still rejects", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const port = {
|
||||
postMessage: ((_v: unknown) => {
|
||||
const s: string = "raw string";
|
||||
throw s as unknown as Error;
|
||||
}) as (v: unknown) => void,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
start: () => {},
|
||||
};
|
||||
const c = createRawWorkerRpcClient(port);
|
||||
await expect(c.call("x", [])).rejects.toThrow("raw string");
|
||||
c.close();
|
||||
});
|
||||
|
||||
it("inbound stream frame routed to onStreamFrame", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
const seen: unknown[] = [];
|
||||
const c = createRawWorkerRpcClient(ports.clientPort, {
|
||||
onStreamFrame: (f) => seen.push(f),
|
||||
});
|
||||
ports.serverPort.postMessage({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "stream-chunk",
|
||||
id: 1,
|
||||
chunk: "x",
|
||||
});
|
||||
ports.serverPort.postMessage({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "stream-end",
|
||||
ok: true,
|
||||
id: 1,
|
||||
});
|
||||
ports.serverPort.postMessage({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "stream-ack",
|
||||
id: 1,
|
||||
delta: 1,
|
||||
});
|
||||
ports.serverPort.postMessage({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "progress",
|
||||
id: 1,
|
||||
chunk: "p",
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(seen).toHaveLength(4);
|
||||
c.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("ignores inbound non-relevant frames (hello/goodbye/cancel)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
const c = createRawWorkerRpcClient(ports.clientPort);
|
||||
ports.serverPort.postMessage({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "hello",
|
||||
hostVersion: "1.0",
|
||||
capabilities: [],
|
||||
});
|
||||
ports.serverPort.postMessage({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "goodbye",
|
||||
reason: "bye",
|
||||
});
|
||||
ports.serverPort.postMessage({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "cancel",
|
||||
id: 99,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
c.close();
|
||||
ports.close();
|
||||
});
|
||||
});
|
||||
265
packages/api-stack/api/api-worker-rpc/src/client/client.ts
Normal file
265
packages/api-stack/api/api-worker-rpc/src/client/client.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import {
|
||||
AbortError,
|
||||
attachAbortSignal,
|
||||
} from "../abort-forwarding/abort-forwarding.js";
|
||||
import {
|
||||
type BackpressurePolicy,
|
||||
BoundedQueue,
|
||||
CallBudget,
|
||||
DEFAULT_BACKPRESSURE,
|
||||
} from "../backpressure/backpressure.js";
|
||||
import {
|
||||
type CorrelationIdGenerator,
|
||||
createCorrelationIdGenerator,
|
||||
PendingRegistry,
|
||||
} from "../correlation/correlation.js";
|
||||
import { WorkerRpcError } from "../error-codes/error-codes.js";
|
||||
import { envelopeToError } from "../error-reconstruction/error-reconstruction.js";
|
||||
import {
|
||||
PROTOCOL_VERSION,
|
||||
parseFrame,
|
||||
type RequestFrame,
|
||||
type WorkerRpcFrame,
|
||||
} from "../protocol/protocol.js";
|
||||
import type { PortLike } from "../testing/testing.js";
|
||||
import { assertCloneable } from "./cloneable-guard.js";
|
||||
|
||||
export interface WorkerRpcClientOptions {
|
||||
readonly idGenerator?: CorrelationIdGenerator;
|
||||
readonly callBudget?: number;
|
||||
readonly outboundQueue?: BackpressurePolicy;
|
||||
/** Per-call timeout in ms; `0` disables. Default `0`. */
|
||||
readonly defaultTimeoutMs?: number;
|
||||
/**
|
||||
* Hook invoked when a stream-chunk arrives for an open stream. Wired up
|
||||
* by `streamRequest`; here so the client can multiplex without circular
|
||||
* imports.
|
||||
*/
|
||||
readonly onStreamFrame?: (frame: WorkerRpcFrame) => void;
|
||||
/** Observer invoked when a non-fatal protocol error is detected. */
|
||||
readonly onProtocolError?: (err: WorkerRpcError) => void;
|
||||
}
|
||||
|
||||
export interface WorkerRpcClient {
|
||||
/** Invoke a method on the remote side. */
|
||||
call<R = unknown>(
|
||||
method: string,
|
||||
params: ReadonlyArray<unknown>,
|
||||
opts?: { signal?: AbortSignal; timeoutMs?: number }
|
||||
): Promise<R>;
|
||||
/** Send a `cancel` frame for an active stream/request id. */
|
||||
cancel(id: number): void;
|
||||
/** Allocate the next correlation id (used by streaming layer). */
|
||||
nextId(): number;
|
||||
/** Send an already-built frame; used by streaming layer. */
|
||||
send(frame: WorkerRpcFrame): void;
|
||||
/** Underlying registry — exposed for streaming + tests. */
|
||||
readonly registry: PendingRegistry;
|
||||
/** Active in-flight call count. */
|
||||
readonly inFlight: () => number;
|
||||
/** Tear down: rejects pending, removes listener. */
|
||||
close(reason?: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a worker-RPC client over a {@link PortLike}. The returned object
|
||||
* is callable via `.call(method, params)` or — through the
|
||||
* {@link createWorkerRpcClient}-derived proxy — directly as
|
||||
* `client.method(...args)`.
|
||||
*/
|
||||
export function createRawWorkerRpcClient(
|
||||
port: PortLike,
|
||||
opts: WorkerRpcClientOptions = {}
|
||||
): WorkerRpcClient {
|
||||
const idGen = opts.idGenerator ?? createCorrelationIdGenerator();
|
||||
const registry = new PendingRegistry();
|
||||
const budget = new CallBudget(opts.callBudget ?? 1024);
|
||||
const queue = new BoundedQueue<WorkerRpcFrame>(
|
||||
opts.outboundQueue ?? DEFAULT_BACKPRESSURE
|
||||
);
|
||||
let closed = false;
|
||||
|
||||
const listener = (event: { data: unknown }): void => {
|
||||
let frame: WorkerRpcFrame;
|
||||
try {
|
||||
frame = parseFrame(event.data);
|
||||
} catch (cause) {
|
||||
opts.onProtocolError?.(
|
||||
new WorkerRpcError(
|
||||
"WORKER_RPC.INVALID_FRAME",
|
||||
"received malformed RPC frame",
|
||||
{ cause }
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (frame.v !== PROTOCOL_VERSION) {
|
||||
opts.onProtocolError?.(
|
||||
new WorkerRpcError(
|
||||
"WORKER_RPC.VERSION_MISMATCH",
|
||||
`expected protocol ${PROTOCOL_VERSION}, got ${frame.v}`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (frame.kind === "response") {
|
||||
if (!registry.has(frame.id)) {
|
||||
opts.onProtocolError?.(
|
||||
new WorkerRpcError(
|
||||
"WORKER_RPC.ORPHAN_RESPONSE",
|
||||
`no pending call for response id ${frame.id}`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (frame.ok) {
|
||||
registry.settle(frame.id, { ok: true, value: frame.result });
|
||||
budget.release();
|
||||
} else {
|
||||
registry.settle(frame.id, {
|
||||
ok: false,
|
||||
error: envelopeToError(frame.error),
|
||||
});
|
||||
budget.release();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
frame.kind === "stream-chunk" ||
|
||||
frame.kind === "stream-end" ||
|
||||
frame.kind === "progress" ||
|
||||
frame.kind === "stream-ack"
|
||||
) {
|
||||
opts.onStreamFrame?.(frame);
|
||||
return;
|
||||
}
|
||||
// hello / goodbye / cancel arriving inbound are advisory here.
|
||||
};
|
||||
port.addEventListener("message", listener);
|
||||
port.start?.();
|
||||
|
||||
const sendRaw = (frame: WorkerRpcFrame): void => {
|
||||
if (closed) {
|
||||
throw new WorkerRpcError(
|
||||
"WORKER_RPC.PORT_CLOSED",
|
||||
"cannot send on closed port"
|
||||
);
|
||||
}
|
||||
queue.enqueue(frame);
|
||||
const next = queue.dequeue();
|
||||
// dequeue is always non-undefined here because enqueue just
|
||||
// accepted (or threw) on overflow; the cast is for the type
|
||||
// narrowing only.
|
||||
port.postMessage(next as WorkerRpcFrame);
|
||||
};
|
||||
|
||||
return {
|
||||
registry,
|
||||
nextId: () => idGen.next(),
|
||||
send: sendRaw,
|
||||
inFlight: () => budget.inFlight,
|
||||
call<R = unknown>(
|
||||
method: string,
|
||||
params: ReadonlyArray<unknown>,
|
||||
callOpts?: { signal?: AbortSignal; timeoutMs?: number }
|
||||
): Promise<R> {
|
||||
assertCloneable(params, "args");
|
||||
budget.acquire();
|
||||
const id = idGen.next();
|
||||
const promise = new Promise<R>((resolve, reject) => {
|
||||
registry.register({
|
||||
id,
|
||||
resolve: (v) => resolve(v as R),
|
||||
reject,
|
||||
});
|
||||
const abortLink = attachAbortSignal(callOpts?.signal, () => {
|
||||
if (registry.has(id)) {
|
||||
sendRaw({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "cancel",
|
||||
id,
|
||||
});
|
||||
registry.settle(id, {
|
||||
ok: false,
|
||||
error: new AbortError(`call ${method} aborted`),
|
||||
});
|
||||
budget.release();
|
||||
}
|
||||
});
|
||||
const timeout =
|
||||
callOpts?.timeoutMs ?? opts.defaultTimeoutMs ?? 0;
|
||||
if (timeout > 0) {
|
||||
registry.attachTimeout(id, timeout);
|
||||
}
|
||||
const frame: RequestFrame = {
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "request",
|
||||
id,
|
||||
method,
|
||||
params: [...params],
|
||||
};
|
||||
try {
|
||||
sendRaw(frame);
|
||||
} catch (err) {
|
||||
abortLink.dispose();
|
||||
registry.settle(id, {
|
||||
ok: false,
|
||||
error:
|
||||
err instanceof Error ? err : new Error(String(err)),
|
||||
});
|
||||
budget.release();
|
||||
}
|
||||
});
|
||||
return promise;
|
||||
},
|
||||
cancel(id: number): void {
|
||||
if (registry.has(id)) {
|
||||
sendRaw({ v: PROTOCOL_VERSION, kind: "cancel", id });
|
||||
registry.cancel(id);
|
||||
budget.release();
|
||||
}
|
||||
},
|
||||
close(reason = "closed"): void {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
port.removeEventListener("message", listener);
|
||||
registry.clear(
|
||||
new WorkerRpcError("WORKER_RPC.PORT_CLOSED", reason)
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed proxy over {@link createRawWorkerRpcClient}. Every property access
|
||||
* becomes a method invocation; the returned promise resolves with the
|
||||
* remote result. Reserved fields (`close`, `__raw__`) expose lifecycle
|
||||
* controls.
|
||||
*/
|
||||
export type WorkerProxyClient<T extends object> = {
|
||||
[K in keyof T]: T[K] extends (...args: infer A) => infer R
|
||||
? (...args: A) => Promise<Awaited<R>>
|
||||
: T[K];
|
||||
} & {
|
||||
readonly __raw__: WorkerRpcClient;
|
||||
close(reason?: string): void;
|
||||
};
|
||||
|
||||
export function createWorkerRpcClient<T extends object>(
|
||||
port: PortLike,
|
||||
opts: WorkerRpcClientOptions = {}
|
||||
): WorkerProxyClient<T> {
|
||||
const raw = createRawWorkerRpcClient(port, opts);
|
||||
const target: Record<string, unknown> = {
|
||||
__raw__: raw,
|
||||
close: (reason?: string) => raw.close(reason),
|
||||
};
|
||||
const proxy = new Proxy(target, {
|
||||
get(t, prop) {
|
||||
if (typeof prop !== "string") return undefined;
|
||||
if (prop in t) return t[prop];
|
||||
return (...args: ReadonlyArray<unknown>) => raw.call(prop, args);
|
||||
},
|
||||
});
|
||||
return proxy as WorkerProxyClient<T>;
|
||||
}
|
||||
@@ -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,84 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { WorkerRpcError } from "../error-codes/error-codes.js";
|
||||
import { assertCloneable } from "./cloneable-guard.js";
|
||||
|
||||
describe("cloneable-guard", () => {
|
||||
it("accepts primitives", { timeout: 5000 }, () => {
|
||||
for (const v of [null, undefined, 1, "x", true, false, 1n]) {
|
||||
assertCloneable(v);
|
||||
}
|
||||
});
|
||||
|
||||
it("function argument throws NOT_CLONEABLE", { timeout: 5000 }, () => {
|
||||
let captured: WorkerRpcError | undefined;
|
||||
try {
|
||||
assertCloneable({ onProgress: () => {} }, "args[0]");
|
||||
} catch (e) {
|
||||
captured = e as WorkerRpcError;
|
||||
}
|
||||
expect(captured).toBeInstanceOf(WorkerRpcError);
|
||||
expect(captured?.code).toBe("WORKER_RPC.NOT_CLONEABLE");
|
||||
expect(captured?.context?.["path"]).toBe("args[0].onProgress");
|
||||
});
|
||||
|
||||
it("symbol throws NOT_CLONEABLE", { timeout: 5000 }, () => {
|
||||
expect(() => assertCloneable(Symbol("x"))).toThrow(WorkerRpcError);
|
||||
});
|
||||
|
||||
it("class instance throws NOT_CLONEABLE", { timeout: 5000 }, () => {
|
||||
class Foo {
|
||||
public a = 1;
|
||||
}
|
||||
let captured: WorkerRpcError | undefined;
|
||||
try {
|
||||
assertCloneable(new Foo(), "args[0]");
|
||||
} catch (e) {
|
||||
captured = e as WorkerRpcError;
|
||||
}
|
||||
expect(captured?.code).toBe("WORKER_RPC.NOT_CLONEABLE");
|
||||
});
|
||||
|
||||
it("cloneable nested object passes", { timeout: 5000 }, () => {
|
||||
assertCloneable({ a: { b: { c: 1 } }, d: [1, 2, "x"] });
|
||||
});
|
||||
|
||||
it("array passes element check", { timeout: 5000 }, () => {
|
||||
expect(() => assertCloneable([1, 2, () => 3])).toThrow(WorkerRpcError);
|
||||
});
|
||||
|
||||
it("Date, RegExp, ArrayBuffer, URL, Error pass", { timeout: 5000 }, () => {
|
||||
assertCloneable(new Date());
|
||||
assertCloneable(/x/);
|
||||
assertCloneable(new ArrayBuffer(4));
|
||||
assertCloneable(new URL("https://example.com"));
|
||||
assertCloneable(new Error("e"));
|
||||
assertCloneable(new Uint8Array([1, 2]));
|
||||
});
|
||||
|
||||
it("Map values walked", { timeout: 5000 }, () => {
|
||||
const m = new Map<string, unknown>([["a", () => 1]]);
|
||||
expect(() => assertCloneable(m)).toThrow(WorkerRpcError);
|
||||
const ok = new Map<string, unknown>([["a", 1]]);
|
||||
assertCloneable(ok);
|
||||
});
|
||||
|
||||
it("Set values walked", { timeout: 5000 }, () => {
|
||||
const s = new Set<unknown>([() => 1]);
|
||||
expect(() => assertCloneable(s)).toThrow(WorkerRpcError);
|
||||
const ok = new Set<unknown>([1, "x"]);
|
||||
assertCloneable(ok);
|
||||
});
|
||||
|
||||
it("null-prototype object passes class check", { timeout: 5000 }, () => {
|
||||
const obj = Object.create(null) as { a?: number };
|
||||
obj.a = 1;
|
||||
assertCloneable(obj);
|
||||
});
|
||||
|
||||
it("cycles do not infinite-loop", { timeout: 5000 }, () => {
|
||||
type C = { self?: C; v: number };
|
||||
const o: C = { v: 1 };
|
||||
o.self = o;
|
||||
assertCloneable(o);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { WorkerRpcError } from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Pre-send check that walks a value and rejects anything not
|
||||
* structured-clone-safe. Saves callers from silent wire corruption AND
|
||||
* from the opaque `DataCloneError` that browsers/Bun throw when a
|
||||
* non-cloneable value reaches `postMessage`.
|
||||
*
|
||||
* Rejects: functions, symbols, class instances whose constructor is not
|
||||
* `Object`/`Array`/`Map`/`Set`/`Date`/`Error`/`URL`/`RegExp`/typed-arrays.
|
||||
* Accepts: primitives, plain objects, arrays, Map, Set, Date, Error,
|
||||
* RegExp, ArrayBuffer + views, URL, structured-clonable wire built-ins.
|
||||
*/
|
||||
export function assertCloneable(value: unknown, path = "value"): void {
|
||||
walk(value, path, new WeakSet<object>());
|
||||
}
|
||||
|
||||
function walk(value: unknown, path: string, seen: WeakSet<object>): void {
|
||||
const t = typeof value;
|
||||
if (
|
||||
value === null ||
|
||||
t === "string" ||
|
||||
t === "number" ||
|
||||
t === "boolean" ||
|
||||
t === "bigint" ||
|
||||
t === "undefined"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (t === "function") {
|
||||
throw new WorkerRpcError(
|
||||
"WORKER_RPC.NOT_CLONEABLE",
|
||||
`non-cloneable function at ${path}`,
|
||||
{ context: { path } }
|
||||
);
|
||||
}
|
||||
if (t === "symbol") {
|
||||
throw new WorkerRpcError(
|
||||
"WORKER_RPC.NOT_CLONEABLE",
|
||||
`non-cloneable symbol at ${path}`,
|
||||
{ context: { path } }
|
||||
);
|
||||
}
|
||||
const obj = value as object;
|
||||
if (seen.has(obj)) return;
|
||||
seen.add(obj);
|
||||
if (Array.isArray(obj)) {
|
||||
for (let i = 0; i < obj.length; i++) {
|
||||
walk(obj[i], `${path}[${i}]`, seen);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
obj instanceof Date ||
|
||||
obj instanceof RegExp ||
|
||||
obj instanceof ArrayBuffer ||
|
||||
obj instanceof Error ||
|
||||
obj instanceof URL ||
|
||||
ArrayBuffer.isView(obj)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (obj instanceof Map) {
|
||||
for (const [k, v] of obj.entries()) {
|
||||
walk(k, `${path}.<key>`, seen);
|
||||
walk(v, `${path}.<value>`, seen);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (obj instanceof Set) {
|
||||
for (const v of obj.values()) {
|
||||
walk(v, `${path}.<entry>`, seen);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const proto = Object.getPrototypeOf(obj);
|
||||
if (proto !== Object.prototype && proto !== null) {
|
||||
throw new WorkerRpcError(
|
||||
"WORKER_RPC.NOT_CLONEABLE",
|
||||
`non-cloneable class instance at ${path}`,
|
||||
{ context: { path } }
|
||||
);
|
||||
}
|
||||
for (const k of Object.keys(obj)) {
|
||||
walk((obj as Record<string, unknown>)[k], `${path}.${k}`, seen);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
createRawWorkerRpcClient,
|
||||
createWorkerRpcClient,
|
||||
type WorkerProxyClient,
|
||||
type WorkerRpcClient,
|
||||
type WorkerRpcClientOptions,
|
||||
} from "./client.js";
|
||||
export { assertCloneable } from "./cloneable-guard.js";
|
||||
@@ -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,197 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { WorkerRpcError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
createCorrelationIdGenerator,
|
||||
PendingRegistry,
|
||||
} from "./correlation.js";
|
||||
|
||||
describe("correlation", () => {
|
||||
it("ids are strictly monotonic per port", { timeout: 5000 }, () => {
|
||||
const gen = createCorrelationIdGenerator();
|
||||
const seq: number[] = [];
|
||||
for (let i = 0; i < 1000; i++) seq.push(gen.next());
|
||||
for (let i = 1; i < seq.length; i++) {
|
||||
const prev = seq[i - 1] as number;
|
||||
const cur = seq[i] as number;
|
||||
expect(cur).toBe(prev + 1);
|
||||
}
|
||||
expect(gen.peek()).toBe(1001);
|
||||
});
|
||||
|
||||
it("custom start value", { timeout: 5000 }, () => {
|
||||
const gen = createCorrelationIdGenerator(100);
|
||||
expect(gen.next()).toBe(100);
|
||||
expect(gen.next()).toBe(101);
|
||||
});
|
||||
|
||||
it("registers and settles a call", { timeout: 5000 }, async () => {
|
||||
const reg = new PendingRegistry();
|
||||
const promise = new Promise<unknown>((resolve, reject) => {
|
||||
reg.register({ id: 1, resolve, reject });
|
||||
});
|
||||
reg.settle(1, { ok: true, value: "ok" });
|
||||
await expect(promise).resolves.toBe("ok");
|
||||
expect(reg.size).toBe(0);
|
||||
});
|
||||
|
||||
it("orphan response throws WORKER_RPC.ORPHAN_RESPONSE", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new PendingRegistry();
|
||||
expect(() => reg.settle(99, { ok: true, value: 1 })).toThrow(
|
||||
WorkerRpcError
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects correlation-id reuse", { timeout: 5000 }, () => {
|
||||
const reg = new PendingRegistry();
|
||||
reg.register({ id: 1, resolve: () => {}, reject: () => {} });
|
||||
expect(() =>
|
||||
reg.register({ id: 1, resolve: () => {}, reject: () => {} })
|
||||
).toThrow(WorkerRpcError);
|
||||
});
|
||||
|
||||
it("cancel rejects pending with CANCELLED", { timeout: 5000 }, async () => {
|
||||
const reg = new PendingRegistry();
|
||||
let observed: Error | undefined;
|
||||
reg.register({
|
||||
id: 7,
|
||||
resolve: () => {},
|
||||
reject: (e) => {
|
||||
observed = e;
|
||||
},
|
||||
});
|
||||
const cancelled = reg.cancel(7);
|
||||
expect(cancelled).toBe(true);
|
||||
expect((observed as WorkerRpcError).code).toBe("WORKER_RPC.CANCELLED");
|
||||
expect(reg.cancel(7)).toBe(false);
|
||||
});
|
||||
|
||||
it("clear rejects all pending with reason", { timeout: 5000 }, () => {
|
||||
const reg = new PendingRegistry();
|
||||
const errs: Error[] = [];
|
||||
reg.register({
|
||||
id: 1,
|
||||
resolve: () => {},
|
||||
reject: (e) => errs.push(e),
|
||||
});
|
||||
reg.register({
|
||||
id: 2,
|
||||
resolve: () => {},
|
||||
reject: (e) => errs.push(e),
|
||||
});
|
||||
reg.clear(new Error("teardown"));
|
||||
expect(errs).toHaveLength(2);
|
||||
expect(reg.size).toBe(0);
|
||||
});
|
||||
|
||||
it("settle with error rejects", { timeout: 5000 }, async () => {
|
||||
const reg = new PendingRegistry();
|
||||
const promise = new Promise<unknown>((resolve, reject) => {
|
||||
reg.register({ id: 3, resolve, reject });
|
||||
});
|
||||
reg.settle(3, { ok: false, error: new Error("nope") });
|
||||
await expect(promise).rejects.toThrow("nope");
|
||||
});
|
||||
|
||||
it("attachTimeout rejects after delay", { timeout: 5000 }, async () => {
|
||||
const reg = new PendingRegistry();
|
||||
const promise = new Promise<unknown>((resolve, reject) => {
|
||||
reg.register({ id: 4, resolve, reject });
|
||||
});
|
||||
reg.attachTimeout(4, 10);
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
code: "WORKER_RPC.REQUEST_TIMEOUT",
|
||||
});
|
||||
});
|
||||
|
||||
it("attachTimeout on unknown id is a no-op", { timeout: 5000 }, () => {
|
||||
const reg = new PendingRegistry();
|
||||
reg.attachTimeout(999, 10);
|
||||
expect(reg.size).toBe(0);
|
||||
});
|
||||
|
||||
it("settle clears pending timer", { timeout: 5000 }, async () => {
|
||||
const reg = new PendingRegistry();
|
||||
const promise = new Promise<unknown>((resolve, reject) => {
|
||||
reg.register({ id: 5, resolve, reject });
|
||||
});
|
||||
reg.attachTimeout(5, 30);
|
||||
reg.settle(5, { ok: true, value: "done" });
|
||||
await expect(promise).resolves.toBe("done");
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
// No throw from late timer
|
||||
});
|
||||
|
||||
it("cancel clears pending timer", { timeout: 5000 }, async () => {
|
||||
const reg = new PendingRegistry();
|
||||
let captured: Error | undefined;
|
||||
reg.register({
|
||||
id: 6,
|
||||
resolve: () => {},
|
||||
reject: (e) => {
|
||||
captured = e;
|
||||
},
|
||||
});
|
||||
reg.attachTimeout(6, 30);
|
||||
reg.cancel(6);
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
expect((captured as WorkerRpcError).code).toBe("WORKER_RPC.CANCELLED");
|
||||
});
|
||||
|
||||
it("clear runs pending onCancel", { timeout: 5000 }, () => {
|
||||
const reg = new PendingRegistry();
|
||||
reg.register({ id: 1, resolve: () => {}, reject: () => {} });
|
||||
reg.attachTimeout(1, 1000);
|
||||
reg.clear(new Error("x"));
|
||||
});
|
||||
|
||||
it("late-fire timer is no-op when entry already removed", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const reg = new PendingRegistry();
|
||||
const settled: unknown[] = [];
|
||||
reg.register({
|
||||
id: 100,
|
||||
resolve: (v) => settled.push(v),
|
||||
reject: (e) => settled.push(e),
|
||||
});
|
||||
// Race: schedule a 0ms timer, then delete the entry directly so
|
||||
// the late-fire branch (live !== call) hits.
|
||||
reg.attachTimeout(100, 0);
|
||||
// Replace registration to simulate the race (settle clears the
|
||||
// timer normally — we intentionally bypass it here to exercise
|
||||
// the defensive guard).
|
||||
const internalMap = (reg as unknown as { map: Map<number, unknown> })
|
||||
.map;
|
||||
internalMap.delete(100);
|
||||
internalMap.set(100, {
|
||||
id: 100,
|
||||
resolve: () => {},
|
||||
reject: () => {},
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
// The defensive guard prevents double-settle.
|
||||
expect(settled).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("late timer is no-op after settle", { timeout: 5000 }, async () => {
|
||||
const reg = new PendingRegistry();
|
||||
let count = 0;
|
||||
reg.register({
|
||||
id: 8,
|
||||
resolve: () => count++,
|
||||
reject: () => count++,
|
||||
});
|
||||
reg.attachTimeout(8, 5);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it("has() reflects registration", { timeout: 5000 }, () => {
|
||||
const reg = new PendingRegistry();
|
||||
expect(reg.has(1)).toBe(false);
|
||||
reg.register({ id: 1, resolve: () => {}, reject: () => {} });
|
||||
expect(reg.has(1)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { WorkerRpcError } from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Monotonic correlation-id generator. Per-port-scoped: each call to
|
||||
* {@link createCorrelationIdGenerator} returns an independent counter,
|
||||
* so two ports cannot accidentally collide.
|
||||
*/
|
||||
export interface CorrelationIdGenerator {
|
||||
next(): number;
|
||||
peek(): number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an id generator with optional starting value (default `1`). Ids are
|
||||
* strictly monotonic and dense (no gaps), making orphan-response detection
|
||||
* a single map lookup.
|
||||
*/
|
||||
export function createCorrelationIdGenerator(
|
||||
start = 1
|
||||
): CorrelationIdGenerator {
|
||||
let counter = start;
|
||||
return {
|
||||
next: () => counter++,
|
||||
peek: () => counter,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending-call record stored in the registry. The settle callbacks are
|
||||
* driven by inbound `response` frames; `onCancel` lets the registry
|
||||
* transmit an external `cancel` frame when a caller-side `AbortSignal`
|
||||
* fires.
|
||||
*/
|
||||
export interface PendingCall {
|
||||
readonly id: number;
|
||||
readonly resolve: (value: unknown) => void;
|
||||
readonly reject: (error: Error) => void;
|
||||
readonly onCancel?: () => void;
|
||||
timer?: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry for outstanding calls. Implements timeout eviction and
|
||||
* orphan-response detection — both stand on the same fast lookup-by-id
|
||||
* path so neither costs more than `O(1)` per inbound frame.
|
||||
*/
|
||||
export class PendingRegistry {
|
||||
private readonly map = new Map<number, PendingCall>();
|
||||
|
||||
public get size(): number {
|
||||
return this.map.size;
|
||||
}
|
||||
|
||||
public register(call: PendingCall): void {
|
||||
if (this.map.has(call.id)) {
|
||||
throw new WorkerRpcError(
|
||||
"WORKER_RPC.CORRELATION_ID_REUSED",
|
||||
"correlation id reused while previous call still in flight"
|
||||
);
|
||||
}
|
||||
this.map.set(call.id, call);
|
||||
}
|
||||
|
||||
public settle(
|
||||
id: number,
|
||||
outcome: { ok: true; value: unknown } | { ok: false; error: Error }
|
||||
): void {
|
||||
const call = this.map.get(id);
|
||||
if (call === undefined) {
|
||||
throw new WorkerRpcError(
|
||||
"WORKER_RPC.ORPHAN_RESPONSE",
|
||||
`no pending call for response id ${id}`
|
||||
);
|
||||
}
|
||||
this.map.delete(id);
|
||||
if (call.timer !== undefined) {
|
||||
clearTimeout(call.timer);
|
||||
}
|
||||
if (outcome.ok) {
|
||||
call.resolve(outcome.value);
|
||||
} else {
|
||||
call.reject(outcome.error);
|
||||
}
|
||||
}
|
||||
|
||||
public has(id: number): boolean {
|
||||
return this.map.has(id);
|
||||
}
|
||||
|
||||
public cancel(id: number): boolean {
|
||||
const call = this.map.get(id);
|
||||
if (call === undefined) return false;
|
||||
this.map.delete(id);
|
||||
if (call.timer !== undefined) clearTimeout(call.timer);
|
||||
call.onCancel?.();
|
||||
call.reject(
|
||||
new WorkerRpcError(
|
||||
"WORKER_RPC.CANCELLED",
|
||||
`call ${id} cancelled before completion`
|
||||
)
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
public clear(reason: Error): void {
|
||||
for (const call of this.map.values()) {
|
||||
if (call.timer !== undefined) clearTimeout(call.timer);
|
||||
call.reject(reason);
|
||||
}
|
||||
this.map.clear();
|
||||
}
|
||||
|
||||
public attachTimeout(id: number, ms: number): void {
|
||||
const call = this.map.get(id);
|
||||
if (call === undefined) return;
|
||||
call.timer = setTimeout(() => {
|
||||
const live = this.map.get(id);
|
||||
if (live !== call) return;
|
||||
this.map.delete(id);
|
||||
call.reject(
|
||||
new WorkerRpcError(
|
||||
"WORKER_RPC.REQUEST_TIMEOUT",
|
||||
`call ${id} timed out after ${ms}ms`
|
||||
)
|
||||
);
|
||||
}, ms);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type CorrelationIdGenerator,
|
||||
createCorrelationIdGenerator,
|
||||
type PendingCall,
|
||||
PendingRegistry,
|
||||
} from "./correlation.js";
|
||||
@@ -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,42 @@
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { WORKER_RPC_CODES, WorkerRpcError } from "./error-codes.js";
|
||||
|
||||
describe("error-codes", () => {
|
||||
it("defines every documented code", { timeout: 5000 }, () => {
|
||||
const codes = Object.values(WORKER_RPC_CODES);
|
||||
expect(codes).toContain("WORKER_RPC.PORT_CLOSED");
|
||||
expect(codes).toContain("WORKER_RPC.REQUEST_TIMEOUT");
|
||||
expect(codes).toContain("WORKER_RPC.INVALID_FRAME");
|
||||
expect(codes).toContain("WORKER_RPC.UNKNOWN_METHOD");
|
||||
expect(codes).toContain("WORKER_RPC.CORRELATION_ID_REUSED");
|
||||
expect(codes).toContain("WORKER_RPC.STREAM_BACKPRESSURE_EXCEEDED");
|
||||
expect(codes).toContain("WORKER_RPC.HANDLER_THREW");
|
||||
expect(codes).toContain("WORKER_RPC.CANCELLED");
|
||||
expect(codes).toContain("WORKER_RPC.VERSION_MISMATCH");
|
||||
expect(codes).toContain("WORKER_RPC.ORPHAN_RESPONSE");
|
||||
expect(codes).toContain("WORKER_RPC.NOT_CLONEABLE");
|
||||
expect(codes).toContain("WORKER_RPC.REMOTE_THROW");
|
||||
expect(codes).toContain("WORKER_RPC.BACKPRESSURE");
|
||||
expect(codes).toContain("WORKER_RPC.RESPONSE_TIMEOUT");
|
||||
});
|
||||
|
||||
it("WorkerRpcError extends SuaveplanError", { timeout: 5000 }, () => {
|
||||
const err = new WorkerRpcError("WORKER_RPC.PORT_CLOSED", "closed");
|
||||
expect(err).toBeInstanceOf(SuaveplanError);
|
||||
expect(err).toBeInstanceOf(WorkerRpcError);
|
||||
expect(err.code).toBe("WORKER_RPC.PORT_CLOSED");
|
||||
expect(err.name).toBe("WorkerRpcError");
|
||||
});
|
||||
|
||||
it("preserves cause and context options", { timeout: 5000 }, () => {
|
||||
const inner = new Error("inner");
|
||||
const err = new WorkerRpcError(
|
||||
"WORKER_RPC.HANDLER_THREW",
|
||||
"handler failed",
|
||||
{ cause: inner, context: { method: "x" } }
|
||||
);
|
||||
expect(err.cause).toBe(inner);
|
||||
expect(err.context).toEqual({ method: "x" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/api-worker-rpc`.
|
||||
*
|
||||
* Every public failure path raises a {@link WorkerRpcError} carrying one of
|
||||
* these namespaced `WORKER_RPC.*` codes. Consumers branch on the literal
|
||||
* value (e.g. `error.code === WORKER_RPC_CODES.PORT_CLOSED`).
|
||||
*/
|
||||
export const WORKER_RPC_CODES = defineCodes("WORKER_RPC", [
|
||||
"PORT_CLOSED",
|
||||
"REQUEST_TIMEOUT",
|
||||
"RESPONSE_TIMEOUT",
|
||||
"INVALID_FRAME",
|
||||
"UNKNOWN_METHOD",
|
||||
"CORRELATION_ID_REUSED",
|
||||
"STREAM_BACKPRESSURE_EXCEEDED",
|
||||
"HANDLER_THREW",
|
||||
"CANCELLED",
|
||||
"VERSION_MISMATCH",
|
||||
"ORPHAN_RESPONSE",
|
||||
"NOT_CLONEABLE",
|
||||
"REMOTE_THROW",
|
||||
"BACKPRESSURE",
|
||||
] as const);
|
||||
|
||||
/** Union of every fully-qualified code in {@link WORKER_RPC_CODES}. */
|
||||
export type WorkerRpcCode =
|
||||
(typeof WORKER_RPC_CODES)[keyof typeof WORKER_RPC_CODES];
|
||||
|
||||
/**
|
||||
* Typed error class for `@suaveplan/api-worker-rpc`. Extends
|
||||
* {@link SuaveplanError} so callers may discriminate via either
|
||||
* `instanceof WorkerRpcError` or by code string.
|
||||
*/
|
||||
export class WorkerRpcError extends SuaveplanError<WorkerRpcCode> {
|
||||
public constructor(
|
||||
code: WorkerRpcCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.name = "WorkerRpcError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
WORKER_RPC_CODES,
|
||||
type WorkerRpcCode,
|
||||
WorkerRpcError,
|
||||
} from "./error-codes.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,204 @@
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { WorkerRpcError } from "../error-codes/error-codes.js";
|
||||
import { envelopeToError, errorToEnvelope } from "./error-reconstruction.js";
|
||||
|
||||
class TestError extends SuaveplanError<"TEST.A" | "TEST.B"> {
|
||||
public constructor(
|
||||
code: "TEST.A" | "TEST.B",
|
||||
msg: string,
|
||||
options?: { cause?: unknown }
|
||||
) {
|
||||
super(code, msg, options);
|
||||
this.name = "TestError";
|
||||
}
|
||||
}
|
||||
|
||||
describe("error-reconstruction", () => {
|
||||
it("SuaveplanError round-trips with cause", { timeout: 5000 }, () => {
|
||||
const inner = new TestError("TEST.B", "inner reason");
|
||||
const outer = new TestError("TEST.A", "outer reason", { cause: inner });
|
||||
const env = errorToEnvelope(outer);
|
||||
expect(env.code).toBe("TEST.A");
|
||||
expect(env.cause?.code).toBe("TEST.B");
|
||||
const back = envelopeToError(env);
|
||||
expect(back).toBeInstanceOf(SuaveplanError);
|
||||
expect((back as SuaveplanError<string>).code).toBe("TEST.A");
|
||||
expect(
|
||||
((back as SuaveplanError<string>).cause as SuaveplanError<string>)
|
||||
.code
|
||||
).toBe("TEST.B");
|
||||
});
|
||||
|
||||
it("WorkerRpcError envelope reconstructs as WorkerRpcError", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new WorkerRpcError(
|
||||
"WORKER_RPC.HANDLER_THREW",
|
||||
"remote handler crashed"
|
||||
);
|
||||
const back = envelopeToError(errorToEnvelope(err));
|
||||
expect(back).toBeInstanceOf(WorkerRpcError);
|
||||
expect((back as WorkerRpcError).code).toBe("WORKER_RPC.HANDLER_THREW");
|
||||
});
|
||||
|
||||
it("plain Error becomes WORKER_RPC.REMOTE_THROW", { timeout: 5000 }, () => {
|
||||
const env = errorToEnvelope(new TypeError("boom"));
|
||||
expect(env.code).toBe("WORKER_RPC.REMOTE_THROW");
|
||||
expect(env.details?.["remoteName"]).toBe("TypeError");
|
||||
const back = envelopeToError(env);
|
||||
expect((back as WorkerRpcError).code).toBe("WORKER_RPC.REMOTE_THROW");
|
||||
});
|
||||
|
||||
it("non-Error value becomes REMOTE_THROW", { timeout: 5000 }, () => {
|
||||
const env = errorToEnvelope("plain string");
|
||||
expect(env.code).toBe("WORKER_RPC.REMOTE_THROW");
|
||||
expect(env.message).toBe("plain string");
|
||||
const env2 = errorToEnvelope({ obscure: true });
|
||||
expect(env2.code).toBe("WORKER_RPC.REMOTE_THROW");
|
||||
});
|
||||
|
||||
it("depth-bounded cause chain truncates", { timeout: 5000 }, () => {
|
||||
let last: TestError | undefined;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const next: TestError = new TestError("TEST.A", `level-${i}`, {
|
||||
cause: last,
|
||||
});
|
||||
last = next;
|
||||
}
|
||||
const env = errorToEnvelope(last as TestError);
|
||||
let depth = 0;
|
||||
let cur: typeof env | undefined = env;
|
||||
while (cur !== undefined) {
|
||||
depth++;
|
||||
cur = cur.cause;
|
||||
}
|
||||
// 5 real + 1 truncation marker
|
||||
expect(depth).toBeLessThanOrEqual(6);
|
||||
});
|
||||
|
||||
it("preserves stack as remoteStack", { timeout: 5000 }, () => {
|
||||
const err = new WorkerRpcError("WORKER_RPC.CANCELLED", "cancel");
|
||||
const env = errorToEnvelope(err);
|
||||
expect(env.remoteStack).toBeDefined();
|
||||
const back = envelopeToError(env);
|
||||
expect(back.stack).toBe(env.remoteStack);
|
||||
});
|
||||
|
||||
it("envelope-to-error preserves cause chain on foreign codes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const env = {
|
||||
code: "X.Y.Z",
|
||||
name: "Outer",
|
||||
message: "outer",
|
||||
cause: { code: "X.Y.W", name: "Inner", message: "inner" },
|
||||
};
|
||||
const back = envelopeToError(env);
|
||||
expect((back as SuaveplanError<string>).code).toBe("X.Y.Z");
|
||||
expect(back.name).toBe("Outer");
|
||||
expect(
|
||||
((back as SuaveplanError<string>).cause as SuaveplanError<string>)
|
||||
.code
|
||||
).toBe("X.Y.W");
|
||||
});
|
||||
|
||||
it("Error with cause chains via remoteThrow", { timeout: 5000 }, () => {
|
||||
const inner = new RangeError("inner");
|
||||
const outer = new Error("outer", { cause: inner });
|
||||
const env = errorToEnvelope(outer);
|
||||
expect(env.cause).toBeDefined();
|
||||
expect(env.cause?.code).toBe("WORKER_RPC.REMOTE_THROW");
|
||||
});
|
||||
|
||||
it("SuaveplanError without stack still envelopes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new WorkerRpcError("WORKER_RPC.CANCELLED", "x");
|
||||
Object.defineProperty(err, "stack", { value: undefined });
|
||||
const env = errorToEnvelope(err);
|
||||
expect(env.remoteStack).toBeUndefined();
|
||||
});
|
||||
|
||||
it("WORKER_RPC envelope without remoteStack reconstructs", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const back = envelopeToError({
|
||||
code: "WORKER_RPC.CANCELLED",
|
||||
name: "WorkerRpcError",
|
||||
message: "x",
|
||||
});
|
||||
expect((back as WorkerRpcError).code).toBe("WORKER_RPC.CANCELLED");
|
||||
});
|
||||
|
||||
it("generic foreign envelope without remoteStack reconstructs", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const back = envelopeToError({
|
||||
code: "FOO.BAR",
|
||||
name: "FooError",
|
||||
message: "x",
|
||||
});
|
||||
expect((back as SuaveplanError<string>).code).toBe("FOO.BAR");
|
||||
});
|
||||
|
||||
it("WorkerRpcError envelope reconstructs with cause + details", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const env = {
|
||||
code: "WORKER_RPC.HANDLER_THREW",
|
||||
name: "WorkerRpcError",
|
||||
message: "x",
|
||||
details: { foo: 1 },
|
||||
cause: {
|
||||
code: "WORKER_RPC.CANCELLED",
|
||||
name: "WorkerRpcError",
|
||||
message: "inner",
|
||||
details: { bar: 2 },
|
||||
},
|
||||
};
|
||||
const back = envelopeToError(env);
|
||||
expect((back as WorkerRpcError).code).toBe("WORKER_RPC.HANDLER_THREW");
|
||||
expect((back as WorkerRpcError).context).toEqual({ foo: 1 });
|
||||
const c = (back as WorkerRpcError).cause as WorkerRpcError;
|
||||
expect(c.code).toBe("WORKER_RPC.CANCELLED");
|
||||
expect(c.context).toEqual({ bar: 2 });
|
||||
});
|
||||
|
||||
it("generic envelope reconstructs with cause + details", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const env = {
|
||||
code: "FOO.BAR",
|
||||
name: "FooError",
|
||||
message: "x",
|
||||
details: { foo: 1 },
|
||||
cause: {
|
||||
code: "FOO.BAZ",
|
||||
name: "FooError",
|
||||
message: "inner",
|
||||
details: { bar: 2 },
|
||||
},
|
||||
};
|
||||
const back = envelopeToError(env);
|
||||
expect((back as SuaveplanError<string>).code).toBe("FOO.BAR");
|
||||
expect((back as SuaveplanError<string>).context).toEqual({ foo: 1 });
|
||||
});
|
||||
|
||||
it("foreign Error without stack still envelopes", { timeout: 5000 }, () => {
|
||||
const e = new Error("x");
|
||||
Object.defineProperty(e, "stack", { value: undefined });
|
||||
const env = errorToEnvelope(e);
|
||||
expect(env.remoteStack).toBeUndefined();
|
||||
});
|
||||
|
||||
it("details survive round-trip", { timeout: 5000 }, () => {
|
||||
const err = new WorkerRpcError("WORKER_RPC.HANDLER_THREW", "x", {
|
||||
context: { foo: 1 },
|
||||
});
|
||||
const env = errorToEnvelope(err);
|
||||
expect(env.details).toEqual({ foo: 1 });
|
||||
const back = envelopeToError(env);
|
||||
expect((back as WorkerRpcError).context).toEqual({ foo: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
import { WorkerRpcError } from "../error-codes/error-codes.js";
|
||||
import type { ErrorEnvelope } from "../protocol/protocol.js";
|
||||
|
||||
const MAX_CAUSE_DEPTH = 5;
|
||||
|
||||
/**
|
||||
* Serialise any thrown value into an {@link ErrorEnvelope} suitable for
|
||||
* the wire. `SuaveplanError` retains its `code`, `name`, and (depth-bounded)
|
||||
* cause chain; foreign errors keep their constructor name in `details.remoteName`.
|
||||
*/
|
||||
export function errorToEnvelope(value: unknown, depth = 0): ErrorEnvelope {
|
||||
if (depth >= MAX_CAUSE_DEPTH) {
|
||||
return {
|
||||
code: "ERROR.CAUSE.TRUNCATED",
|
||||
name: "TruncatedCause",
|
||||
message: "[cause chain truncated]",
|
||||
};
|
||||
}
|
||||
if (value instanceof SuaveplanError) {
|
||||
const env: {
|
||||
code: string;
|
||||
name: string;
|
||||
message: string;
|
||||
details?: Readonly<Record<string, unknown>>;
|
||||
remoteStack?: string;
|
||||
cause?: ErrorEnvelope;
|
||||
} = {
|
||||
code: value.code,
|
||||
name: value.name,
|
||||
message: value.message,
|
||||
};
|
||||
if (value.context !== undefined) {
|
||||
env.details = value.context;
|
||||
}
|
||||
if (typeof value.stack === "string") {
|
||||
env.remoteStack = value.stack;
|
||||
}
|
||||
if (value.cause !== undefined) {
|
||||
env.cause = errorToEnvelope(value.cause, depth + 1);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
if (value instanceof Error) {
|
||||
const details: Record<string, unknown> = { remoteName: value.name };
|
||||
const env: {
|
||||
code: string;
|
||||
name: string;
|
||||
message: string;
|
||||
details?: Readonly<Record<string, unknown>>;
|
||||
remoteStack?: string;
|
||||
cause?: ErrorEnvelope;
|
||||
} = {
|
||||
code: "WORKER_RPC.REMOTE_THROW",
|
||||
name: "WorkerRpcError",
|
||||
message: value.message,
|
||||
details,
|
||||
};
|
||||
if (typeof value.stack === "string") {
|
||||
env.remoteStack = value.stack;
|
||||
}
|
||||
if (value.cause !== undefined) {
|
||||
env.cause = errorToEnvelope(value.cause, depth + 1);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
return {
|
||||
code: "WORKER_RPC.REMOTE_THROW",
|
||||
name: "WorkerRpcError",
|
||||
message: typeof value === "string" ? value : String(value),
|
||||
details: { remoteName: "NonError" },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct an `Error` from an envelope. `WORKER_RPC.*` codes become a
|
||||
* fresh `WorkerRpcError`; every other code becomes a generic
|
||||
* `SuaveplanError` subclass with the original code preserved (`name` and
|
||||
* `code` are both stable, satisfying `instanceof SuaveplanError`).
|
||||
*/
|
||||
export function envelopeToError(env: ErrorEnvelope): Error {
|
||||
const cause =
|
||||
env.cause !== undefined ? envelopeToError(env.cause) : undefined;
|
||||
if (env.code.startsWith("WORKER_RPC.")) {
|
||||
return buildWorkerRpcError(env, cause);
|
||||
}
|
||||
return buildGenericReconstructed(env, cause);
|
||||
}
|
||||
|
||||
function buildWorkerRpcError(
|
||||
env: ErrorEnvelope,
|
||||
cause: Error | undefined
|
||||
): WorkerRpcError {
|
||||
const opts: {
|
||||
cause?: unknown;
|
||||
context?: Readonly<Record<string, unknown>>;
|
||||
} = {};
|
||||
if (cause !== undefined) opts.cause = cause;
|
||||
if (env.details !== undefined) opts.context = env.details;
|
||||
const err = new WorkerRpcError(
|
||||
env.code as ConstructorParameters<typeof WorkerRpcError>[0],
|
||||
env.message,
|
||||
opts
|
||||
);
|
||||
if (env.remoteStack !== undefined) {
|
||||
Object.defineProperty(err, "stack", {
|
||||
value: env.remoteStack,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic concrete subclass of {@link SuaveplanError} used for foreign
|
||||
* codes — preserves the code string + cause chain so callers can branch
|
||||
* on `error.code`.
|
||||
*/
|
||||
class ReconstructedSuaveplanError extends SuaveplanError<string> {
|
||||
public constructor(
|
||||
code: string,
|
||||
message: string,
|
||||
options: {
|
||||
cause?: unknown;
|
||||
context?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.name = "ReconstructedSuaveplanError";
|
||||
}
|
||||
}
|
||||
|
||||
function buildGenericReconstructed(
|
||||
env: ErrorEnvelope,
|
||||
cause: Error | undefined
|
||||
): SuaveplanError<string> {
|
||||
const opts: {
|
||||
cause?: unknown;
|
||||
context?: Readonly<Record<string, unknown>>;
|
||||
} = {};
|
||||
if (cause !== undefined) opts.cause = cause;
|
||||
if (env.details !== undefined) opts.context = env.details;
|
||||
const err = new ReconstructedSuaveplanError(env.code, env.message, opts);
|
||||
err.name = env.name;
|
||||
if (env.remoteStack !== undefined) {
|
||||
Object.defineProperty(err, "stack", {
|
||||
value: env.remoteStack,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
return err;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
envelopeToError,
|
||||
errorToEnvelope,
|
||||
} from "./error-reconstruction.js";
|
||||
95
packages/api-stack/api/api-worker-rpc/src/index.ts
Normal file
95
packages/api-stack/api/api-worker-rpc/src/index.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
// Error codes
|
||||
|
||||
// Abort forwarding
|
||||
export {
|
||||
AbortError,
|
||||
type AbortLink,
|
||||
attachAbortSignal,
|
||||
createRemoteAbort,
|
||||
type RemoteAbort,
|
||||
} from "./abort-forwarding/index.js";
|
||||
// Backpressure
|
||||
export {
|
||||
type BackpressureMode,
|
||||
type BackpressurePolicy,
|
||||
BoundedQueue,
|
||||
CallBudget,
|
||||
DEFAULT_BACKPRESSURE,
|
||||
} from "./backpressure/index.js";
|
||||
// Client
|
||||
export {
|
||||
assertCloneable,
|
||||
createRawWorkerRpcClient,
|
||||
createWorkerRpcClient,
|
||||
type WorkerProxyClient,
|
||||
type WorkerRpcClient,
|
||||
type WorkerRpcClientOptions,
|
||||
} from "./client/index.js";
|
||||
// Correlation
|
||||
export {
|
||||
type CorrelationIdGenerator,
|
||||
createCorrelationIdGenerator,
|
||||
type PendingCall,
|
||||
PendingRegistry,
|
||||
} from "./correlation/index.js";
|
||||
export {
|
||||
WORKER_RPC_CODES,
|
||||
type WorkerRpcCode,
|
||||
WorkerRpcError,
|
||||
} from "./error-codes/index.js";
|
||||
// Error reconstruction
|
||||
export {
|
||||
envelopeToError,
|
||||
errorToEnvelope,
|
||||
} from "./error-reconstruction/index.js";
|
||||
// Protocol
|
||||
export {
|
||||
type CancelFrame,
|
||||
cancelFrameSchema,
|
||||
type ErrorEnvelope,
|
||||
errorEnvelopeSchema,
|
||||
type GoodbyeFrame,
|
||||
goodbyeFrameSchema,
|
||||
type HelloFrame,
|
||||
helloFrameSchema,
|
||||
PROTOCOL_VERSION,
|
||||
type ProgressFrame,
|
||||
parseFrame,
|
||||
progressFrameSchema,
|
||||
type RequestFrame,
|
||||
type ResponseFrame,
|
||||
requestFrameSchema,
|
||||
responseFrameSchema,
|
||||
type StreamAckFrame,
|
||||
type StreamChunkFrame,
|
||||
type StreamEndFrame,
|
||||
type StreamOpenFrame,
|
||||
streamAckFrameSchema,
|
||||
streamChunkFrameSchema,
|
||||
streamEndFrameSchema,
|
||||
streamOpenFrameSchema,
|
||||
type WorkerRpcFrame,
|
||||
workerRpcFrameSchema,
|
||||
} from "./protocol/index.js";
|
||||
// Server
|
||||
export {
|
||||
type ServeWorkerRpcOptions,
|
||||
serveWorkerRpc,
|
||||
type WorkerRpcHandler,
|
||||
type WorkerRpcHandlers,
|
||||
type WorkerRpcServer,
|
||||
} from "./server/index.js";
|
||||
// Streaming
|
||||
export {
|
||||
StreamDispatcher,
|
||||
type StreamRequestOptions,
|
||||
streamRequest,
|
||||
} from "./streaming/index.js";
|
||||
// Testing
|
||||
export {
|
||||
type ConformanceHarness,
|
||||
createInProcessPortPair,
|
||||
type InProcessPortPair,
|
||||
type PortLike,
|
||||
runWorkerRpcConformance,
|
||||
} from "./testing/index.js";
|
||||
28
packages/api-stack/api/api-worker-rpc/src/protocol/index.ts
Normal file
28
packages/api-stack/api/api-worker-rpc/src/protocol/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export {
|
||||
type CancelFrame,
|
||||
cancelFrameSchema,
|
||||
type ErrorEnvelope,
|
||||
errorEnvelopeSchema,
|
||||
type GoodbyeFrame,
|
||||
goodbyeFrameSchema,
|
||||
type HelloFrame,
|
||||
helloFrameSchema,
|
||||
PROTOCOL_VERSION,
|
||||
type ProgressFrame,
|
||||
parseFrame,
|
||||
progressFrameSchema,
|
||||
type RequestFrame,
|
||||
type ResponseFrame,
|
||||
requestFrameSchema,
|
||||
responseFrameSchema,
|
||||
type StreamAckFrame,
|
||||
type StreamChunkFrame,
|
||||
type StreamEndFrame,
|
||||
type StreamOpenFrame,
|
||||
streamAckFrameSchema,
|
||||
streamChunkFrameSchema,
|
||||
streamEndFrameSchema,
|
||||
streamOpenFrameSchema,
|
||||
type WorkerRpcFrame,
|
||||
workerRpcFrameSchema,
|
||||
} from "./protocol.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
PROTOCOL_VERSION,
|
||||
parseFrame,
|
||||
workerRpcFrameSchema,
|
||||
} from "./protocol.js";
|
||||
|
||||
describe("protocol", () => {
|
||||
it("parses every documented frame kind", { timeout: 5000 }, () => {
|
||||
const kinds = [
|
||||
{
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "request",
|
||||
id: 1,
|
||||
method: "m",
|
||||
params: [],
|
||||
},
|
||||
{
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "response",
|
||||
ok: true,
|
||||
id: 1,
|
||||
result: 42,
|
||||
},
|
||||
{
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "response",
|
||||
ok: false,
|
||||
id: 1,
|
||||
error: { code: "X.Y", name: "X", message: "boom" },
|
||||
},
|
||||
{ v: PROTOCOL_VERSION, kind: "cancel", id: 1 },
|
||||
{ v: PROTOCOL_VERSION, kind: "progress", id: 1, chunk: "p" },
|
||||
{
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "stream-open",
|
||||
id: 1,
|
||||
method: "s",
|
||||
params: [],
|
||||
},
|
||||
{ v: PROTOCOL_VERSION, kind: "stream-chunk", id: 1, chunk: 1 },
|
||||
{ v: PROTOCOL_VERSION, kind: "stream-end", ok: true, id: 1 },
|
||||
{
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "stream-end",
|
||||
ok: false,
|
||||
id: 1,
|
||||
error: { code: "X.Y", name: "X", message: "b" },
|
||||
},
|
||||
{ v: PROTOCOL_VERSION, kind: "stream-ack", id: 1, delta: 1 },
|
||||
{
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "hello",
|
||||
hostVersion: "1.0",
|
||||
capabilities: ["db"],
|
||||
},
|
||||
{ v: PROTOCOL_VERSION, kind: "goodbye", reason: "shutdown" },
|
||||
];
|
||||
for (const frame of kinds) {
|
||||
const parsed = parseFrame(frame);
|
||||
expect(parsed.kind).toBe(frame.kind);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects malformed frames", { timeout: 5000 }, () => {
|
||||
expect(() => parseFrame({})).toThrow();
|
||||
expect(() =>
|
||||
parseFrame({
|
||||
v: "1.0.0",
|
||||
kind: "request",
|
||||
id: -1,
|
||||
method: "m",
|
||||
params: [],
|
||||
})
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
parseFrame({
|
||||
v: "1.0.0",
|
||||
kind: "request",
|
||||
id: 1,
|
||||
method: "",
|
||||
params: [],
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("accepts nested cause in error envelope", { timeout: 5000 }, () => {
|
||||
const env = {
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "response",
|
||||
ok: false,
|
||||
id: 1,
|
||||
error: {
|
||||
code: "A.B",
|
||||
name: "A",
|
||||
message: "outer",
|
||||
cause: { code: "A.C", name: "A", message: "inner" },
|
||||
},
|
||||
};
|
||||
const parsed = workerRpcFrameSchema.parse(env);
|
||||
if (parsed.kind === "response" && !parsed.ok) {
|
||||
expect(parsed.error.cause?.code).toBe("A.C");
|
||||
} else {
|
||||
throw new Error("expected error response");
|
||||
}
|
||||
});
|
||||
});
|
||||
154
packages/api-stack/api/api-worker-rpc/src/protocol/protocol.ts
Normal file
154
packages/api-stack/api/api-worker-rpc/src/protocol/protocol.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Wire protocol version embedded in every framed message. Receivers reject
|
||||
* messages whose major version is incompatible with their own.
|
||||
*/
|
||||
export const PROTOCOL_VERSION = "1.0.0" as const;
|
||||
|
||||
/**
|
||||
* Branded numeric correlation id. Aligns with `@suaveplan/api-types`'s
|
||||
* `WorkerRpcCorrelationId` shape: a number with a phantom brand.
|
||||
*/
|
||||
export const correlationIdSchema = z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.finite() as unknown as z.ZodType<number>;
|
||||
|
||||
/**
|
||||
* Serialised error envelope. Mirrors `@suaveplan/error`'s `WireError`
|
||||
* shape closely enough for boundary use without re-exporting the schema.
|
||||
*/
|
||||
export interface ErrorEnvelope {
|
||||
readonly code: string;
|
||||
readonly name: string;
|
||||
readonly message: string;
|
||||
readonly details?: Readonly<Record<string, unknown>> | undefined;
|
||||
readonly remoteStack?: string | undefined;
|
||||
readonly cause?: ErrorEnvelope | undefined;
|
||||
}
|
||||
|
||||
export const errorEnvelopeSchema: z.ZodType<ErrorEnvelope> = z.lazy(() =>
|
||||
z.object({
|
||||
code: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
message: z.string(),
|
||||
details: z.record(z.string(), z.unknown()).optional(),
|
||||
remoteStack: z.string().optional(),
|
||||
cause: errorEnvelopeSchema.optional(),
|
||||
})
|
||||
);
|
||||
|
||||
const baseFrame = z.object({ v: z.string().min(1) });
|
||||
|
||||
export const requestFrameSchema = baseFrame.extend({
|
||||
kind: z.literal("request"),
|
||||
id: correlationIdSchema,
|
||||
method: z.string().min(1),
|
||||
params: z.array(z.unknown()),
|
||||
});
|
||||
|
||||
export const responseFrameSchema = z.discriminatedUnion("ok", [
|
||||
baseFrame.extend({
|
||||
kind: z.literal("response"),
|
||||
ok: z.literal(true),
|
||||
id: correlationIdSchema,
|
||||
result: z.unknown(),
|
||||
}),
|
||||
baseFrame.extend({
|
||||
kind: z.literal("response"),
|
||||
ok: z.literal(false),
|
||||
id: correlationIdSchema,
|
||||
error: errorEnvelopeSchema,
|
||||
}),
|
||||
]);
|
||||
|
||||
export const cancelFrameSchema = baseFrame.extend({
|
||||
kind: z.literal("cancel"),
|
||||
id: correlationIdSchema,
|
||||
});
|
||||
|
||||
export const progressFrameSchema = baseFrame.extend({
|
||||
kind: z.literal("progress"),
|
||||
id: correlationIdSchema,
|
||||
chunk: z.unknown(),
|
||||
});
|
||||
|
||||
export const streamOpenFrameSchema = baseFrame.extend({
|
||||
kind: z.literal("stream-open"),
|
||||
id: correlationIdSchema,
|
||||
method: z.string().min(1),
|
||||
params: z.array(z.unknown()),
|
||||
});
|
||||
|
||||
export const streamChunkFrameSchema = baseFrame.extend({
|
||||
kind: z.literal("stream-chunk"),
|
||||
id: correlationIdSchema,
|
||||
chunk: z.unknown(),
|
||||
});
|
||||
|
||||
export const streamEndFrameSchema = z.discriminatedUnion("ok", [
|
||||
baseFrame.extend({
|
||||
kind: z.literal("stream-end"),
|
||||
ok: z.literal(true),
|
||||
id: correlationIdSchema,
|
||||
}),
|
||||
baseFrame.extend({
|
||||
kind: z.literal("stream-end"),
|
||||
ok: z.literal(false),
|
||||
id: correlationIdSchema,
|
||||
error: errorEnvelopeSchema,
|
||||
}),
|
||||
]);
|
||||
|
||||
export const streamAckFrameSchema = baseFrame.extend({
|
||||
kind: z.literal("stream-ack"),
|
||||
id: correlationIdSchema,
|
||||
delta: z.number().int().positive(),
|
||||
});
|
||||
|
||||
export const helloFrameSchema = baseFrame.extend({
|
||||
kind: z.literal("hello"),
|
||||
hostVersion: z.string().min(1),
|
||||
capabilities: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const goodbyeFrameSchema = baseFrame.extend({
|
||||
kind: z.literal("goodbye"),
|
||||
reason: z.string(),
|
||||
});
|
||||
|
||||
export const workerRpcFrameSchema = z.union([
|
||||
requestFrameSchema,
|
||||
responseFrameSchema,
|
||||
cancelFrameSchema,
|
||||
progressFrameSchema,
|
||||
streamOpenFrameSchema,
|
||||
streamChunkFrameSchema,
|
||||
streamEndFrameSchema,
|
||||
streamAckFrameSchema,
|
||||
helloFrameSchema,
|
||||
goodbyeFrameSchema,
|
||||
]);
|
||||
|
||||
export type RequestFrame = z.infer<typeof requestFrameSchema>;
|
||||
export type ResponseFrame = z.infer<typeof responseFrameSchema>;
|
||||
export type CancelFrame = z.infer<typeof cancelFrameSchema>;
|
||||
export type ProgressFrame = z.infer<typeof progressFrameSchema>;
|
||||
export type StreamOpenFrame = z.infer<typeof streamOpenFrameSchema>;
|
||||
export type StreamChunkFrame = z.infer<typeof streamChunkFrameSchema>;
|
||||
export type StreamEndFrame = z.infer<typeof streamEndFrameSchema>;
|
||||
export type StreamAckFrame = z.infer<typeof streamAckFrameSchema>;
|
||||
export type HelloFrame = z.infer<typeof helloFrameSchema>;
|
||||
export type GoodbyeFrame = z.infer<typeof goodbyeFrameSchema>;
|
||||
export type WorkerRpcFrame = z.infer<typeof workerRpcFrameSchema>;
|
||||
|
||||
/**
|
||||
* Parse and validate an incoming wire frame. Returns a discriminated
|
||||
* union; throws `ZodError` on malformed input (callers map to
|
||||
* `WORKER_RPC.INVALID_FRAME`).
|
||||
*/
|
||||
export function parseFrame(input: unknown): WorkerRpcFrame {
|
||||
return workerRpcFrameSchema.parse(input);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
type ServeWorkerRpcOptions,
|
||||
serveWorkerRpc,
|
||||
type WorkerRpcHandler,
|
||||
type WorkerRpcHandlers,
|
||||
type WorkerRpcServer,
|
||||
} from "./server.js";
|
||||
101
packages/api-stack/api/api-worker-rpc/src/server/server.md
Normal file
101
packages/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.
|
||||
430
packages/api-stack/api/api-worker-rpc/src/server/server.test.ts
Normal file
430
packages/api-stack/api/api-worker-rpc/src/server/server.test.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createRawWorkerRpcClient } from "../client/client.js";
|
||||
import { PROTOCOL_VERSION } from "../protocol/protocol.js";
|
||||
import { createInProcessPortPair } from "../testing/testing.js";
|
||||
import { serveWorkerRpc } from "./server.js";
|
||||
|
||||
describe("server", () => {
|
||||
it("dispatches request to handler", { timeout: 5000 }, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
const server = serveWorkerRpc(ports.serverPort, {
|
||||
id: (p) => p[0],
|
||||
});
|
||||
const client = createRawWorkerRpcClient(ports.clientPort);
|
||||
await expect(client.call("id", [99])).resolves.toBe(99);
|
||||
client.close();
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("unknown method returns UNKNOWN_METHOD", { timeout: 5000 }, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
const server = serveWorkerRpc(ports.serverPort, {});
|
||||
const client = createRawWorkerRpcClient(ports.clientPort);
|
||||
await expect(client.call("missing", [])).rejects.toMatchObject({
|
||||
code: "WORKER_RPC.UNKNOWN_METHOD",
|
||||
});
|
||||
client.close();
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("handler throw round-trips as REMOTE_THROW", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
const server = serveWorkerRpc(ports.serverPort, {
|
||||
x: () => {
|
||||
throw new Error("nope");
|
||||
},
|
||||
});
|
||||
const client = createRawWorkerRpcClient(ports.clientPort);
|
||||
await expect(client.call("x", [])).rejects.toMatchObject({
|
||||
code: "WORKER_RPC.REMOTE_THROW",
|
||||
});
|
||||
client.close();
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("cancel triggers handler AbortSignal", { timeout: 5000 }, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
let observed = false;
|
||||
const server = serveWorkerRpc(ports.serverPort, {
|
||||
wait: (_p, signal) =>
|
||||
new Promise((resolve) => {
|
||||
signal.addEventListener("abort", () => {
|
||||
observed = true;
|
||||
resolve("aborted");
|
||||
});
|
||||
}),
|
||||
});
|
||||
const client = createRawWorkerRpcClient(ports.clientPort);
|
||||
const ac = new AbortController();
|
||||
const p = client.call("wait", [], { signal: ac.signal });
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
ac.abort();
|
||||
await expect(p).rejects.toMatchObject({ name: "AbortError" });
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(observed).toBe(true);
|
||||
client.close();
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("malformed inbound frame replies with error envelope", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
const server = serveWorkerRpc(ports.serverPort, {});
|
||||
const seen: unknown[] = [];
|
||||
ports.clientPort.addEventListener("message", (e) => seen.push(e.data));
|
||||
ports.serverPort.postMessage({ junk: true });
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
// Server can't actually receive its own postMessage, so we need to
|
||||
// simulate from the *client* side by posting through clientPort.
|
||||
ports.clientPort.postMessage({ junk: true });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const errFrame = seen.find(
|
||||
(f): f is { kind: "response"; ok: false } =>
|
||||
typeof f === "object" &&
|
||||
f !== null &&
|
||||
(f as { kind?: string }).kind === "response" &&
|
||||
(f as { ok?: boolean }).ok === false
|
||||
);
|
||||
expect(errFrame).toBeDefined();
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("version-mismatch frame replies with error envelope", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
const server = serveWorkerRpc(ports.serverPort, {});
|
||||
const seen: unknown[] = [];
|
||||
ports.clientPort.addEventListener("message", (e) => seen.push(e.data));
|
||||
ports.clientPort.postMessage({
|
||||
v: "9.9.9",
|
||||
kind: "request",
|
||||
id: 1,
|
||||
method: "x",
|
||||
params: [],
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// The schema rejects v:"9.9.9" too (we no longer literal-match
|
||||
// exact), so server replies with VERSION_MISMATCH or INVALID_FRAME.
|
||||
const errFrame = seen.find(
|
||||
(
|
||||
f
|
||||
): f is {
|
||||
kind: "response";
|
||||
ok: false;
|
||||
error?: { code?: string };
|
||||
} =>
|
||||
typeof f === "object" &&
|
||||
f !== null &&
|
||||
(f as { kind?: string }).kind === "response" &&
|
||||
(f as { ok?: boolean }).ok === false
|
||||
);
|
||||
expect(errFrame?.error?.code).toMatch(
|
||||
/WORKER_RPC\.(VERSION_MISMATCH|INVALID_FRAME)/
|
||||
);
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("close drains in-flight handlers", { timeout: 5000 }, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
const server = serveWorkerRpc(ports.serverPort, {
|
||||
wait: (_p, signal) =>
|
||||
new Promise((resolve) => {
|
||||
signal.addEventListener("abort", () => resolve("a"));
|
||||
}),
|
||||
});
|
||||
const client = createRawWorkerRpcClient(ports.clientPort);
|
||||
client.call("wait", []).catch(() => {});
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
server.close("done");
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
client.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("stream method emits chunks then stream-end", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
const server = serveWorkerRpc(ports.serverPort, {
|
||||
counts: async function* () {
|
||||
yield 1;
|
||||
yield 2;
|
||||
yield 3;
|
||||
},
|
||||
});
|
||||
const seen: unknown[] = [];
|
||||
ports.clientPort.addEventListener("message", (e) => seen.push(e.data));
|
||||
ports.clientPort.postMessage({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "stream-open",
|
||||
id: 1,
|
||||
method: "counts",
|
||||
params: [],
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
const chunks = seen.filter(
|
||||
(f) =>
|
||||
typeof f === "object" &&
|
||||
f !== null &&
|
||||
(f as { kind?: string }).kind === "stream-chunk"
|
||||
);
|
||||
expect(chunks).toHaveLength(3);
|
||||
const end = seen.find(
|
||||
(f) =>
|
||||
typeof f === "object" &&
|
||||
f !== null &&
|
||||
(f as { kind?: string }).kind === "stream-end"
|
||||
);
|
||||
expect(end).toBeDefined();
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("non-iterable stream method emits stream-end error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
const server = serveWorkerRpc(ports.serverPort, {
|
||||
wrong: () => "not iterable",
|
||||
});
|
||||
const seen: unknown[] = [];
|
||||
ports.clientPort.addEventListener("message", (e) => seen.push(e.data));
|
||||
ports.clientPort.postMessage({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "stream-open",
|
||||
id: 2,
|
||||
method: "wrong",
|
||||
params: [],
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const end = seen.find(
|
||||
(
|
||||
f
|
||||
): f is {
|
||||
kind: "stream-end";
|
||||
ok: false;
|
||||
error: { code: string };
|
||||
} =>
|
||||
typeof f === "object" &&
|
||||
f !== null &&
|
||||
(f as { kind?: string }).kind === "stream-end" &&
|
||||
(f as { ok?: boolean }).ok === false
|
||||
);
|
||||
expect(end?.error.code).toBe("WORKER_RPC.HANDLER_THREW");
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("unknown stream method emits stream-end error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
const server = serveWorkerRpc(ports.serverPort, {});
|
||||
const seen: unknown[] = [];
|
||||
ports.clientPort.addEventListener("message", (e) => seen.push(e.data));
|
||||
ports.clientPort.postMessage({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "stream-open",
|
||||
id: 3,
|
||||
method: "missing",
|
||||
params: [],
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const end = seen.find(
|
||||
(
|
||||
f
|
||||
): f is {
|
||||
kind: "stream-end";
|
||||
ok: false;
|
||||
error: { code: string };
|
||||
} =>
|
||||
typeof f === "object" &&
|
||||
f !== null &&
|
||||
(f as { kind?: string }).kind === "stream-end" &&
|
||||
(f as { ok?: boolean }).ok === false
|
||||
);
|
||||
expect(end?.error.code).toBe("WORKER_RPC.UNKNOWN_METHOD");
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("stream cancellation propagates to handler signal", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
let aborted = false;
|
||||
const server = serveWorkerRpc(ports.serverPort, {
|
||||
slow: async function* (_p, signal) {
|
||||
yield 1;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
if (signal.aborted) aborted = true;
|
||||
yield 2;
|
||||
},
|
||||
});
|
||||
ports.clientPort.postMessage({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "stream-open",
|
||||
id: 4,
|
||||
method: "slow",
|
||||
params: [],
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
ports.clientPort.postMessage({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "cancel",
|
||||
id: 4,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
expect(aborted).toBe(true);
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("server ignores inbound response/progress/etc", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
const server = serveWorkerRpc(ports.serverPort, {});
|
||||
ports.clientPort.postMessage({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "response",
|
||||
ok: true,
|
||||
id: 1,
|
||||
result: 1,
|
||||
});
|
||||
ports.clientPort.postMessage({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "stream-chunk",
|
||||
id: 1,
|
||||
chunk: "x",
|
||||
});
|
||||
ports.clientPort.postMessage({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "stream-ack",
|
||||
id: 1,
|
||||
delta: 1,
|
||||
});
|
||||
ports.clientPort.postMessage({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "progress",
|
||||
id: 1,
|
||||
chunk: "x",
|
||||
});
|
||||
ports.clientPort.postMessage({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "stream-end",
|
||||
ok: true,
|
||||
id: 1,
|
||||
});
|
||||
ports.clientPort.postMessage({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "hello",
|
||||
hostVersion: "1.0",
|
||||
capabilities: [],
|
||||
});
|
||||
ports.clientPort.postMessage({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "goodbye",
|
||||
reason: "bye",
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("onFrameOut option observes every outbound", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
const out: unknown[] = [];
|
||||
const server = serveWorkerRpc(
|
||||
ports.serverPort,
|
||||
{
|
||||
ping: () => "pong",
|
||||
},
|
||||
{ onFrameOut: (f) => out.push(f) }
|
||||
);
|
||||
const client = createRawWorkerRpcClient(ports.clientPort);
|
||||
await client.call("ping", []);
|
||||
expect(out.length).toBeGreaterThan(0);
|
||||
client.close();
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("post-close listener guard handles late inbound", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Race: post several messages then close in the same tick. With
|
||||
// microtask scheduling, the async listener may begin processing a
|
||||
// queued message after `closed = true` is set but before the
|
||||
// removeEventListener has fully taken effect.
|
||||
const ports = createInProcessPortPair();
|
||||
const server = serveWorkerRpc(ports.serverPort, {
|
||||
x: () => 1,
|
||||
});
|
||||
for (let i = 0; i < 10; i++) {
|
||||
ports.clientPort.postMessage({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "request",
|
||||
id: i + 1,
|
||||
method: "x",
|
||||
params: [],
|
||||
});
|
||||
}
|
||||
server.close();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("version-mismatch on hello frame replies with id 0", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
const server = serveWorkerRpc(ports.serverPort, {});
|
||||
const seen: unknown[] = [];
|
||||
ports.clientPort.addEventListener("message", (e) => seen.push(e.data));
|
||||
ports.clientPort.postMessage({
|
||||
v: "9.9.9",
|
||||
kind: "hello",
|
||||
hostVersion: "1.0",
|
||||
capabilities: [],
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const errFrame = seen.find(
|
||||
(
|
||||
f
|
||||
): f is {
|
||||
kind: "response";
|
||||
ok: false;
|
||||
id: number;
|
||||
error: { code: string };
|
||||
} =>
|
||||
typeof f === "object" &&
|
||||
f !== null &&
|
||||
(f as { kind?: string }).kind === "response" &&
|
||||
(f as { ok?: boolean }).ok === false
|
||||
);
|
||||
expect(errFrame?.id).toBe(0);
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("close is idempotent", { timeout: 5000 }, () => {
|
||||
const ports = createInProcessPortPair();
|
||||
const server = serveWorkerRpc(ports.serverPort, {});
|
||||
server.close();
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
});
|
||||
242
packages/api-stack/api/api-worker-rpc/src/server/server.ts
Normal file
242
packages/api-stack/api/api-worker-rpc/src/server/server.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { createRemoteAbort } from "../abort-forwarding/abort-forwarding.js";
|
||||
import { WorkerRpcError } from "../error-codes/error-codes.js";
|
||||
import { errorToEnvelope } from "../error-reconstruction/error-reconstruction.js";
|
||||
import {
|
||||
PROTOCOL_VERSION,
|
||||
parseFrame,
|
||||
type WorkerRpcFrame,
|
||||
} from "../protocol/protocol.js";
|
||||
import type { PortLike } from "../testing/testing.js";
|
||||
|
||||
/**
|
||||
* Server-side method handler. Receives positional `params`, an
|
||||
* `AbortSignal` that fires on caller-side cancel, and may return
|
||||
* a value, a Promise, or an `AsyncIterable` (for streaming).
|
||||
*/
|
||||
export type WorkerRpcHandler = (
|
||||
params: ReadonlyArray<unknown>,
|
||||
signal: AbortSignal
|
||||
) => unknown | Promise<unknown> | AsyncIterable<unknown>;
|
||||
|
||||
export interface WorkerRpcHandlers {
|
||||
readonly [method: string]: WorkerRpcHandler;
|
||||
}
|
||||
|
||||
export interface WorkerRpcServer {
|
||||
close(reason?: string): void;
|
||||
}
|
||||
|
||||
export interface ServeWorkerRpcOptions {
|
||||
/** Surfaces handler-internal frames (e.g. progress) to observers. */
|
||||
readonly onFrameOut?: (frame: WorkerRpcFrame) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a handlers object on a `MessagePort`. Returns a disposer.
|
||||
*
|
||||
* For each incoming `request` frame:
|
||||
* 1. Look up `handlers[method]`; unknown methods → `UNKNOWN_METHOD`.
|
||||
* 2. Build a fresh {@link AbortController} for the handler.
|
||||
* 3. Invoke the handler; await the result.
|
||||
* 4. Send a `response` frame, ok-or-error.
|
||||
*
|
||||
* Incoming `stream-open` is dispatched identically except the handler
|
||||
* is expected to return `AsyncIterable<unknown>`; each yielded value
|
||||
* becomes a `stream-chunk` frame; completion is a `stream-end`.
|
||||
*
|
||||
* Incoming `cancel` triggers the per-call `AbortController`.
|
||||
*/
|
||||
export function serveWorkerRpc(
|
||||
port: PortLike,
|
||||
handlers: WorkerRpcHandlers,
|
||||
opts: ServeWorkerRpcOptions = {}
|
||||
): WorkerRpcServer {
|
||||
const inflight = new Map<number, { abort: () => void }>();
|
||||
let closed = false;
|
||||
|
||||
const send = (frame: WorkerRpcFrame): void => {
|
||||
if (closed) return;
|
||||
port.postMessage(frame);
|
||||
opts.onFrameOut?.(frame);
|
||||
};
|
||||
|
||||
const listener = async (event: { data: unknown }): Promise<void> => {
|
||||
let frame: WorkerRpcFrame;
|
||||
try {
|
||||
frame = parseFrame(event.data);
|
||||
} catch (cause) {
|
||||
// We have no id to bind this to — best effort log only.
|
||||
const env = errorToEnvelope(
|
||||
new WorkerRpcError(
|
||||
"WORKER_RPC.INVALID_FRAME",
|
||||
"received malformed RPC frame",
|
||||
{ cause }
|
||||
)
|
||||
);
|
||||
send({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "response",
|
||||
ok: false,
|
||||
id: 0,
|
||||
error: env,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (frame.v !== PROTOCOL_VERSION) {
|
||||
send({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "response",
|
||||
ok: false,
|
||||
id: "id" in frame ? (frame.id as number) : 0,
|
||||
error: errorToEnvelope(
|
||||
new WorkerRpcError(
|
||||
"WORKER_RPC.VERSION_MISMATCH",
|
||||
`expected protocol ${PROTOCOL_VERSION}, got ${frame.v}`
|
||||
)
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (frame.kind === "cancel") {
|
||||
inflight.get(frame.id)?.abort();
|
||||
inflight.delete(frame.id);
|
||||
return;
|
||||
}
|
||||
if (frame.kind === "request") {
|
||||
await dispatchRequest(frame.id, frame.method, frame.params);
|
||||
return;
|
||||
}
|
||||
if (frame.kind === "stream-open") {
|
||||
await dispatchStream(frame.id, frame.method, frame.params);
|
||||
return;
|
||||
}
|
||||
// hello / goodbye / stream-ack / progress / stream-chunk / stream-end /
|
||||
// response: server side ignores (it never originates inbound responses).
|
||||
};
|
||||
|
||||
const dispatchRequest = async (
|
||||
id: number,
|
||||
method: string,
|
||||
params: ReadonlyArray<unknown>
|
||||
): Promise<void> => {
|
||||
const handler = handlers[method];
|
||||
if (handler === undefined) {
|
||||
send({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "response",
|
||||
ok: false,
|
||||
id,
|
||||
error: errorToEnvelope(
|
||||
new WorkerRpcError(
|
||||
"WORKER_RPC.UNKNOWN_METHOD",
|
||||
`no handler for method ${method}`
|
||||
)
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const remote = createRemoteAbort();
|
||||
inflight.set(id, { abort: () => remote.trigger() });
|
||||
try {
|
||||
const result = await handler(params, remote.signal);
|
||||
inflight.delete(id);
|
||||
send({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "response",
|
||||
ok: true,
|
||||
id,
|
||||
result,
|
||||
});
|
||||
} catch (err) {
|
||||
inflight.delete(id);
|
||||
send({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "response",
|
||||
ok: false,
|
||||
id,
|
||||
error: errorToEnvelope(err),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const dispatchStream = async (
|
||||
id: number,
|
||||
method: string,
|
||||
params: ReadonlyArray<unknown>
|
||||
): Promise<void> => {
|
||||
const handler = handlers[method];
|
||||
if (handler === undefined) {
|
||||
send({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "stream-end",
|
||||
ok: false,
|
||||
id,
|
||||
error: errorToEnvelope(
|
||||
new WorkerRpcError(
|
||||
"WORKER_RPC.UNKNOWN_METHOD",
|
||||
`no handler for stream method ${method}`
|
||||
)
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const remote = createRemoteAbort();
|
||||
inflight.set(id, { abort: () => remote.trigger() });
|
||||
try {
|
||||
const result = await handler(params, remote.signal);
|
||||
const iterable = result as AsyncIterable<unknown> | null;
|
||||
if (
|
||||
iterable === null ||
|
||||
typeof iterable !== "object" ||
|
||||
typeof (iterable as { [Symbol.asyncIterator]?: unknown })[
|
||||
Symbol.asyncIterator
|
||||
] !== "function"
|
||||
) {
|
||||
throw new WorkerRpcError(
|
||||
"WORKER_RPC.HANDLER_THREW",
|
||||
`stream method ${method} did not return AsyncIterable`
|
||||
);
|
||||
}
|
||||
for await (const chunk of iterable) {
|
||||
if (remote.signal.aborted) break;
|
||||
send({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "stream-chunk",
|
||||
id,
|
||||
chunk,
|
||||
});
|
||||
}
|
||||
inflight.delete(id);
|
||||
send({ v: PROTOCOL_VERSION, kind: "stream-end", ok: true, id });
|
||||
} catch (err) {
|
||||
inflight.delete(id);
|
||||
send({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "stream-end",
|
||||
ok: false,
|
||||
id,
|
||||
error: errorToEnvelope(err),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
port.addEventListener(
|
||||
"message",
|
||||
listener as (e: { data: unknown }) => void
|
||||
);
|
||||
port.start?.();
|
||||
|
||||
return {
|
||||
close(reason = "closed"): void {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (const entry of inflight.values()) entry.abort();
|
||||
inflight.clear();
|
||||
port.removeEventListener(
|
||||
"message",
|
||||
listener as (e: { data: unknown }) => void
|
||||
);
|
||||
send({ v: PROTOCOL_VERSION, kind: "goodbye", reason });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
StreamDispatcher,
|
||||
type StreamRequestOptions,
|
||||
streamRequest,
|
||||
} from "./streaming.js";
|
||||
101
packages/api-stack/api/api-worker-rpc/src/streaming/streaming.md
Normal file
101
packages/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.
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createRawWorkerRpcClient } from "../client/client.js";
|
||||
import { serveWorkerRpc } from "../server/server.js";
|
||||
import { createInProcessPortPair } from "../testing/testing.js";
|
||||
import { StreamDispatcher, streamRequest } from "./streaming.js";
|
||||
|
||||
function setup() {
|
||||
const ports = createInProcessPortPair();
|
||||
const server = serveWorkerRpc(ports.serverPort, {
|
||||
counts: async function* () {
|
||||
yield 1;
|
||||
yield 2;
|
||||
yield 3;
|
||||
},
|
||||
big: async function* () {
|
||||
for (let i = 0; i < 100; i++) yield i;
|
||||
},
|
||||
boom: async function* () {
|
||||
yield 1;
|
||||
throw new Error("explode");
|
||||
},
|
||||
slow: async function* (_p, signal) {
|
||||
yield 1;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
if (signal.aborted) return;
|
||||
yield 2;
|
||||
},
|
||||
});
|
||||
const dispatcher = new StreamDispatcher();
|
||||
const client = createRawWorkerRpcClient(ports.clientPort, {
|
||||
onStreamFrame: dispatcher.dispatch,
|
||||
});
|
||||
return { ports, server, client, dispatcher };
|
||||
}
|
||||
|
||||
describe("streaming", () => {
|
||||
it("AsyncIterable yields all chunks", { timeout: 5000 }, async () => {
|
||||
const { ports, server, client, dispatcher } = setup();
|
||||
const it = streamRequest(client, dispatcher, "counts", []);
|
||||
const out: unknown[] = [];
|
||||
for await (const c of it) out.push(c);
|
||||
expect(out).toEqual([1, 2, 3]);
|
||||
client.close();
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("large stream transfers in chunks", { timeout: 5000 }, async () => {
|
||||
const { ports, server, client, dispatcher } = setup();
|
||||
const it = streamRequest(client, dispatcher, "big", [], {
|
||||
queue: { highWaterMark: 256, mode: "fail" },
|
||||
});
|
||||
const out: unknown[] = [];
|
||||
for await (const c of it) out.push(c);
|
||||
expect(out).toHaveLength(100);
|
||||
expect(out[0]).toBe(0);
|
||||
expect(out[99]).toBe(99);
|
||||
client.close();
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("stream-end error rejects iterator", { timeout: 5000 }, async () => {
|
||||
const { ports, server, client, dispatcher } = setup();
|
||||
const it = streamRequest(client, dispatcher, "boom", []);
|
||||
const out: unknown[] = [];
|
||||
let threw: Error | undefined;
|
||||
try {
|
||||
for await (const c of it) out.push(c);
|
||||
} catch (e) {
|
||||
threw = e as Error;
|
||||
}
|
||||
expect(out).toEqual([1]);
|
||||
expect(threw?.message).toContain("explode");
|
||||
client.close();
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("consumer return cancels remote producer", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { ports, server, client, dispatcher } = setup();
|
||||
const it = streamRequest(client, dispatcher, "slow", [])[
|
||||
Symbol.asyncIterator
|
||||
]();
|
||||
const first = await it.next();
|
||||
expect(first.value).toBe(1);
|
||||
const done = await it.return?.();
|
||||
expect(done?.done).toBe(true);
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
client.close();
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("AbortSignal cancels mid-stream", { timeout: 5000 }, async () => {
|
||||
const { ports, server, client, dispatcher } = setup();
|
||||
const ac = new AbortController();
|
||||
const it = streamRequest(client, dispatcher, "slow", [], {
|
||||
signal: ac.signal,
|
||||
});
|
||||
const iter = it[Symbol.asyncIterator]();
|
||||
const first = await iter.next();
|
||||
expect(first.value).toBe(1);
|
||||
ac.abort();
|
||||
let captured: Error | undefined;
|
||||
try {
|
||||
await iter.next();
|
||||
} catch (e) {
|
||||
captured = e as Error;
|
||||
}
|
||||
expect(captured?.name).toBe("AbortError");
|
||||
client.close();
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("pre-aborted signal cancels immediately", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { ports, server, client, dispatcher } = setup();
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
const it = streamRequest(client, dispatcher, "counts", [], {
|
||||
signal: ac.signal,
|
||||
});
|
||||
let threw: Error | undefined;
|
||||
try {
|
||||
for await (const _c of it) {
|
||||
// drain
|
||||
}
|
||||
} catch (e) {
|
||||
threw = e as Error;
|
||||
}
|
||||
expect(threw?.name).toBe("AbortError");
|
||||
client.close();
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("non-chunk/end frames routed to a stream handler are ignored", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Inject an unrelated frame after stream-end to exercise the
|
||||
// fall-through branch in the dispatcher callback.
|
||||
const { ports, server, client, dispatcher } = setup();
|
||||
const it = streamRequest(client, dispatcher, "counts", []);
|
||||
// Pre-emptively dispatch a stream-ack frame for id=1 (will be the
|
||||
// first allocated id since this is a fresh client).
|
||||
dispatcher.dispatch({
|
||||
v: "1.0.0",
|
||||
kind: "stream-ack",
|
||||
id: 1,
|
||||
delta: 1,
|
||||
});
|
||||
const out: unknown[] = [];
|
||||
for await (const c of it) out.push(c);
|
||||
expect(out).toEqual([1, 2, 3]);
|
||||
client.close();
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
|
||||
it("StreamDispatcher routes by id", { timeout: 5000 }, () => {
|
||||
const d = new StreamDispatcher();
|
||||
const seen: unknown[] = [];
|
||||
d.register(7, (f) => seen.push(f));
|
||||
d.dispatch({
|
||||
v: "1.0.0",
|
||||
kind: "stream-chunk",
|
||||
id: 7,
|
||||
chunk: "a",
|
||||
});
|
||||
d.dispatch({
|
||||
v: "1.0.0",
|
||||
kind: "stream-chunk",
|
||||
id: 8,
|
||||
chunk: "b",
|
||||
});
|
||||
expect(seen).toHaveLength(1);
|
||||
d.unregister(7);
|
||||
d.clear();
|
||||
});
|
||||
|
||||
it("StreamDispatcher ignores frames without numeric id", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const d = new StreamDispatcher();
|
||||
d.dispatch({
|
||||
v: "1.0.0",
|
||||
kind: "hello",
|
||||
hostVersion: "1.0",
|
||||
capabilities: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
164
packages/api-stack/api/api-worker-rpc/src/streaming/streaming.ts
Normal file
164
packages/api-stack/api/api-worker-rpc/src/streaming/streaming.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
type BackpressurePolicy,
|
||||
BoundedQueue,
|
||||
DEFAULT_BACKPRESSURE,
|
||||
} from "../backpressure/backpressure.js";
|
||||
import type { WorkerRpcClient } from "../client/client.js";
|
||||
import { envelopeToError } from "../error-reconstruction/error-reconstruction.js";
|
||||
import { PROTOCOL_VERSION, type WorkerRpcFrame } from "../protocol/protocol.js";
|
||||
|
||||
export interface StreamRequestOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly queue?: BackpressurePolicy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a streaming request. Returns an `AsyncIterable<chunk>` that pulls
|
||||
* `stream-chunk` frames as they arrive; on `stream-end` ok the iterator
|
||||
* completes; on `stream-end` error it throws the reconstructed error.
|
||||
*
|
||||
* Backpressure is governed by {@link BoundedQueue}. The default policy
|
||||
* caps the buffered chunks at 64; switch to `drop-oldest`/`drop-newest`
|
||||
* for lossy consumers.
|
||||
*
|
||||
* Note: this helper attaches its own listener on the *raw* client, which
|
||||
* routes inbound stream frames via the `onStreamFrame` option. Callers
|
||||
* who want streaming MUST construct the client with that option wired to
|
||||
* a dispatcher; the helper {@link createStreamingClient} below does this
|
||||
* automatically.
|
||||
*/
|
||||
export function streamRequest(
|
||||
client: WorkerRpcClient,
|
||||
dispatcher: StreamDispatcher,
|
||||
method: string,
|
||||
params: ReadonlyArray<unknown>,
|
||||
opts: StreamRequestOptions = {}
|
||||
): AsyncIterable<unknown> {
|
||||
const id = client.nextId();
|
||||
const queue = new BoundedQueue<unknown>(opts.queue ?? DEFAULT_BACKPRESSURE);
|
||||
let pendingResolver: ((value: IteratorResult<unknown>) => void) | undefined;
|
||||
let pendingRejecter: ((reason: Error) => void) | undefined;
|
||||
let endError: Error | undefined;
|
||||
let ended = false;
|
||||
|
||||
const tryFlush = (): void => {
|
||||
if (pendingResolver === undefined) return;
|
||||
const next = queue.dequeue();
|
||||
if (next !== undefined) {
|
||||
const r = pendingResolver;
|
||||
pendingResolver = undefined;
|
||||
pendingRejecter = undefined;
|
||||
r({ value: next, done: false });
|
||||
return;
|
||||
}
|
||||
if (ended) {
|
||||
const r = pendingResolver;
|
||||
pendingResolver = undefined;
|
||||
if (endError !== undefined) {
|
||||
const rej = pendingRejecter;
|
||||
pendingRejecter = undefined;
|
||||
rej?.(endError);
|
||||
return;
|
||||
}
|
||||
r({ value: undefined, done: true });
|
||||
}
|
||||
};
|
||||
|
||||
dispatcher.register(id, (frame: WorkerRpcFrame) => {
|
||||
if (frame.kind === "stream-chunk") {
|
||||
queue.enqueue(frame.chunk);
|
||||
tryFlush();
|
||||
return;
|
||||
}
|
||||
if (frame.kind === "stream-end") {
|
||||
ended = true;
|
||||
if (!frame.ok) {
|
||||
endError = envelopeToError(frame.error);
|
||||
}
|
||||
tryFlush();
|
||||
dispatcher.unregister(id);
|
||||
}
|
||||
});
|
||||
|
||||
if (opts.signal !== undefined) {
|
||||
const onAbort = (): void => {
|
||||
ended = true;
|
||||
endError = new Error("stream cancelled");
|
||||
endError.name = "AbortError";
|
||||
client.send({ v: PROTOCOL_VERSION, kind: "cancel", id });
|
||||
tryFlush();
|
||||
dispatcher.unregister(id);
|
||||
};
|
||||
if (opts.signal.aborted) onAbort();
|
||||
else opts.signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
|
||||
client.send({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "stream-open",
|
||||
id,
|
||||
method,
|
||||
params: [...params],
|
||||
});
|
||||
|
||||
return {
|
||||
[Symbol.asyncIterator](): AsyncIterator<unknown> {
|
||||
return {
|
||||
next(): Promise<IteratorResult<unknown>> {
|
||||
return new Promise<IteratorResult<unknown>>(
|
||||
(resolve, reject) => {
|
||||
pendingResolver = resolve;
|
||||
pendingRejecter = reject;
|
||||
tryFlush();
|
||||
}
|
||||
);
|
||||
},
|
||||
return(): Promise<IteratorResult<unknown>> {
|
||||
ended = true;
|
||||
client.send({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "cancel",
|
||||
id,
|
||||
});
|
||||
dispatcher.unregister(id);
|
||||
return Promise.resolve({ value: undefined, done: true });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiplexer for stream frames. Pass its `dispatch` method into the
|
||||
* raw client's `onStreamFrame` option; the dispatcher routes inbound
|
||||
* stream-chunk / stream-end frames back to the correct {@link streamRequest}
|
||||
* caller.
|
||||
*/
|
||||
export class StreamDispatcher {
|
||||
private readonly handlers = new Map<
|
||||
number,
|
||||
(frame: WorkerRpcFrame) => void
|
||||
>();
|
||||
|
||||
public dispatch = (frame: WorkerRpcFrame): void => {
|
||||
if ("id" in frame && typeof frame.id === "number") {
|
||||
const h = this.handlers.get(frame.id);
|
||||
h?.(frame);
|
||||
}
|
||||
};
|
||||
|
||||
public register(
|
||||
id: number,
|
||||
handler: (frame: WorkerRpcFrame) => void
|
||||
): void {
|
||||
this.handlers.set(id, handler);
|
||||
}
|
||||
|
||||
public unregister(id: number): void {
|
||||
this.handlers.delete(id);
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.handlers.clear();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createRawWorkerRpcClient } from "../client/client.js";
|
||||
import { serveWorkerRpc } from "../server/server.js";
|
||||
import { createInProcessPortPair, type InProcessPortPair } from "./testing.js";
|
||||
|
||||
/**
|
||||
* Spin up a paired client/server harness for conformance tests. Returns
|
||||
* the live handles plus a `dispose` that tears everything down — exists
|
||||
* so downstream worker-proxy packages (db, events, secrets, jobs,
|
||||
* logger) can plug their handler maps into a real `MessagePort` round
|
||||
* trip without re-implementing setup.
|
||||
*/
|
||||
export interface ConformanceHarness {
|
||||
readonly client: ReturnType<typeof createRawWorkerRpcClient>;
|
||||
readonly server: ReturnType<typeof serveWorkerRpc>;
|
||||
readonly ports: InProcessPortPair;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export function runWorkerRpcConformance(
|
||||
factory: () => Parameters<typeof serveWorkerRpc>[1]
|
||||
): ConformanceHarness {
|
||||
const ports = createInProcessPortPair();
|
||||
const handlers = factory();
|
||||
const server = serveWorkerRpc(ports.serverPort, handlers);
|
||||
const client = createRawWorkerRpcClient(ports.clientPort);
|
||||
return {
|
||||
client,
|
||||
server,
|
||||
ports,
|
||||
dispose: () => {
|
||||
client.close();
|
||||
server.close();
|
||||
ports.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
type ConformanceHarness,
|
||||
runWorkerRpcConformance,
|
||||
} from "./conformance.js";
|
||||
export {
|
||||
createInProcessPortPair,
|
||||
type InProcessPortPair,
|
||||
type PortLike,
|
||||
} from "./testing.js";
|
||||
87
packages/api-stack/api/api-worker-rpc/src/testing/testing.md
Normal file
87
packages/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.
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { serveWorkerRpc } from "../server/server.js";
|
||||
import { runWorkerRpcConformance } from "./conformance.js";
|
||||
import { createInProcessPortPair } from "./testing.js";
|
||||
|
||||
describe("testing/port-pair", () => {
|
||||
it("ports are bidirectional", { timeout: 5000 }, async () => {
|
||||
const pair = createInProcessPortPair();
|
||||
const received: unknown[] = [];
|
||||
const l = (e: { data: unknown }): void => {
|
||||
received.push(e.data);
|
||||
};
|
||||
pair.serverPort.addEventListener("message", l);
|
||||
pair.clientPort.postMessage("hello");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(received).toEqual(["hello"]);
|
||||
pair.serverPort.removeEventListener("message", l);
|
||||
pair.close();
|
||||
});
|
||||
|
||||
it("removeEventListener detaches the wrapper", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pair = createInProcessPortPair();
|
||||
const received: unknown[] = [];
|
||||
const l = (e: { data: unknown }): void => {
|
||||
received.push(e.data);
|
||||
};
|
||||
pair.serverPort.addEventListener("message", l);
|
||||
pair.serverPort.removeEventListener("message", l);
|
||||
pair.clientPort.postMessage("x");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(received).toEqual([]);
|
||||
pair.close();
|
||||
});
|
||||
|
||||
it("removeEventListener tolerates unknown listener", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pair = createInProcessPortPair();
|
||||
const l = (): void => {};
|
||||
pair.serverPort.removeEventListener("message", l);
|
||||
pair.close();
|
||||
});
|
||||
|
||||
it("start() is exposed and callable", { timeout: 5000 }, () => {
|
||||
const pair = createInProcessPortPair();
|
||||
pair.serverPort.start?.();
|
||||
pair.clientPort.start?.();
|
||||
pair.close();
|
||||
});
|
||||
|
||||
it("port.close() forwards to underlying port", { timeout: 5000 }, () => {
|
||||
const pair = createInProcessPortPair();
|
||||
pair.serverPort.close?.();
|
||||
pair.clientPort.close?.();
|
||||
});
|
||||
});
|
||||
|
||||
describe("testing/conformance harness", () => {
|
||||
it("runs a paired round-trip", { timeout: 5000 }, async () => {
|
||||
const h = runWorkerRpcConformance(() => ({
|
||||
echo: (params: ReadonlyArray<unknown>) => params[0],
|
||||
}));
|
||||
const result = await h.client.call("echo", [42]);
|
||||
expect(result).toBe(42);
|
||||
h.dispose();
|
||||
});
|
||||
|
||||
it("dispose tears down ports + listeners", { timeout: 5000 }, () => {
|
||||
const h = runWorkerRpcConformance(() => ({}));
|
||||
h.dispose();
|
||||
// close is idempotent
|
||||
h.client.close();
|
||||
h.server.close();
|
||||
});
|
||||
|
||||
it("explicit serveWorkerRpc + ports also works", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
const server = serveWorkerRpc(ports.serverPort, {
|
||||
ping: () => "pong",
|
||||
});
|
||||
const h = runWorkerRpcConformance(() => ({}));
|
||||
h.dispose();
|
||||
server.close();
|
||||
ports.close();
|
||||
});
|
||||
});
|
||||
77
packages/api-stack/api/api-worker-rpc/src/testing/testing.ts
Normal file
77
packages/api-stack/api/api-worker-rpc/src/testing/testing.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Minimal `MessagePort`-shaped surface this package needs. Aligns with
|
||||
* the Web `MessagePort` AND Node's `worker_threads` `MessagePort` (both
|
||||
* accept `postMessage(value)` and dispatch `"message"` events).
|
||||
*
|
||||
* Using a structural type keeps the package universal — neither
|
||||
* `worker_threads` nor browser globals are imported at module load.
|
||||
*/
|
||||
export interface PortLike {
|
||||
postMessage(value: unknown): void;
|
||||
addEventListener(
|
||||
type: "message",
|
||||
listener: (event: { data: unknown }) => void
|
||||
): void;
|
||||
removeEventListener(
|
||||
type: "message",
|
||||
listener: (event: { data: unknown }) => void
|
||||
): void;
|
||||
start?: () => void;
|
||||
close?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A paired-port factory used by conformance suites. Wraps the platform
|
||||
* `MessageChannel` so suites do not import platform globals directly.
|
||||
*/
|
||||
export interface InProcessPortPair {
|
||||
readonly clientPort: PortLike;
|
||||
readonly serverPort: PortLike;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an in-process linked pair of {@link PortLike} ports via
|
||||
* `MessageChannel`. Both Node 18+ and Bun expose `MessageChannel` as a
|
||||
* global; browsers expose it natively. The pair is fully bidirectional
|
||||
* and zero-copy for `Transferable`s.
|
||||
*/
|
||||
export function createInProcessPortPair(): InProcessPortPair {
|
||||
const channel = new MessageChannel();
|
||||
const a = wrapPort(channel.port1);
|
||||
const b = wrapPort(channel.port2);
|
||||
return {
|
||||
clientPort: a,
|
||||
serverPort: b,
|
||||
close: () => {
|
||||
channel.port1.close();
|
||||
channel.port2.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function wrapPort(port: MessagePort): PortLike {
|
||||
port.start?.();
|
||||
const wrappers = new WeakMap<
|
||||
(event: { data: unknown }) => void,
|
||||
EventListener
|
||||
>();
|
||||
return {
|
||||
postMessage: (value: unknown) => port.postMessage(value),
|
||||
addEventListener: (_type, listener) => {
|
||||
const wrapper: EventListener = (event) =>
|
||||
listener({ data: (event as MessageEvent).data });
|
||||
wrappers.set(listener, wrapper);
|
||||
port.addEventListener("message", wrapper);
|
||||
},
|
||||
removeEventListener: (_type, listener) => {
|
||||
const wrapper = wrappers.get(listener);
|
||||
if (wrapper !== undefined) {
|
||||
port.removeEventListener("message", wrapper);
|
||||
wrappers.delete(listener);
|
||||
}
|
||||
},
|
||||
start: () => port.start?.(),
|
||||
close: () => port.close(),
|
||||
};
|
||||
}
|
||||
12
packages/api-stack/api/api-worker-rpc/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-worker-rpc/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
30
packages/api-stack/api/api-worker-rpc/vite.config.ts
Normal file
30
packages/api-stack/api/api-worker-rpc/vite.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../../node_modules/.vite/packages/api-worker-rpc",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [/^@suaveplan\//, /^node:/, "tslib", "zod"],
|
||||
},
|
||||
},
|
||||
});
|
||||
30
packages/api-stack/api/api-worker-rpc/vitest.config.ts
Normal file
30
packages/api-stack/api/api-worker-rpc/vitest.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user