Initial Commit
This commit is contained in:
78
packages/api-stack/api/api-router-worker-bridge/README.md
Normal file
78
packages/api-stack/api/api-router-worker-bridge/README.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# @suaveplan/api-router-worker-bridge — HTTP Request/Response marshalling across the worker boundary. Pairs a main-side and worker-side bridge over @suaveplan/api-worker-rpc, transporting fetch-compatible Request and Response objects (including ReadableStream bodies via streaming frames) without exposing the raw Request to worker code.
|
||||
|
||||
Stage-3 HTTP bridge that marshals Fetch `Request` and `Response` objects
|
||||
across the worker boundary. Pairs a main-side `dispatch` with a worker-
|
||||
side handler over a single `MessagePort`, transporting bodies either
|
||||
inline (small payloads) or via the `@suaveplan/api-worker-rpc` streaming
|
||||
frames (large payloads, with back-pressure).
|
||||
|
||||
The raw `Request` instance is **never** placed on the wire. Only the
|
||||
method, URL, allow-listed headers, and body descriptor cross the port;
|
||||
worker code reconstructs a fresh `Request` from those fields and can
|
||||
neither read the original socket nor inspect headers on the deny-list
|
||||
(`Authorization`, `Cookie`, `Set-Cookie`, `X-CSRF-Token`,
|
||||
`Proxy-Authorization` by default).
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [bridge](./src/bridge/bridge.md) — This module ties `marshal-request`, `marshal-response`, and `streaming` together into the two halves of a working Fetch…
|
||||
- [bridge](./src/bridge/bridge.md) — This module ties `marshal-request`, `marshal-response`, and `streaming` together into the two halves of a working Fetch…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable error-code registry for `@suaveplan/api-router-worker-bridge`. Every failure path that aris…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable error-code registry for `@suaveplan/api-router-worker-bridge`. Every failure path that aris…
|
||||
- [marshal-request](./src/marshal-request/marshal-request.md) — This module converts a Fetch `Request` into a plain `SerializedRequest` object that is safe to ship across a `MessagePo…
|
||||
- [marshal-request](./src/marshal-request/marshal-request.md) — This module converts a Fetch `Request` into a plain `SerializedRequest` object that is safe to ship across a `MessagePo…
|
||||
- [marshal-response](./src/marshal-response/marshal-response.md) — This module is the response-side counterpart to `marshal-request`. It converts a Fetch `Response` produced by a worker-…
|
||||
- [marshal-response](./src/marshal-response/marshal-response.md) — This module is the response-side counterpart to `marshal-request`. It converts a Fetch `Response` produced by a worker-…
|
||||
- [streaming](./src/streaming/streaming.md) — This module marshals binary `ReadableStream<Uint8Array>` bodies between the main and worker processes by piggy-backing…
|
||||
- [streaming](./src/streaming/streaming.md) — This module marshals binary `ReadableStream<Uint8Array>` bodies between the main and worker processes by piggy-backing…
|
||||
- [testing](./src/testing/testing.md) — This module hands consumers the in-process scaffolding needed to exercise the worker bridge under a real `MessageChanne…
|
||||
- [testing](./src/testing/testing.md) — This module hands consumers the in-process scaffolding needed to exercise the worker bridge under a real `MessageChanne…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Modules
|
||||
|
||||
- [`marshal-request`](src/marshal-request/marshal-request.md) —
|
||||
`Request` ↔ `SerializedRequest` round-trip.
|
||||
- [`marshal-response`](src/marshal-response/marshal-response.md) —
|
||||
`Response` ↔ `SerializedResponse` round-trip.
|
||||
- [`streaming`](src/streaming/streaming.md) — `ReadableStream<Uint8Array>`
|
||||
transport via `stream-chunk` / `stream-end` / `stream-ack` frames.
|
||||
- [`bridge`](src/bridge/bridge.md) — main + worker bridge factories.
|
||||
- [`error-codes`](src/error-codes/error-codes.md) — frozen
|
||||
`ROUTER_BRIDGE.*` registry.
|
||||
- [`testing`](src/testing/testing.md) — in-process bridge + conformance
|
||||
harness.
|
||||
|
||||
## No-raw-Request invariant
|
||||
|
||||
`marshalRequest` returns a plain, structured-clone-safe object. Inspection
|
||||
of the wire envelope yields exactly: `method`, `url`, `headers`,
|
||||
`body { kind, ... }`. There is no `Request` reference and no way for
|
||||
worker code to call `request.clone()` on the original — the deny-list
|
||||
strips credentials before serialisation and the `Headers` instance handed
|
||||
to worker code is freshly constructed from the allow-listed tuples only.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/api-router-worker-bridge
|
||||
```
|
||||
|
||||
## Quickstart
|
||||
|
||||
```ts
|
||||
import {
|
||||
createMainBridge,
|
||||
createWorkerBridge,
|
||||
} from "@suaveplan/api-router-worker-bridge/bridge";
|
||||
|
||||
const channel = new MessageChannel();
|
||||
createWorkerBridge(channel.port2 as never, async (req) =>
|
||||
new Response(`hi ${new URL(req.url).pathname}`)
|
||||
);
|
||||
const { dispatch } = createMainBridge(channel.port1 as never);
|
||||
const res = await dispatch(new Request("https://api.local/world"));
|
||||
console.log(await res.text()); // "hi /world"
|
||||
```
|
||||
|
||||
105
packages/api-stack/api/api-router-worker-bridge/package.json
Normal file
105
packages/api-stack/api/api-router-worker-bridge/package.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"name": "@suaveplan/api-router-worker-bridge",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Stage-3 HTTP Request/Response marshalling across the worker boundary. Pairs a main-side and worker-side bridge over @suaveplan/api-worker-rpc, transporting fetch-compatible Request and Response objects (including ReadableStream bodies via streaming frames) without exposing the raw Request to worker code.",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"./marshal-request": {
|
||||
"import": {
|
||||
"types": "./dist/marshal-request/index.d.ts",
|
||||
"default": "./dist/marshal-request/index.js"
|
||||
}
|
||||
},
|
||||
"./marshal-response": {
|
||||
"import": {
|
||||
"types": "./dist/marshal-response/index.d.ts",
|
||||
"default": "./dist/marshal-response/index.js"
|
||||
}
|
||||
},
|
||||
"./streaming": {
|
||||
"import": {
|
||||
"types": "./dist/streaming/index.d.ts",
|
||||
"default": "./dist/streaming/index.js"
|
||||
}
|
||||
},
|
||||
"./bridge": {
|
||||
"import": {
|
||||
"types": "./dist/bridge/index.d.ts",
|
||||
"default": "./dist/bridge/index.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/index.js"
|
||||
}
|
||||
},
|
||||
"./testing": {
|
||||
"import": {
|
||||
"types": "./dist/testing/index.d.ts",
|
||||
"default": "./dist/testing/index.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-router": "workspace:^",
|
||||
"@suaveplan/api-types": "workspace:^",
|
||||
"@suaveplan/api-worker-rpc": "workspace:^",
|
||||
"@suaveplan/error": "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"
|
||||
],
|
||||
"suaveplan": {
|
||||
"conformanceSkip": [
|
||||
"api-router"
|
||||
],
|
||||
"conformanceSkipReason": "api-router-worker-bridge marshals HTTP Request/Response over the worker-rpc boundary; it does NOT implement the api-router Router/PipelineRouter contract — the router lives on the main side. The bridge's correctness is verified by its own round-trip conformance harness."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
# `bridge`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module ties `marshal-request`, `marshal-response`, and `streaming`
|
||||
together into the two halves of a working Fetch-`Request`-to-`Response`
|
||||
bridge over a single `MessagePort`. `createMainBridge` hands back a
|
||||
`dispatch(Request) → Promise<Response>` function that worker-isolated
|
||||
plugins consume; `createWorkerBridge` runs inside the worker and routes
|
||||
incoming envelopes to a user-supplied handler.
|
||||
|
||||
## Features
|
||||
|
||||
- Single bridge dispatch RPC method (`http.dispatch`); the main-side
|
||||
listener correlates responses by `id` so multiple in-flight requests
|
||||
multiplex over one port.
|
||||
- Optional request- and response-body streaming when `inlineBodyLimit`
|
||||
is set to `0`, threading through the `streaming` module.
|
||||
- Worker-side error rethrows are forwarded via `errorToEnvelope`, so the
|
||||
main side observes the original `SuaveplanError.code` chain.
|
||||
- `close()` on either side drops pending calls with a deterministic
|
||||
`ROUTER_BRIDGE.STREAM_INTERRUPTED` error rather than hanging.
|
||||
- Strict envelope validation on both ends — a malformed payload fails
|
||||
with `ROUTER_BRIDGE.MARSHAL_FAILED` or `RESPONSE_INVALID`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createMainBridge,
|
||||
createWorkerBridge,
|
||||
} from "@suaveplan/api-router-worker-bridge/bridge";
|
||||
|
||||
const { dispatch } = createMainBridge(mainPort);
|
||||
createWorkerBridge(workerPort, async (req) =>
|
||||
new Response(`hello ${new URL(req.url).pathname}`)
|
||||
);
|
||||
|
||||
const res = await dispatch(new Request("https://api.local/world"));
|
||||
await res.text(); // "hello /world"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wire a streaming-only bridge for large uploads/downloads:
|
||||
|
||||
```ts
|
||||
import {
|
||||
createMainBridge,
|
||||
createWorkerBridge,
|
||||
} from "@suaveplan/api-router-worker-bridge/bridge";
|
||||
|
||||
const { dispatch } = createMainBridge(mainPort, {
|
||||
inlineBodyLimit: 0,
|
||||
streamWindow: 32,
|
||||
});
|
||||
createWorkerBridge(
|
||||
workerPort,
|
||||
async (req) => new Response(req.body, { headers: req.headers }),
|
||||
{ inlineBodyLimit: 0, streamWindow: 32 }
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createMainBridge(port, options?) → { dispatch(Request): Promise<Response>, close(): void }`.
|
||||
- `createWorkerBridge(port, handler, options?) → { close(): void }`.
|
||||
- `BRIDGE_DISPATCH_METHOD = "http.dispatch"`.
|
||||
- `BridgeOptions { inlineBodyLimit?, maxBodyBytes?, headerDenyList?, streamWindow? }`.
|
||||
- `WorkerHandler = (Request) => Promise<Response> | Response`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bridge intentionally re-uses `api-worker-rpc`'s request/response and
|
||||
stream frame shapes (and `PROTOCOL_VERSION`) so its traffic shares the
|
||||
same wire dialect — a downstream channel can multiplex both ordinary RPC
|
||||
and HTTP traffic without parsing ambiguity. When the request body is
|
||||
streamed, the same numeric id is used for both the RPC correlation and
|
||||
the stream id; that is safe because the streaming-frame `kind` is
|
||||
disjoint from the response frame and never collides.
|
||||
@@ -0,0 +1,591 @@
|
||||
import { createInProcessPortPair } from "@suaveplan/api-worker-rpc/testing";
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { RouterBridgeError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
BRIDGE_DISPATCH_METHOD,
|
||||
createMainBridge,
|
||||
createWorkerBridge,
|
||||
} from "./bridge.js";
|
||||
|
||||
function setupBridge(
|
||||
handler: (req: Request) => Promise<Response> | Response,
|
||||
options: Parameters<typeof createMainBridge>[1] = {}
|
||||
) {
|
||||
const ports = createInProcessPortPair();
|
||||
const worker = createWorkerBridge(ports.serverPort, handler, options);
|
||||
const main = createMainBridge(ports.clientPort, options);
|
||||
return {
|
||||
ports,
|
||||
main,
|
||||
worker,
|
||||
teardown: () => {
|
||||
main.close();
|
||||
worker.close();
|
||||
ports.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("bridge", () => {
|
||||
it("method literal exposed", { timeout: 5000 }, () => {
|
||||
expect(BRIDGE_DISPATCH_METHOD).toBe("http.dispatch");
|
||||
});
|
||||
|
||||
it("GET round-trips", { timeout: 5000 }, async () => {
|
||||
const t = setupBridge(
|
||||
async (req) =>
|
||||
new Response(`hit:${new URL(req.url).pathname}`, {
|
||||
status: 200,
|
||||
})
|
||||
);
|
||||
try {
|
||||
const res = await t.main.dispatch(
|
||||
new Request("https://api.local/world")
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe("hit:/world");
|
||||
} finally {
|
||||
t.teardown();
|
||||
}
|
||||
});
|
||||
|
||||
it("POST with JSON body round-trips", { timeout: 5000 }, async () => {
|
||||
const t = setupBridge(async (req) => {
|
||||
const body = (await req.json()) as { n: number };
|
||||
return new Response(JSON.stringify({ d: body.n * 2 }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
});
|
||||
try {
|
||||
const res = await t.main.dispatch(
|
||||
new Request("https://api.local/d", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ n: 3 }),
|
||||
})
|
||||
);
|
||||
expect(await res.json()).toEqual({ d: 6 });
|
||||
} finally {
|
||||
t.teardown();
|
||||
}
|
||||
});
|
||||
|
||||
it("Authorization header stripped before reaching worker", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = setupBridge(
|
||||
async (req) =>
|
||||
new Response(req.headers.get("authorization") ?? "missing")
|
||||
);
|
||||
try {
|
||||
const res = await t.main.dispatch(
|
||||
new Request("https://api.local/auth", {
|
||||
headers: { authorization: "Bearer leak" },
|
||||
})
|
||||
);
|
||||
expect(await res.text()).toBe("missing");
|
||||
} finally {
|
||||
t.teardown();
|
||||
}
|
||||
});
|
||||
|
||||
it("handler-thrown SuaveplanError propagates with code", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = setupBridge(() => {
|
||||
throw new SuaveplanError("PLUGIN.X.NOT_FOUND", "gone");
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
t.main.dispatch(new Request("https://api.local/x"))
|
||||
).rejects.toMatchObject({ code: "PLUGIN.X.NOT_FOUND" });
|
||||
} finally {
|
||||
t.teardown();
|
||||
}
|
||||
});
|
||||
|
||||
it("handler-thrown plain Error propagates as Error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = setupBridge(() => {
|
||||
throw new RangeError("boom");
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
t.main.dispatch(new Request("https://api.local/"))
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
t.teardown();
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores unrelated request frames", { timeout: 5000 }, async () => {
|
||||
const t = setupBridge(async () => new Response("ok"));
|
||||
try {
|
||||
t.ports.clientPort.postMessage({
|
||||
v: "1.0.0",
|
||||
kind: "request",
|
||||
id: 999,
|
||||
method: "not.bridge",
|
||||
params: [{}],
|
||||
});
|
||||
t.ports.serverPort.postMessage({
|
||||
v: "1.0.0",
|
||||
kind: "progress",
|
||||
id: 5,
|
||||
});
|
||||
const res = await t.main.dispatch(
|
||||
new Request("https://api.local/")
|
||||
);
|
||||
expect(await res.text()).toBe("ok");
|
||||
} finally {
|
||||
t.teardown();
|
||||
}
|
||||
});
|
||||
|
||||
it("main bridge close rejects pending calls", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = setupBridge(
|
||||
() =>
|
||||
new Promise<Response>((r) =>
|
||||
setTimeout(() => r(new Response("late")), 200)
|
||||
)
|
||||
);
|
||||
try {
|
||||
const promise = t.main.dispatch(
|
||||
new Request("https://api.local/slow")
|
||||
);
|
||||
// let dispatch finish marshalling + register pending
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
t.main.close();
|
||||
await expect(promise).rejects.toBeInstanceOf(RouterBridgeError);
|
||||
} finally {
|
||||
t.worker.close();
|
||||
t.ports.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("close during marshal rejects the in-flight dispatch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = setupBridge(async () => new Response("ok"));
|
||||
try {
|
||||
const slowRequest = new Request("https://api.local/", {
|
||||
method: "POST",
|
||||
body: "x",
|
||||
});
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => {
|
||||
release = r;
|
||||
});
|
||||
(
|
||||
slowRequest as unknown as {
|
||||
arrayBuffer: () => Promise<ArrayBuffer>;
|
||||
}
|
||||
).arrayBuffer = async () => {
|
||||
await gate;
|
||||
return new ArrayBuffer(0);
|
||||
};
|
||||
const promise = t.main.dispatch(slowRequest);
|
||||
t.main.close();
|
||||
release();
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
code: "ROUTER_BRIDGE.STREAM_INTERRUPTED",
|
||||
});
|
||||
} finally {
|
||||
t.worker.close();
|
||||
t.ports.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("main bridge close prevents new dispatches", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = setupBridge(async () => new Response("x"));
|
||||
t.main.close();
|
||||
await expect(
|
||||
t.main.dispatch(new Request("https://api.local/"))
|
||||
).rejects.toMatchObject({
|
||||
code: "ROUTER_BRIDGE.STREAM_INTERRUPTED",
|
||||
});
|
||||
t.worker.close();
|
||||
t.ports.close();
|
||||
});
|
||||
|
||||
it("rejects malformed request envelope server-side", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = setupBridge(async () => new Response("ok"));
|
||||
try {
|
||||
// Send a manual malformed request envelope.
|
||||
const manual = new Promise<unknown>((resolve) => {
|
||||
t.ports.clientPort.addEventListener("message", (event) => {
|
||||
const d = event.data as {
|
||||
kind?: string;
|
||||
id?: number;
|
||||
ok?: boolean;
|
||||
};
|
||||
if (d.kind === "response" && d.id === 42) resolve(d);
|
||||
});
|
||||
});
|
||||
t.ports.clientPort.postMessage({
|
||||
v: "1.0.0",
|
||||
kind: "request",
|
||||
id: 42,
|
||||
method: BRIDGE_DISPATCH_METHOD,
|
||||
params: [{ bad: "envelope" }],
|
||||
});
|
||||
const got = (await manual) as { ok: boolean };
|
||||
expect(got.ok).toBe(false);
|
||||
} finally {
|
||||
t.teardown();
|
||||
}
|
||||
});
|
||||
|
||||
it("main side reports response_invalid on malformed result", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
const main = createMainBridge(ports.clientPort);
|
||||
try {
|
||||
// Stand in for a worker: send a malformed result.
|
||||
ports.serverPort.addEventListener("message", (event) => {
|
||||
const d = event.data as { kind?: string; id?: number };
|
||||
if (d.kind === "request") {
|
||||
ports.serverPort.postMessage({
|
||||
v: "1.0.0",
|
||||
kind: "response",
|
||||
ok: true,
|
||||
id: d.id,
|
||||
result: { bogus: true },
|
||||
});
|
||||
}
|
||||
});
|
||||
await expect(
|
||||
main.dispatch(new Request("https://api.local/"))
|
||||
).rejects.toMatchObject({
|
||||
code: "ROUTER_BRIDGE.RESPONSE_INVALID",
|
||||
});
|
||||
} finally {
|
||||
main.close();
|
||||
ports.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("main side handles response frame without error envelope", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
const main = createMainBridge(ports.clientPort);
|
||||
try {
|
||||
ports.serverPort.addEventListener("message", (event) => {
|
||||
const d = event.data as { kind?: string; id?: number };
|
||||
if (d.kind === "request") {
|
||||
ports.serverPort.postMessage({
|
||||
v: "1.0.0",
|
||||
kind: "response",
|
||||
ok: false,
|
||||
id: d.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
await expect(
|
||||
main.dispatch(new Request("https://api.local/"))
|
||||
).rejects.toMatchObject({
|
||||
code: "ROUTER_BRIDGE.RESPONSE_INVALID",
|
||||
});
|
||||
} finally {
|
||||
main.close();
|
||||
ports.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("streams a large response when inlineBodyLimit=0", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = setupBridge(
|
||||
async () => {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
for (let i = 0; i < 5; i++)
|
||||
c.enqueue(new Uint8Array([i, i + 1]));
|
||||
c.close();
|
||||
},
|
||||
});
|
||||
return new Response(stream, { status: 200 });
|
||||
},
|
||||
{ inlineBodyLimit: 0, streamWindow: 2 }
|
||||
);
|
||||
try {
|
||||
const res = await t.main.dispatch(
|
||||
new Request("https://api.local/dl")
|
||||
);
|
||||
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||
expect(bytes.length).toBe(10);
|
||||
} finally {
|
||||
t.teardown();
|
||||
}
|
||||
});
|
||||
|
||||
it("streams a large request body when inlineBodyLimit=0", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = setupBridge(
|
||||
async (req) => {
|
||||
const body = new Uint8Array(await req.arrayBuffer());
|
||||
return new Response(`got:${body.length}`);
|
||||
},
|
||||
{ inlineBodyLimit: 0, streamWindow: 2 }
|
||||
);
|
||||
try {
|
||||
const upstream = new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
for (let i = 0; i < 4; i++)
|
||||
c.enqueue(new Uint8Array([i, i, i]));
|
||||
c.close();
|
||||
},
|
||||
});
|
||||
const req = new Request("https://api.local/up", {
|
||||
method: "POST",
|
||||
body: upstream,
|
||||
duplex: "half",
|
||||
} as RequestInit);
|
||||
const res = await t.main.dispatch(req);
|
||||
expect(await res.text()).toBe("got:12");
|
||||
} finally {
|
||||
t.teardown();
|
||||
}
|
||||
});
|
||||
|
||||
it("request stream failing after response already resolved is silent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = setupBridge(async () => new Response("done"), {
|
||||
inlineBodyLimit: 0,
|
||||
});
|
||||
try {
|
||||
// Body that emits one chunk then fails AFTER a microtask delay,
|
||||
// giving the worker time to respond first.
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async start(c) {
|
||||
c.enqueue(new Uint8Array([1]));
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
c.error(new Error("late-failure"));
|
||||
},
|
||||
});
|
||||
const req = new Request("https://api.local/", {
|
||||
method: "POST",
|
||||
body,
|
||||
duplex: "half",
|
||||
} as RequestInit);
|
||||
const res = await t.main.dispatch(req);
|
||||
expect(await res.text()).toBe("done");
|
||||
// Allow stream failure to fire after response settled.
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
} finally {
|
||||
t.teardown();
|
||||
}
|
||||
});
|
||||
|
||||
it("request stream failure rejects the pending call", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = setupBridge(async () => new Response("ok"), {
|
||||
inlineBodyLimit: 0,
|
||||
});
|
||||
try {
|
||||
const failing = new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
c.error(new Error("upstream-died"));
|
||||
},
|
||||
});
|
||||
const req = new Request("https://api.local/up", {
|
||||
method: "POST",
|
||||
body: failing,
|
||||
duplex: "half",
|
||||
} as RequestInit);
|
||||
await expect(t.main.dispatch(req)).rejects.toThrow();
|
||||
} finally {
|
||||
t.teardown();
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores responses with no pending call", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
const main = createMainBridge(ports.clientPort);
|
||||
try {
|
||||
ports.serverPort.postMessage({
|
||||
v: "1.0.0",
|
||||
kind: "response",
|
||||
ok: true,
|
||||
id: 12345,
|
||||
result: {
|
||||
status: 200,
|
||||
statusText: "",
|
||||
headers: [],
|
||||
body: { kind: "none" },
|
||||
},
|
||||
});
|
||||
// Should not throw / hang
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
} finally {
|
||||
main.close();
|
||||
ports.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("non-Error handler throw wraps as MARSHAL_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = setupBridge(() => {
|
||||
// eslint-disable-next-line no-throw-literal
|
||||
throw "string-throw";
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
t.main.dispatch(new Request("https://api.local/"))
|
||||
).rejects.toMatchObject({
|
||||
code: "ROUTER_BRIDGE.MARSHAL_FAILED",
|
||||
});
|
||||
} finally {
|
||||
t.teardown();
|
||||
}
|
||||
});
|
||||
|
||||
it("response stream error sends stream-end !ok", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = setupBridge(
|
||||
async () => {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
c.enqueue(new Uint8Array([1]));
|
||||
c.error(new Error("downstream"));
|
||||
},
|
||||
});
|
||||
return new Response(stream, { status: 200 });
|
||||
},
|
||||
{ inlineBodyLimit: 0, streamWindow: 1 }
|
||||
);
|
||||
try {
|
||||
const res = await t.main.dispatch(
|
||||
new Request("https://api.local/dl")
|
||||
);
|
||||
await expect(res.arrayBuffer()).rejects.toBeInstanceOf(Error);
|
||||
} finally {
|
||||
t.teardown();
|
||||
}
|
||||
});
|
||||
|
||||
it("non-Error response stream producer wraps as STREAM_INTERRUPTED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = setupBridge(
|
||||
async () => {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull() {
|
||||
// eslint-disable-next-line no-throw-literal
|
||||
throw "non-error";
|
||||
},
|
||||
});
|
||||
return new Response(stream);
|
||||
},
|
||||
{ inlineBodyLimit: 0 }
|
||||
);
|
||||
try {
|
||||
const res = await t.main.dispatch(
|
||||
new Request("https://api.local/")
|
||||
);
|
||||
await expect(res.arrayBuffer()).rejects.toBeInstanceOf(Error);
|
||||
} finally {
|
||||
t.teardown();
|
||||
}
|
||||
});
|
||||
|
||||
it("response marshal failure surfaces RESPONSE_INVALID", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = setupBridge(async () => {
|
||||
const r = new Response("x");
|
||||
Object.defineProperty(r, "status", { value: 700 });
|
||||
return r;
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
t.main.dispatch(new Request("https://api.local/"))
|
||||
).rejects.toMatchObject({
|
||||
code: "ROUTER_BRIDGE.RESPONSE_INVALID",
|
||||
});
|
||||
} finally {
|
||||
t.teardown();
|
||||
}
|
||||
});
|
||||
|
||||
it("passes maxBodyBytes and headerDenyList through to marshal options", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = setupBridge(
|
||||
async (req) =>
|
||||
new Response(req.headers.get("x-allow") ?? "missing"),
|
||||
{
|
||||
maxBodyBytes: 1024 * 1024,
|
||||
headerDenyList: ["x-private"],
|
||||
}
|
||||
);
|
||||
try {
|
||||
const res = await t.main.dispatch(
|
||||
new Request("https://api.local/", {
|
||||
headers: {
|
||||
"x-allow": "passed",
|
||||
"x-private": "hidden",
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(await res.text()).toBe("passed");
|
||||
} finally {
|
||||
t.teardown();
|
||||
}
|
||||
});
|
||||
|
||||
it("default options work without explicit BridgeOptions", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
const worker = createWorkerBridge(
|
||||
ports.serverPort,
|
||||
async () => new Response("default")
|
||||
);
|
||||
const main = createMainBridge(ports.clientPort);
|
||||
try {
|
||||
const res = await main.dispatch(new Request("https://api.local/"));
|
||||
expect(await res.text()).toBe("default");
|
||||
} finally {
|
||||
main.close();
|
||||
worker.close();
|
||||
ports.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores non-response frames on main side", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
const main = createMainBridge(ports.clientPort);
|
||||
try {
|
||||
ports.serverPort.postMessage({
|
||||
v: "1.0.0",
|
||||
kind: "progress",
|
||||
id: 1,
|
||||
});
|
||||
ports.serverPort.postMessage({ kind: "not-a-frame" });
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
} finally {
|
||||
main.close();
|
||||
ports.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,372 @@
|
||||
import {
|
||||
envelopeToError,
|
||||
errorToEnvelope,
|
||||
} from "@suaveplan/api-worker-rpc/error-reconstruction";
|
||||
import { PROTOCOL_VERSION } from "@suaveplan/api-worker-rpc/protocol";
|
||||
import type { PortLike } from "@suaveplan/api-worker-rpc/testing";
|
||||
import {
|
||||
ROUTER_BRIDGE_CODES,
|
||||
RouterBridgeError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
marshalRequest,
|
||||
rehydrateRequest,
|
||||
type SerializedRequest,
|
||||
serializedRequestSchema,
|
||||
} from "../marshal-request/marshal-request.js";
|
||||
import {
|
||||
marshalResponse,
|
||||
rehydrateResponse,
|
||||
type SerializedResponse,
|
||||
serializedResponseSchema,
|
||||
} from "../marshal-response/marshal-response.js";
|
||||
import { receiveStream, sendStream } from "../streaming/streaming.js";
|
||||
|
||||
/** RPC method literal used for the bridge HTTP-dispatch call. */
|
||||
export const BRIDGE_DISPATCH_METHOD = "http.dispatch";
|
||||
|
||||
/** Handler shape accepted by {@link createWorkerBridge}. */
|
||||
export type WorkerHandler = (request: Request) => Promise<Response> | Response;
|
||||
|
||||
/** Options for both bridge halves. */
|
||||
export interface BridgeOptions {
|
||||
readonly inlineBodyLimit?: number;
|
||||
readonly maxBodyBytes?: number;
|
||||
readonly headerDenyList?: ReadonlyArray<string>;
|
||||
readonly streamWindow?: number;
|
||||
}
|
||||
|
||||
function streamOpts(window?: number): { window?: number } {
|
||||
return window === undefined ? {} : { window };
|
||||
}
|
||||
|
||||
function reqOpts(
|
||||
options: BridgeOptions,
|
||||
streamId?: number
|
||||
): {
|
||||
inlineBodyLimit?: number;
|
||||
maxBodyBytes?: number;
|
||||
headerDenyList?: ReadonlyArray<string>;
|
||||
streamId?: number;
|
||||
} {
|
||||
const out: {
|
||||
inlineBodyLimit?: number;
|
||||
maxBodyBytes?: number;
|
||||
headerDenyList?: ReadonlyArray<string>;
|
||||
streamId?: number;
|
||||
} = {};
|
||||
if (options.inlineBodyLimit !== undefined)
|
||||
out.inlineBodyLimit = options.inlineBodyLimit;
|
||||
if (options.maxBodyBytes !== undefined)
|
||||
out.maxBodyBytes = options.maxBodyBytes;
|
||||
if (options.headerDenyList !== undefined)
|
||||
out.headerDenyList = options.headerDenyList;
|
||||
if (streamId !== undefined) out.streamId = streamId;
|
||||
return out;
|
||||
}
|
||||
|
||||
function resOpts(
|
||||
options: BridgeOptions,
|
||||
streamId?: number
|
||||
): {
|
||||
inlineBodyLimit?: number;
|
||||
maxBodyBytes?: number;
|
||||
streamId?: number;
|
||||
} {
|
||||
const out: {
|
||||
inlineBodyLimit?: number;
|
||||
maxBodyBytes?: number;
|
||||
streamId?: number;
|
||||
} = {};
|
||||
if (options.inlineBodyLimit !== undefined)
|
||||
out.inlineBodyLimit = options.inlineBodyLimit;
|
||||
if (options.maxBodyBytes !== undefined)
|
||||
out.maxBodyBytes = options.maxBodyBytes;
|
||||
if (streamId !== undefined) out.streamId = streamId;
|
||||
return out;
|
||||
}
|
||||
|
||||
interface PendingCall {
|
||||
readonly resolve: (response: Response) => void;
|
||||
readonly reject: (err: Error) => void;
|
||||
readonly cleanup: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a main-side bridge over `port`. Returns a `dispatch(request)`
|
||||
* function: marshals the request, sends a `request` frame across the
|
||||
* worker port, awaits the `response` frame, rehydrates the response, and
|
||||
* returns it.
|
||||
*/
|
||||
export function createMainBridge(
|
||||
port: PortLike,
|
||||
options: BridgeOptions = {}
|
||||
): {
|
||||
dispatch(request: Request): Promise<Response>;
|
||||
close(): void;
|
||||
} {
|
||||
const pending = new Map<number, PendingCall>();
|
||||
let nextId = 1;
|
||||
let closed = false;
|
||||
|
||||
const listener = (event: { data: unknown }): void => {
|
||||
const data = event.data as {
|
||||
kind?: string;
|
||||
id?: number;
|
||||
ok?: boolean;
|
||||
result?: unknown;
|
||||
error?: Parameters<typeof envelopeToError>[0];
|
||||
};
|
||||
if (data.kind !== "response" || typeof data.id !== "number") return;
|
||||
const call = pending.get(data.id);
|
||||
if (call === undefined) return;
|
||||
pending.delete(data.id);
|
||||
if (data.ok === true) {
|
||||
try {
|
||||
const result = serializedResponseSchema.parse(data.result);
|
||||
const streamBody =
|
||||
result.body.kind === "stream"
|
||||
? receiveStream(
|
||||
port,
|
||||
result.body.streamId,
|
||||
streamOpts(options.streamWindow)
|
||||
)
|
||||
: undefined;
|
||||
call.resolve(rehydrateResponse(result, streamBody));
|
||||
} catch (cause) {
|
||||
call.reject(
|
||||
new RouterBridgeError(
|
||||
ROUTER_BRIDGE_CODES.RESPONSE_INVALID,
|
||||
"response envelope failed validation",
|
||||
{ cause }
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (data.error !== undefined) {
|
||||
call.reject(envelopeToError(data.error));
|
||||
} else {
|
||||
call.reject(
|
||||
new RouterBridgeError(
|
||||
ROUTER_BRIDGE_CODES.RESPONSE_INVALID,
|
||||
"response frame !ok without error envelope"
|
||||
)
|
||||
);
|
||||
}
|
||||
call.cleanup();
|
||||
};
|
||||
port.addEventListener("message", listener);
|
||||
|
||||
async function dispatch(request: Request): Promise<Response> {
|
||||
if (closed) {
|
||||
throw new RouterBridgeError(
|
||||
ROUTER_BRIDGE_CODES.STREAM_INTERRUPTED,
|
||||
"bridge is closed"
|
||||
);
|
||||
}
|
||||
const id = nextId++;
|
||||
const inlineLimit = options.inlineBodyLimit ?? undefined;
|
||||
const streamRequestBody =
|
||||
request.body !== null &&
|
||||
inlineLimit !== undefined &&
|
||||
inlineLimit === 0;
|
||||
const streamId = streamRequestBody ? id : undefined;
|
||||
|
||||
const envelope = await marshalRequest(
|
||||
request,
|
||||
reqOpts(options, streamId)
|
||||
);
|
||||
if (closed) {
|
||||
throw new RouterBridgeError(
|
||||
ROUTER_BRIDGE_CODES.STREAM_INTERRUPTED,
|
||||
"bridge closed mid-call"
|
||||
);
|
||||
}
|
||||
|
||||
const responsePromise = new Promise<Response>((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject, cleanup: () => {} });
|
||||
});
|
||||
|
||||
port.postMessage({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "request",
|
||||
id,
|
||||
method: BRIDGE_DISPATCH_METHOD,
|
||||
params: [envelope],
|
||||
});
|
||||
|
||||
if (
|
||||
streamRequestBody &&
|
||||
streamId !== undefined &&
|
||||
request.body !== null
|
||||
) {
|
||||
sendStream(
|
||||
port,
|
||||
streamId,
|
||||
request.body,
|
||||
streamOpts(options.streamWindow)
|
||||
).catch((err: Error) => {
|
||||
const call = pending.get(id);
|
||||
if (call !== undefined) {
|
||||
pending.delete(id);
|
||||
call.reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return responsePromise;
|
||||
}
|
||||
|
||||
return {
|
||||
dispatch,
|
||||
close(): void {
|
||||
closed = true;
|
||||
port.removeEventListener("message", listener);
|
||||
for (const [, call] of pending) {
|
||||
call.reject(
|
||||
new RouterBridgeError(
|
||||
ROUTER_BRIDGE_CODES.STREAM_INTERRUPTED,
|
||||
"bridge closed mid-call"
|
||||
)
|
||||
);
|
||||
}
|
||||
pending.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a worker-side bridge over `port`. Listens for `request` frames
|
||||
* targeted at the bridge dispatch method, rehydrates the request,
|
||||
* invokes the supplied handler, and marshals the response back.
|
||||
*/
|
||||
export function createWorkerBridge(
|
||||
port: PortLike,
|
||||
handler: WorkerHandler,
|
||||
options: BridgeOptions = {}
|
||||
): { close(): void } {
|
||||
const listener = (event: { data: unknown }): void => {
|
||||
const data = event.data as {
|
||||
kind?: string;
|
||||
id?: number;
|
||||
method?: string;
|
||||
params?: ReadonlyArray<unknown>;
|
||||
};
|
||||
if (
|
||||
data.kind !== "request" ||
|
||||
data.method !== BRIDGE_DISPATCH_METHOD ||
|
||||
typeof data.id !== "number" ||
|
||||
data.params === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const id = data.id;
|
||||
const rawEnvelope = data.params[0];
|
||||
void handle(id, rawEnvelope);
|
||||
};
|
||||
|
||||
async function handle(id: number, rawEnvelope: unknown): Promise<void> {
|
||||
let envelope: SerializedRequest;
|
||||
try {
|
||||
envelope = serializedRequestSchema.parse(rawEnvelope);
|
||||
} catch (cause) {
|
||||
const err = new RouterBridgeError(
|
||||
ROUTER_BRIDGE_CODES.MARSHAL_FAILED,
|
||||
"request envelope failed validation",
|
||||
{ cause }
|
||||
);
|
||||
port.postMessage({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "response",
|
||||
ok: false,
|
||||
id,
|
||||
error: errorToEnvelope(err),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const streamBody =
|
||||
envelope.body.kind === "stream"
|
||||
? receiveStream(
|
||||
port,
|
||||
envelope.body.streamId,
|
||||
streamOpts(options.streamWindow)
|
||||
)
|
||||
: undefined;
|
||||
const request = rehydrateRequest(envelope, streamBody);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await handler(request);
|
||||
} catch (cause) {
|
||||
const err =
|
||||
cause instanceof Error
|
||||
? cause
|
||||
: new RouterBridgeError(
|
||||
ROUTER_BRIDGE_CODES.MARSHAL_FAILED,
|
||||
"handler threw non-Error"
|
||||
);
|
||||
port.postMessage({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "response",
|
||||
ok: false,
|
||||
id,
|
||||
error: errorToEnvelope(err),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const streamResponseBody =
|
||||
response.body !== null &&
|
||||
options.inlineBodyLimit !== undefined &&
|
||||
options.inlineBodyLimit === 0;
|
||||
const streamId = streamResponseBody ? id : undefined;
|
||||
const result: SerializedResponse = await marshalResponse(
|
||||
response,
|
||||
resOpts(options, streamId)
|
||||
);
|
||||
port.postMessage({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "response",
|
||||
ok: true,
|
||||
id,
|
||||
result,
|
||||
});
|
||||
if (
|
||||
streamResponseBody &&
|
||||
streamId !== undefined &&
|
||||
response.body !== null
|
||||
) {
|
||||
sendStream(
|
||||
port,
|
||||
streamId,
|
||||
response.body,
|
||||
streamOpts(options.streamWindow)
|
||||
).catch((err: Error) => {
|
||||
port.postMessage({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "stream-end",
|
||||
id: streamId,
|
||||
ok: false,
|
||||
error: errorToEnvelope(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (cause) {
|
||||
port.postMessage({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "response",
|
||||
ok: false,
|
||||
id,
|
||||
error: errorToEnvelope(cause as Error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
port.addEventListener("message", listener);
|
||||
return {
|
||||
close(): void {
|
||||
port.removeEventListener("message", listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type { BridgeOptions, WorkerHandler } from "./bridge.js";
|
||||
export {
|
||||
BRIDGE_DISPATCH_METHOD,
|
||||
createMainBridge,
|
||||
createWorkerBridge,
|
||||
} from "./bridge.js";
|
||||
@@ -0,0 +1,64 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the stable error-code registry for `@suaveplan/api-router-worker-bridge`. Every failure path that arises while marshalling an HTTP `Request` or `Response` across the worker boundary — or while transporting body streams via `@suaveplan/api-worker-rpc`'s streaming frames — raises a typed `RouterBridgeError` carrying one of four namespaced codes. Consumers branch on the literal code value so production behaviour stays stable across patch releases.
|
||||
|
||||
## Features
|
||||
|
||||
- Single source of truth for `ROUTER_BRIDGE.*` codes (frozen tuple, never reordered).
|
||||
- `RouterBridgeError` extends `SuaveplanError` so it round-trips through `@suaveplan/api-worker-rpc`'s error reconstruction with its code preserved.
|
||||
- Codes are exhaustively typed via `RouterBridgeCode` so a `switch` on `err.code` is checked at compile time.
|
||||
- Stable string values are part of the public surface — adding a code is a minor bump; removing or renaming is a breaking change.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
ROUTER_BRIDGE_CODES,
|
||||
RouterBridgeError,
|
||||
} from "@suaveplan/api-router-worker-bridge/error-codes";
|
||||
|
||||
throw new RouterBridgeError(
|
||||
ROUTER_BRIDGE_CODES.MARSHAL_FAILED,
|
||||
"request method missing"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
ROUTER_BRIDGE_CODES,
|
||||
RouterBridgeError,
|
||||
} from "@suaveplan/api-router-worker-bridge/error-codes";
|
||||
|
||||
function handle(err: unknown): string {
|
||||
if (err instanceof RouterBridgeError) {
|
||||
switch (err.code) {
|
||||
case ROUTER_BRIDGE_CODES.BODY_TOO_LARGE:
|
||||
return "413";
|
||||
case ROUTER_BRIDGE_CODES.STREAM_INTERRUPTED:
|
||||
return "499";
|
||||
case ROUTER_BRIDGE_CODES.RESPONSE_INVALID:
|
||||
case ROUTER_BRIDGE_CODES.MARSHAL_FAILED:
|
||||
return "500";
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ROUTER_BRIDGE_CODES` — frozen record of `"ROUTER_BRIDGE.<NAME>"` strings.
|
||||
- `RouterBridgeCode` — union of every member of `ROUTER_BRIDGE_CODES`.
|
||||
- `RouterBridgeError` — `SuaveplanError<RouterBridgeCode>` subclass with `name === "RouterBridgeError"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry uses `defineCodes("ROUTER_BRIDGE", [...] as const)` so TypeScript narrows the value union; the `as const` tuple is non-negotiable. `RouterBridgeError` overrides `name` so structured logging and cross-port reconstruction can identify the class without `instanceof` (which is unreliable across realm boundaries).
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { ROUTER_BRIDGE_CODES, RouterBridgeError } from "./error-codes.js";
|
||||
|
||||
describe("error-codes", () => {
|
||||
it("exposes the four documented codes", { timeout: 5000 }, () => {
|
||||
expect(ROUTER_BRIDGE_CODES.MARSHAL_FAILED).toBe(
|
||||
"ROUTER_BRIDGE.MARSHAL_FAILED"
|
||||
);
|
||||
expect(ROUTER_BRIDGE_CODES.BODY_TOO_LARGE).toBe(
|
||||
"ROUTER_BRIDGE.BODY_TOO_LARGE"
|
||||
);
|
||||
expect(ROUTER_BRIDGE_CODES.STREAM_INTERRUPTED).toBe(
|
||||
"ROUTER_BRIDGE.STREAM_INTERRUPTED"
|
||||
);
|
||||
expect(ROUTER_BRIDGE_CODES.RESPONSE_INVALID).toBe(
|
||||
"ROUTER_BRIDGE.RESPONSE_INVALID"
|
||||
);
|
||||
});
|
||||
|
||||
it("RouterBridgeError carries code, name, and cause", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new RouterBridgeError(
|
||||
ROUTER_BRIDGE_CODES.MARSHAL_FAILED,
|
||||
"boom",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.code).toBe("ROUTER_BRIDGE.MARSHAL_FAILED");
|
||||
expect(err.name).toBe("RouterBridgeError");
|
||||
expect(err.message).toBe("boom");
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("RouterBridgeError without options has no cause", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new RouterBridgeError(
|
||||
ROUTER_BRIDGE_CODES.BODY_TOO_LARGE,
|
||||
"x"
|
||||
);
|
||||
expect(err.cause).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/api-router-worker-bridge`.
|
||||
*
|
||||
* Failures during marshalling or transport across the worker boundary raise
|
||||
* a {@link RouterBridgeError} carrying one of these `ROUTER_BRIDGE.*` codes.
|
||||
*/
|
||||
export const ROUTER_BRIDGE_CODES = defineCodes("ROUTER_BRIDGE", [
|
||||
"MARSHAL_FAILED",
|
||||
"BODY_TOO_LARGE",
|
||||
"STREAM_INTERRUPTED",
|
||||
"RESPONSE_INVALID",
|
||||
] as const);
|
||||
|
||||
/** Union of every fully-qualified `ROUTER_BRIDGE.*` code. */
|
||||
export type RouterBridgeCode =
|
||||
(typeof ROUTER_BRIDGE_CODES)[keyof typeof ROUTER_BRIDGE_CODES];
|
||||
|
||||
/**
|
||||
* Typed error class for `@suaveplan/api-router-worker-bridge`.
|
||||
*
|
||||
* Extends {@link SuaveplanError} so callers may discriminate via either
|
||||
* `instanceof RouterBridgeError` or `error.code === ROUTER_BRIDGE_CODES.X`.
|
||||
*/
|
||||
export class RouterBridgeError extends SuaveplanError<RouterBridgeCode> {
|
||||
public constructor(
|
||||
code: RouterBridgeCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.name = "RouterBridgeError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { RouterBridgeCode } from "./error-codes.js";
|
||||
export { ROUTER_BRIDGE_CODES, RouterBridgeError } from "./error-codes.js";
|
||||
57
packages/api-stack/api/api-router-worker-bridge/src/index.ts
Normal file
57
packages/api-stack/api/api-router-worker-bridge/src/index.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// Error codes
|
||||
|
||||
// Bridge
|
||||
export type { BridgeOptions, WorkerHandler } from "./bridge/bridge.js";
|
||||
export {
|
||||
BRIDGE_DISPATCH_METHOD,
|
||||
createMainBridge,
|
||||
createWorkerBridge,
|
||||
} from "./bridge/bridge.js";
|
||||
export type { RouterBridgeCode } from "./error-codes/error-codes.js";
|
||||
export {
|
||||
ROUTER_BRIDGE_CODES,
|
||||
RouterBridgeError,
|
||||
} from "./error-codes/error-codes.js";
|
||||
// Marshal request
|
||||
export type {
|
||||
MarshalRequestOptions,
|
||||
SerializedRequest,
|
||||
SerializedRequestBody,
|
||||
} from "./marshal-request/marshal-request.js";
|
||||
export {
|
||||
DEFAULT_HEADER_DENY_LIST,
|
||||
DEFAULT_INLINE_BODY_LIMIT,
|
||||
DEFAULT_MAX_BODY_BYTES,
|
||||
marshalRequest,
|
||||
rehydrateRequest,
|
||||
serializedRequestSchema,
|
||||
} from "./marshal-request/marshal-request.js";
|
||||
// Marshal response
|
||||
export type {
|
||||
MarshalResponseOptions,
|
||||
SerializedResponse,
|
||||
SerializedResponseBody,
|
||||
} from "./marshal-response/marshal-response.js";
|
||||
export {
|
||||
DEFAULT_RESPONSE_INLINE_LIMIT,
|
||||
DEFAULT_RESPONSE_MAX_BYTES,
|
||||
marshalResponse,
|
||||
rehydrateResponse,
|
||||
serializedResponseSchema,
|
||||
} from "./marshal-response/marshal-response.js";
|
||||
// Streaming
|
||||
export type { StreamOptions } from "./streaming/streaming.js";
|
||||
export {
|
||||
DEFAULT_STREAM_WINDOW,
|
||||
receiveStream,
|
||||
sendStream,
|
||||
} from "./streaming/streaming.js";
|
||||
// Testing
|
||||
export type {
|
||||
ConformanceRunner,
|
||||
InProcessBridge,
|
||||
} from "./testing/testing.js";
|
||||
export {
|
||||
createInProcessBridge,
|
||||
runBridgeConformance,
|
||||
} from "./testing/testing.js";
|
||||
@@ -0,0 +1,13 @@
|
||||
export type {
|
||||
MarshalRequestOptions,
|
||||
SerializedRequest,
|
||||
SerializedRequestBody,
|
||||
} from "./marshal-request.js";
|
||||
export {
|
||||
DEFAULT_HEADER_DENY_LIST,
|
||||
DEFAULT_INLINE_BODY_LIMIT,
|
||||
DEFAULT_MAX_BODY_BYTES,
|
||||
marshalRequest,
|
||||
rehydrateRequest,
|
||||
serializedRequestSchema,
|
||||
} from "./marshal-request.js";
|
||||
@@ -0,0 +1,82 @@
|
||||
# `marshal-request`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module converts a Fetch `Request` into a plain `SerializedRequest`
|
||||
object that is safe to ship across a `MessagePort`. The raw `Request`
|
||||
instance NEVER appears on the wire: only the method, URL, an allow-listed
|
||||
header tuple list, and a body descriptor are serialised. The body is
|
||||
either absent, inlined as `Uint8Array` (small requests), or replaced by a
|
||||
`streamId` reference that the streaming module wires up over
|
||||
`@suaveplan/api-worker-rpc`.
|
||||
|
||||
## Features
|
||||
|
||||
- Strips credentials-bearing headers (`Authorization`, `Cookie`, etc.) by
|
||||
default; the deny-list is overrideable but cannot be silently bypassed.
|
||||
- Inlines bodies up to `DEFAULT_INLINE_BODY_LIMIT` (64 KiB); larger bodies
|
||||
REQUIRE a caller-supplied `streamId` and are paired with the streaming
|
||||
module's frames.
|
||||
- Enforces a hard `DEFAULT_MAX_BODY_BYTES` cap (16 MiB) by raising
|
||||
`RouterBridgeError(ROUTER_BRIDGE.BODY_TOO_LARGE)`.
|
||||
- Provides `rehydrateRequest` on the worker side that reconstructs a real
|
||||
Fetch `Request` (including `duplex: "half"` for streamed bodies).
|
||||
- Round-trip Zod schema (`serializedRequestSchema`) so envelope validation
|
||||
on either end fails with a precise issue tree.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { marshalRequest, rehydrateRequest } from
|
||||
"@suaveplan/api-router-worker-bridge/marshal-request";
|
||||
|
||||
const wire = await marshalRequest(
|
||||
new Request("https://api.example.com/v1/ping", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", "authorization": "Bearer x" },
|
||||
body: JSON.stringify({ ok: true }),
|
||||
})
|
||||
);
|
||||
// wire.headers has no "authorization" entry
|
||||
const reborn = rehydrateRequest(wire);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Streaming a large body via a separate stream id:
|
||||
|
||||
```ts
|
||||
import {
|
||||
marshalRequest,
|
||||
rehydrateRequest,
|
||||
} from "@suaveplan/api-router-worker-bridge/marshal-request";
|
||||
|
||||
const streamId = 42;
|
||||
const wire = await marshalRequest(largeUploadRequest, {
|
||||
streamId,
|
||||
inlineBodyLimit: 0,
|
||||
});
|
||||
// caller pipes `largeUploadRequest.body` over the streaming channel
|
||||
// keyed on `streamId`; worker side calls:
|
||||
const reborn = rehydrateRequest(wire, workerStreamFor(streamId));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `marshalRequest(request, options?) → Promise<SerializedRequest>`.
|
||||
- `rehydrateRequest(serialized, streamBody?) → Request`.
|
||||
- `serializedRequestSchema` — Zod schema validating the envelope shape.
|
||||
- `DEFAULT_INLINE_BODY_LIMIT`, `DEFAULT_MAX_BODY_BYTES`, `DEFAULT_HEADER_DENY_LIST`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The serialised body is a tagged union (`"none" | "inline" | "stream"`); a
|
||||
single discriminant avoids the ambiguity of treating `byteLength === 0` as
|
||||
"no body". `request.headers.forEach` is used instead of spreading entries
|
||||
because cross-runtime `Headers` iteration is more reliable that way.
|
||||
`duplex: "half"` is only set when reconstructing a request with a body —
|
||||
Node refuses the flag otherwise.
|
||||
@@ -0,0 +1,222 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { RouterBridgeError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
DEFAULT_HEADER_DENY_LIST,
|
||||
DEFAULT_INLINE_BODY_LIMIT,
|
||||
DEFAULT_MAX_BODY_BYTES,
|
||||
marshalRequest,
|
||||
rehydrateRequest,
|
||||
serializedRequestSchema,
|
||||
} from "./marshal-request.js";
|
||||
|
||||
describe("marshal-request", () => {
|
||||
it("marshals a plain GET", { timeout: 5000 }, async () => {
|
||||
const wire = await marshalRequest(
|
||||
new Request("https://api.local/x?y=1", {
|
||||
headers: { "x-trace": "abc" },
|
||||
})
|
||||
);
|
||||
expect(wire.method).toBe("GET");
|
||||
expect(wire.url).toBe("https://api.local/x?y=1");
|
||||
expect(wire.headers.find(([k]) => k === "x-trace")?.[1]).toBe("abc");
|
||||
expect(wire.body.kind).toBe("none");
|
||||
});
|
||||
|
||||
it("strips deny-listed headers by default", { timeout: 5000 }, async () => {
|
||||
const wire = await marshalRequest(
|
||||
new Request("https://api.local/", {
|
||||
headers: {
|
||||
authorization: "Bearer leak",
|
||||
cookie: "sid=1",
|
||||
"x-ok": "yes",
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(
|
||||
wire.headers.find(([k]) => k === "authorization")
|
||||
).toBeUndefined();
|
||||
expect(wire.headers.find(([k]) => k === "cookie")).toBeUndefined();
|
||||
expect(wire.headers.find(([k]) => k === "x-ok")?.[1]).toBe("yes");
|
||||
});
|
||||
|
||||
it("honours a custom deny-list", { timeout: 5000 }, async () => {
|
||||
const wire = await marshalRequest(
|
||||
new Request("https://api.local/", {
|
||||
headers: {
|
||||
"x-custom-sensitive": "secret",
|
||||
authorization: "Bearer x",
|
||||
},
|
||||
}),
|
||||
{ headerDenyList: ["x-custom-sensitive"] }
|
||||
);
|
||||
expect(
|
||||
wire.headers.find(([k]) => k === "x-custom-sensitive")
|
||||
).toBeUndefined();
|
||||
// default list overridden: authorization passes through now
|
||||
expect(wire.headers.find(([k]) => k === "authorization")?.[1]).toBe(
|
||||
"Bearer x"
|
||||
);
|
||||
});
|
||||
|
||||
it("inlines small bodies", { timeout: 5000 }, async () => {
|
||||
const wire = await marshalRequest(
|
||||
new Request("https://api.local/p", {
|
||||
method: "POST",
|
||||
body: "hello",
|
||||
})
|
||||
);
|
||||
expect(wire.body.kind).toBe("inline");
|
||||
if (wire.body.kind === "inline") {
|
||||
expect(new TextDecoder().decode(wire.body.bytes)).toBe("hello");
|
||||
}
|
||||
});
|
||||
|
||||
it("tags streamed bodies when streamId supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const wire = await marshalRequest(
|
||||
new Request("https://api.local/p", {
|
||||
method: "POST",
|
||||
body: "stream-me",
|
||||
}),
|
||||
{ streamId: 7 }
|
||||
);
|
||||
expect(wire.body.kind).toBe("stream");
|
||||
if (wire.body.kind === "stream") {
|
||||
expect(wire.body.streamId).toBe(7);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects bodies exceeding the hard max", { timeout: 5000 }, async () => {
|
||||
const payload = new Uint8Array(20);
|
||||
await expect(
|
||||
marshalRequest(
|
||||
new Request("https://api.local/p", {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
}),
|
||||
{ maxBodyBytes: 10, inlineBodyLimit: 5 }
|
||||
)
|
||||
).rejects.toBeInstanceOf(RouterBridgeError);
|
||||
});
|
||||
|
||||
it("rejects inline bodies above inline limit when no stream id", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const payload = new Uint8Array(20);
|
||||
await expect(
|
||||
marshalRequest(
|
||||
new Request("https://api.local/p", {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
}),
|
||||
{ inlineBodyLimit: 5, maxBodyBytes: 1000 }
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: "ROUTER_BRIDGE.BODY_TOO_LARGE",
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps body read failure as MARSHAL_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const failing = new Request("https://api.local/p", {
|
||||
method: "POST",
|
||||
body: "x",
|
||||
});
|
||||
// monkey-patch arrayBuffer to throw
|
||||
(
|
||||
failing as unknown as { arrayBuffer: () => Promise<ArrayBuffer> }
|
||||
).arrayBuffer = () => Promise.reject(new Error("disk gone"));
|
||||
await expect(marshalRequest(failing)).rejects.toMatchObject({
|
||||
code: "ROUTER_BRIDGE.MARSHAL_FAILED",
|
||||
});
|
||||
});
|
||||
|
||||
it("rehydrates a request with no body", { timeout: 5000 }, () => {
|
||||
const reborn = rehydrateRequest({
|
||||
method: "DELETE",
|
||||
url: "https://api.local/x",
|
||||
headers: [["x-a", "1"]],
|
||||
body: { kind: "none" },
|
||||
});
|
||||
expect(reborn.method).toBe("DELETE");
|
||||
expect(reborn.headers.get("x-a")).toBe("1");
|
||||
expect(reborn.body).toBeNull();
|
||||
});
|
||||
|
||||
it("rehydrates a request with an inline body", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const reborn = rehydrateRequest({
|
||||
method: "POST",
|
||||
url: "https://api.local/x",
|
||||
headers: [["content-type", "text/plain"]],
|
||||
body: {
|
||||
kind: "inline",
|
||||
bytes: new TextEncoder().encode("hello"),
|
||||
},
|
||||
});
|
||||
expect(await reborn.text()).toBe("hello");
|
||||
});
|
||||
|
||||
it("rehydrates a streamed body when given a stream", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
c.enqueue(new TextEncoder().encode("chunk"));
|
||||
c.close();
|
||||
},
|
||||
});
|
||||
const reborn = rehydrateRequest(
|
||||
{
|
||||
method: "POST",
|
||||
url: "https://api.local/x",
|
||||
headers: [],
|
||||
body: { kind: "stream", streamId: 1 },
|
||||
},
|
||||
stream
|
||||
);
|
||||
expect(await reborn.text()).toBe("chunk");
|
||||
});
|
||||
|
||||
it("throws when stream body marker has no stream", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
rehydrateRequest({
|
||||
method: "POST",
|
||||
url: "https://api.local/x",
|
||||
headers: [],
|
||||
body: { kind: "stream", streamId: 1 },
|
||||
})
|
||||
).toThrow(RouterBridgeError);
|
||||
});
|
||||
|
||||
it("schema validates well-formed envelopes", { timeout: 5000 }, () => {
|
||||
const ok = serializedRequestSchema.safeParse({
|
||||
method: "GET",
|
||||
url: "https://api.local/",
|
||||
headers: [["x", "y"]],
|
||||
body: { kind: "none" },
|
||||
});
|
||||
expect(ok.success).toBe(true);
|
||||
});
|
||||
|
||||
it("schema rejects bad envelopes", { timeout: 5000 }, () => {
|
||||
const bad = serializedRequestSchema.safeParse({
|
||||
method: "",
|
||||
url: "",
|
||||
headers: [],
|
||||
body: { kind: "what" },
|
||||
});
|
||||
expect(bad.success).toBe(false);
|
||||
});
|
||||
|
||||
it("constants expose the published defaults", { timeout: 5000 }, () => {
|
||||
expect(DEFAULT_INLINE_BODY_LIMIT).toBe(64 * 1024);
|
||||
expect(DEFAULT_MAX_BODY_BYTES).toBe(16 * 1024 * 1024);
|
||||
expect(DEFAULT_HEADER_DENY_LIST).toContain("authorization");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import z from "zod";
|
||||
import {
|
||||
ROUTER_BRIDGE_CODES,
|
||||
RouterBridgeError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Default inline-body cap (bytes). Bodies above this threshold MUST be
|
||||
* streamed via the streaming module rather than inlined into the envelope.
|
||||
*/
|
||||
export const DEFAULT_INLINE_BODY_LIMIT = 64 * 1024;
|
||||
|
||||
/** Default hard body cap (bytes); anything larger is rejected outright. */
|
||||
export const DEFAULT_MAX_BODY_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Header name deny-list. Authorization, cookies and CSRF tokens MUST NOT
|
||||
* cross the worker boundary; the main process keeps sole custody of them.
|
||||
* All comparisons are case-insensitive.
|
||||
*/
|
||||
export const DEFAULT_HEADER_DENY_LIST: ReadonlyArray<string> = [
|
||||
"authorization",
|
||||
"cookie",
|
||||
"set-cookie",
|
||||
"x-csrf-token",
|
||||
"proxy-authorization",
|
||||
];
|
||||
|
||||
/** Configuration for {@link marshalRequest}. */
|
||||
export interface MarshalRequestOptions {
|
||||
readonly inlineBodyLimit?: number;
|
||||
readonly maxBodyBytes?: number;
|
||||
readonly headerDenyList?: ReadonlyArray<string>;
|
||||
/** Pre-allocated stream id to use when the body is streamed. */
|
||||
readonly streamId?: number;
|
||||
}
|
||||
|
||||
/** Body shape inside a {@link SerializedRequest}. */
|
||||
export type SerializedRequestBody =
|
||||
| { readonly kind: "none" }
|
||||
| { readonly kind: "inline"; readonly bytes: Uint8Array }
|
||||
| { readonly kind: "stream"; readonly streamId: number };
|
||||
|
||||
/** Plain-data view of an HTTP request, safe to structuredClone. */
|
||||
export interface SerializedRequest {
|
||||
readonly method: string;
|
||||
readonly url: string;
|
||||
readonly headers: ReadonlyArray<readonly [string, string]>;
|
||||
readonly body: SerializedRequestBody;
|
||||
}
|
||||
|
||||
const serializedRequestBodySchema = z.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("none") }),
|
||||
z.object({ kind: z.literal("inline"), bytes: z.instanceof(Uint8Array) }),
|
||||
z.object({ kind: z.literal("stream"), streamId: z.number().int() }),
|
||||
]);
|
||||
|
||||
/** Zod schema for {@link SerializedRequest}. */
|
||||
export const serializedRequestSchema: z.ZodType<SerializedRequest> = z.object({
|
||||
method: z.string().min(1),
|
||||
url: z.string().min(1),
|
||||
headers: z.array(z.tuple([z.string(), z.string()])),
|
||||
body: serializedRequestBodySchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* Marshal a Fetch `Request` into a plain-data {@link SerializedRequest}.
|
||||
*
|
||||
* The raw `Request` instance is NEVER attached to the envelope. Headers on
|
||||
* the deny-list are stripped before serialisation. Bodies up to
|
||||
* `inlineBodyLimit` are read fully and inlined as `Uint8Array`; larger
|
||||
* bodies are tagged with a `streamId` and the caller is responsible for
|
||||
* piping the original `request.body` over the streaming channel using the
|
||||
* same `streamId`.
|
||||
*/
|
||||
export async function marshalRequest(
|
||||
request: Request,
|
||||
options: MarshalRequestOptions = {}
|
||||
): Promise<SerializedRequest> {
|
||||
const inlineLimit = options.inlineBodyLimit ?? DEFAULT_INLINE_BODY_LIMIT;
|
||||
const maxBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
||||
const deny = (options.headerDenyList ?? DEFAULT_HEADER_DENY_LIST).map((h) =>
|
||||
h.toLowerCase()
|
||||
);
|
||||
|
||||
const headers: Array<readonly [string, string]> = [];
|
||||
request.headers.forEach((value, name) => {
|
||||
if (!deny.includes(name.toLowerCase())) {
|
||||
headers.push([name, value] as const);
|
||||
}
|
||||
});
|
||||
|
||||
const body = await marshalBody(
|
||||
request,
|
||||
inlineLimit,
|
||||
maxBytes,
|
||||
options.streamId
|
||||
);
|
||||
|
||||
return {
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
headers,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
async function marshalBody(
|
||||
request: Request,
|
||||
inlineLimit: number,
|
||||
maxBytes: number,
|
||||
streamId: number | undefined
|
||||
): Promise<SerializedRequestBody> {
|
||||
if (request.body === null) return { kind: "none" };
|
||||
if (streamId !== undefined) return { kind: "stream", streamId };
|
||||
|
||||
let buffer: ArrayBuffer;
|
||||
try {
|
||||
buffer = await request.arrayBuffer();
|
||||
} catch (cause) {
|
||||
throw new RouterBridgeError(
|
||||
ROUTER_BRIDGE_CODES.MARSHAL_FAILED,
|
||||
"failed to read request body",
|
||||
{ cause }
|
||||
);
|
||||
}
|
||||
const bytes = new Uint8Array(buffer);
|
||||
if (bytes.byteLength > maxBytes) {
|
||||
throw new RouterBridgeError(
|
||||
ROUTER_BRIDGE_CODES.BODY_TOO_LARGE,
|
||||
`request body ${bytes.byteLength} bytes exceeds cap ${maxBytes}`
|
||||
);
|
||||
}
|
||||
if (bytes.byteLength > inlineLimit) {
|
||||
throw new RouterBridgeError(
|
||||
ROUTER_BRIDGE_CODES.BODY_TOO_LARGE,
|
||||
`request body ${bytes.byteLength} bytes exceeds inline limit ${inlineLimit}; provide streamId`
|
||||
);
|
||||
}
|
||||
return { kind: "inline", bytes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rehydrate a {@link SerializedRequest} into a Fetch `Request`, optionally
|
||||
* supplying a `ReadableStream` for streamed bodies (the streaming module
|
||||
* exposes such streams via its stream-id mapping).
|
||||
*/
|
||||
export function rehydrateRequest(
|
||||
serialized: SerializedRequest,
|
||||
streamBody?: ReadableStream<Uint8Array>
|
||||
): Request {
|
||||
const headers = new Headers();
|
||||
for (const [k, v] of serialized.headers) headers.append(k, v);
|
||||
|
||||
let body: BodyInit | null = null;
|
||||
if (serialized.body.kind === "inline") {
|
||||
body = serialized.body.bytes as unknown as BodyInit;
|
||||
} else if (serialized.body.kind === "stream") {
|
||||
if (streamBody === undefined) {
|
||||
throw new RouterBridgeError(
|
||||
ROUTER_BRIDGE_CODES.MARSHAL_FAILED,
|
||||
`request body marked stream (id=${serialized.body.streamId}) but no stream supplied`
|
||||
);
|
||||
}
|
||||
body = streamBody;
|
||||
}
|
||||
|
||||
const init: RequestInit & { duplex?: "half" } = {
|
||||
method: serialized.method,
|
||||
headers,
|
||||
body,
|
||||
};
|
||||
if (body !== null && typeof body !== "string") {
|
||||
init.duplex = "half";
|
||||
}
|
||||
return new Request(serialized.url, init);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export type {
|
||||
MarshalResponseOptions,
|
||||
SerializedResponse,
|
||||
SerializedResponseBody,
|
||||
} from "./marshal-response.js";
|
||||
export {
|
||||
DEFAULT_RESPONSE_INLINE_LIMIT,
|
||||
DEFAULT_RESPONSE_MAX_BYTES,
|
||||
marshalResponse,
|
||||
rehydrateResponse,
|
||||
serializedResponseSchema,
|
||||
} from "./marshal-response.js";
|
||||
@@ -0,0 +1,77 @@
|
||||
# `marshal-response`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module is the response-side counterpart to `marshal-request`. It
|
||||
converts a Fetch `Response` produced by a worker-side handler into a
|
||||
plain `SerializedResponse` envelope safe to ship across a `MessagePort`,
|
||||
and rehydrates that envelope back into a real `Response` on the main
|
||||
process so the host pipeline can apply Steps 9-11 (response validation,
|
||||
ETag, Server-Timing aggregation).
|
||||
|
||||
## Features
|
||||
|
||||
- Validates status code is in the 100-599 range (`ROUTER_BRIDGE.RESPONSE_INVALID`).
|
||||
- Preserves status text and full header list (response headers are not on
|
||||
any deny-list — the bridge only filters request-side credentials).
|
||||
- Inlines bodies up to `DEFAULT_RESPONSE_INLINE_LIMIT` (64 KiB) and
|
||||
streams everything else through the streaming module.
|
||||
- Hard cap at `DEFAULT_RESPONSE_MAX_BYTES` (16 MiB) for inline bodies;
|
||||
oversize payloads either stream or fail with
|
||||
`ROUTER_BRIDGE.BODY_TOO_LARGE`.
|
||||
- Tagged-union body shape mirrors the request side for symmetry.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { marshalResponse, rehydrateResponse } from
|
||||
"@suaveplan/api-router-worker-bridge/marshal-response";
|
||||
|
||||
const wire = await marshalResponse(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
);
|
||||
const reborn = rehydrateResponse(wire);
|
||||
await reborn.json(); // { ok: true }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Stream a large attachment response via a stream id:
|
||||
|
||||
```ts
|
||||
import {
|
||||
marshalResponse,
|
||||
rehydrateResponse,
|
||||
} from "@suaveplan/api-router-worker-bridge/marshal-response";
|
||||
|
||||
const streamId = 99;
|
||||
const wire = await marshalResponse(largeDownload, {
|
||||
streamId,
|
||||
inlineBodyLimit: 0,
|
||||
});
|
||||
// caller pipes `largeDownload.body` over the streaming channel; main side:
|
||||
const reborn = rehydrateResponse(wire, mainStreamFor(streamId));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `marshalResponse(response, options?) → Promise<SerializedResponse>`.
|
||||
- `rehydrateResponse(serialized, streamBody?) → Response`.
|
||||
- `serializedResponseSchema` — Zod validator.
|
||||
- `DEFAULT_RESPONSE_INLINE_LIMIT`, `DEFAULT_RESPONSE_MAX_BYTES`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`statusText` is preserved because some clients (and HTTP/1.1 caches)
|
||||
still depend on the reason phrase. The body tagged union is identical in
|
||||
shape to `SerializedRequestBody`, but the two types are intentionally
|
||||
distinct so future divergence (e.g. server-sent-event hints) does not
|
||||
silently propagate. Status validation runs first so a `100/600` test
|
||||
case fails cleanly even when the body would also fail.
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { RouterBridgeError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
DEFAULT_RESPONSE_INLINE_LIMIT,
|
||||
DEFAULT_RESPONSE_MAX_BYTES,
|
||||
marshalResponse,
|
||||
rehydrateResponse,
|
||||
serializedResponseSchema,
|
||||
} from "./marshal-response.js";
|
||||
|
||||
describe("marshal-response", () => {
|
||||
it("marshals a no-body response", { timeout: 5000 }, async () => {
|
||||
const wire = await marshalResponse(new Response(null, { status: 204 }));
|
||||
expect(wire.status).toBe(204);
|
||||
expect(wire.body.kind).toBe("none");
|
||||
});
|
||||
|
||||
it("marshals an inline body and preserves headers", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const wire = await marshalResponse(
|
||||
new Response("hello", {
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: { "x-foo": "bar" },
|
||||
})
|
||||
);
|
||||
expect(wire.status).toBe(200);
|
||||
expect(wire.statusText).toBe("OK");
|
||||
expect(wire.headers.find(([k]) => k === "x-foo")?.[1]).toBe("bar");
|
||||
expect(wire.body.kind).toBe("inline");
|
||||
});
|
||||
|
||||
it("tags streamed bodies when streamId supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const wire = await marshalResponse(new Response("x"), {
|
||||
streamId: 9,
|
||||
});
|
||||
expect(wire.body.kind).toBe("stream");
|
||||
});
|
||||
|
||||
it("rejects invalid status", { timeout: 5000 }, async () => {
|
||||
const r = new Response("x");
|
||||
Object.defineProperty(r, "status", { value: 50 });
|
||||
await expect(marshalResponse(r)).rejects.toMatchObject({
|
||||
code: "ROUTER_BRIDGE.RESPONSE_INVALID",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects oversize inline body", { timeout: 5000 }, async () => {
|
||||
const payload = new Uint8Array(20);
|
||||
await expect(
|
||||
marshalResponse(new Response(payload), {
|
||||
inlineBodyLimit: 5,
|
||||
maxBodyBytes: 10,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "ROUTER_BRIDGE.BODY_TOO_LARGE",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects body above inline limit but below max", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const payload = new Uint8Array(20);
|
||||
await expect(
|
||||
marshalResponse(new Response(payload), {
|
||||
inlineBodyLimit: 5,
|
||||
maxBodyBytes: 1000,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "ROUTER_BRIDGE.BODY_TOO_LARGE",
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps body read failure", { timeout: 5000 }, async () => {
|
||||
const r = new Response("x");
|
||||
(
|
||||
r as unknown as { arrayBuffer: () => Promise<ArrayBuffer> }
|
||||
).arrayBuffer = () => Promise.reject(new Error("nope"));
|
||||
await expect(marshalResponse(r)).rejects.toMatchObject({
|
||||
code: "ROUTER_BRIDGE.MARSHAL_FAILED",
|
||||
});
|
||||
});
|
||||
|
||||
it("rehydrates an inline response", { timeout: 5000 }, async () => {
|
||||
const reborn = rehydrateResponse({
|
||||
status: 201,
|
||||
statusText: "Created",
|
||||
headers: [["x-foo", "bar"]],
|
||||
body: {
|
||||
kind: "inline",
|
||||
bytes: new TextEncoder().encode("yo"),
|
||||
},
|
||||
});
|
||||
expect(reborn.status).toBe(201);
|
||||
expect(reborn.headers.get("x-foo")).toBe("bar");
|
||||
expect(await reborn.text()).toBe("yo");
|
||||
});
|
||||
|
||||
it("rehydrates a no-body response", { timeout: 5000 }, () => {
|
||||
const reborn = rehydrateResponse({
|
||||
status: 204,
|
||||
statusText: "",
|
||||
headers: [],
|
||||
body: { kind: "none" },
|
||||
});
|
||||
expect(reborn.body).toBeNull();
|
||||
});
|
||||
|
||||
it("rehydrates streamed response when given a stream", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
c.enqueue(new TextEncoder().encode("ABC"));
|
||||
c.close();
|
||||
},
|
||||
});
|
||||
const reborn = rehydrateResponse(
|
||||
{
|
||||
status: 200,
|
||||
statusText: "",
|
||||
headers: [],
|
||||
body: { kind: "stream", streamId: 1 },
|
||||
},
|
||||
stream
|
||||
);
|
||||
expect(await reborn.text()).toBe("ABC");
|
||||
});
|
||||
|
||||
it("throws when stream marker lacks a stream", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
rehydrateResponse({
|
||||
status: 200,
|
||||
statusText: "",
|
||||
headers: [],
|
||||
body: { kind: "stream", streamId: 1 },
|
||||
})
|
||||
).toThrow(RouterBridgeError);
|
||||
});
|
||||
|
||||
it("schema accepts and rejects", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
serializedResponseSchema.safeParse({
|
||||
status: 200,
|
||||
statusText: "",
|
||||
headers: [],
|
||||
body: { kind: "none" },
|
||||
}).success
|
||||
).toBe(true);
|
||||
expect(
|
||||
serializedResponseSchema.safeParse({
|
||||
status: 99,
|
||||
statusText: "",
|
||||
headers: [],
|
||||
body: { kind: "none" },
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("constants expose limits", { timeout: 5000 }, () => {
|
||||
expect(DEFAULT_RESPONSE_INLINE_LIMIT).toBe(64 * 1024);
|
||||
expect(DEFAULT_RESPONSE_MAX_BYTES).toBe(16 * 1024 * 1024);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import z from "zod";
|
||||
import {
|
||||
ROUTER_BRIDGE_CODES,
|
||||
RouterBridgeError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/** Default inline-body cap for response bodies (bytes). */
|
||||
export const DEFAULT_RESPONSE_INLINE_LIMIT = 64 * 1024;
|
||||
|
||||
/** Hard cap on inline response bodies. */
|
||||
export const DEFAULT_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
/** Options for {@link marshalResponse}. */
|
||||
export interface MarshalResponseOptions {
|
||||
readonly inlineBodyLimit?: number;
|
||||
readonly maxBodyBytes?: number;
|
||||
readonly streamId?: number;
|
||||
}
|
||||
|
||||
/** Body shape inside a {@link SerializedResponse}. */
|
||||
export type SerializedResponseBody =
|
||||
| { readonly kind: "none" }
|
||||
| { readonly kind: "inline"; readonly bytes: Uint8Array }
|
||||
| { readonly kind: "stream"; readonly streamId: number };
|
||||
|
||||
/** Plain-data view of a Fetch `Response`. */
|
||||
export interface SerializedResponse {
|
||||
readonly status: number;
|
||||
readonly statusText: string;
|
||||
readonly headers: ReadonlyArray<readonly [string, string]>;
|
||||
readonly body: SerializedResponseBody;
|
||||
}
|
||||
|
||||
const serializedResponseBodySchema = z.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("none") }),
|
||||
z.object({ kind: z.literal("inline"), bytes: z.instanceof(Uint8Array) }),
|
||||
z.object({ kind: z.literal("stream"), streamId: z.number().int() }),
|
||||
]);
|
||||
|
||||
/** Zod schema for {@link SerializedResponse}. */
|
||||
export const serializedResponseSchema: z.ZodType<SerializedResponse> = z.object(
|
||||
{
|
||||
status: z.number().int().min(100).max(599),
|
||||
statusText: z.string(),
|
||||
headers: z.array(z.tuple([z.string(), z.string()])),
|
||||
body: serializedResponseBodySchema,
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Marshal a Fetch `Response` into a plain-data {@link SerializedResponse}.
|
||||
*
|
||||
* The response body is either consumed fully (inlined) when under
|
||||
* `inlineBodyLimit`, or replaced by a `streamId` reference. The caller is
|
||||
* responsible for transporting the underlying `response.body` over the
|
||||
* streaming channel when the stream branch is taken.
|
||||
*/
|
||||
export async function marshalResponse(
|
||||
response: Response,
|
||||
options: MarshalResponseOptions = {}
|
||||
): Promise<SerializedResponse> {
|
||||
const inlineLimit =
|
||||
options.inlineBodyLimit ?? DEFAULT_RESPONSE_INLINE_LIMIT;
|
||||
const maxBytes = options.maxBodyBytes ?? DEFAULT_RESPONSE_MAX_BYTES;
|
||||
if (!isValidStatus(response.status)) {
|
||||
throw new RouterBridgeError(
|
||||
ROUTER_BRIDGE_CODES.RESPONSE_INVALID,
|
||||
`response status ${response.status} outside 100-599`
|
||||
);
|
||||
}
|
||||
|
||||
const headers: Array<readonly [string, string]> = [];
|
||||
response.headers.forEach((value, name) => {
|
||||
headers.push([name, value] as const);
|
||||
});
|
||||
|
||||
const body = await marshalResponseBody(
|
||||
response,
|
||||
inlineLimit,
|
||||
maxBytes,
|
||||
options.streamId
|
||||
);
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
function isValidStatus(status: number): boolean {
|
||||
return Number.isInteger(status) && status >= 100 && status <= 599;
|
||||
}
|
||||
|
||||
async function marshalResponseBody(
|
||||
response: Response,
|
||||
inlineLimit: number,
|
||||
maxBytes: number,
|
||||
streamId: number | undefined
|
||||
): Promise<SerializedResponseBody> {
|
||||
if (response.body === null) return { kind: "none" };
|
||||
if (streamId !== undefined) return { kind: "stream", streamId };
|
||||
|
||||
let buffer: ArrayBuffer;
|
||||
try {
|
||||
buffer = await response.arrayBuffer();
|
||||
} catch (cause) {
|
||||
throw new RouterBridgeError(
|
||||
ROUTER_BRIDGE_CODES.MARSHAL_FAILED,
|
||||
"failed to read response body",
|
||||
{ cause }
|
||||
);
|
||||
}
|
||||
const bytes = new Uint8Array(buffer);
|
||||
if (bytes.byteLength > maxBytes) {
|
||||
throw new RouterBridgeError(
|
||||
ROUTER_BRIDGE_CODES.BODY_TOO_LARGE,
|
||||
`response body ${bytes.byteLength} bytes exceeds cap ${maxBytes}`
|
||||
);
|
||||
}
|
||||
if (bytes.byteLength > inlineLimit) {
|
||||
throw new RouterBridgeError(
|
||||
ROUTER_BRIDGE_CODES.BODY_TOO_LARGE,
|
||||
`response body ${bytes.byteLength} bytes exceeds inline limit ${inlineLimit}; provide streamId`
|
||||
);
|
||||
}
|
||||
return { kind: "inline", bytes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rehydrate a {@link SerializedResponse} into a Fetch `Response`. When the
|
||||
* body is `kind: "stream"`, the caller supplies a `ReadableStream` plumbed
|
||||
* by the streaming module.
|
||||
*/
|
||||
export function rehydrateResponse(
|
||||
serialized: SerializedResponse,
|
||||
streamBody?: ReadableStream<Uint8Array>
|
||||
): Response {
|
||||
const headers = new Headers();
|
||||
for (const [k, v] of serialized.headers) headers.append(k, v);
|
||||
|
||||
let body: BodyInit | null = null;
|
||||
if (serialized.body.kind === "inline") {
|
||||
body = serialized.body.bytes as unknown as BodyInit;
|
||||
} else if (serialized.body.kind === "stream") {
|
||||
if (streamBody === undefined) {
|
||||
throw new RouterBridgeError(
|
||||
ROUTER_BRIDGE_CODES.RESPONSE_INVALID,
|
||||
`response body marked stream (id=${serialized.body.streamId}) but no stream supplied`
|
||||
);
|
||||
}
|
||||
body = streamBody;
|
||||
}
|
||||
|
||||
return new Response(body, {
|
||||
status: serialized.status,
|
||||
statusText: serialized.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type { StreamOptions } from "./streaming.js";
|
||||
export {
|
||||
DEFAULT_STREAM_WINDOW,
|
||||
receiveStream,
|
||||
sendStream,
|
||||
} from "./streaming.js";
|
||||
@@ -0,0 +1,66 @@
|
||||
# `streaming`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module marshals binary `ReadableStream<Uint8Array>` bodies between
|
||||
the main and worker processes by piggy-backing on `@suaveplan/api-worker-
|
||||
rpc`'s `stream-chunk` / `stream-end` / `stream-ack` frames. It implements
|
||||
the producer (`sendStream`) and consumer (`receiveStream`) halves of a
|
||||
single body transfer keyed on a numeric `streamId` (the same id baked
|
||||
into the body descriptor on the request or response envelope).
|
||||
|
||||
## Features
|
||||
|
||||
- Window-based back-pressure: the producer stalls after `DEFAULT_STREAM_
|
||||
WINDOW` (16) outbound chunks until the consumer sends a matching
|
||||
`stream-ack`, preventing unbounded buffering across the port.
|
||||
- Reuses the wire frame shape of `api-worker-rpc` so a single port can
|
||||
multiplex RPC and bridge streams interchangeably.
|
||||
- Producer errors are forwarded via `errorToEnvelope`; consumer rehydrates
|
||||
via `envelopeToError`, preserving the `SuaveplanError` code chain.
|
||||
- Defensive enqueue: non-binary chunks fail with `ROUTER_BRIDGE.STREAM_
|
||||
INTERRUPTED` so misuse is loud rather than silently corrupting the body.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
sendStream,
|
||||
receiveStream,
|
||||
} from "@suaveplan/api-router-worker-bridge/streaming";
|
||||
|
||||
const stream = receiveStream(port, 1);
|
||||
// consume `stream` like any ReadableStream
|
||||
await sendStream(port, 1, sourceStream);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Tune the back-pressure window for a high-throughput download path:
|
||||
|
||||
```ts
|
||||
import { sendStream } from "@suaveplan/api-router-worker-bridge/streaming";
|
||||
|
||||
await sendStream(port, streamId, hugeAttachment.body!, { window: 64 });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `sendStream(port, streamId, stream, options?) → Promise<void>`.
|
||||
- `receiveStream(port, streamId, options?) → ReadableStream<Uint8Array>`.
|
||||
- `DEFAULT_STREAM_WINDOW = 16`.
|
||||
- `StreamOptions { window?: number }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The producer's ack listener is registered for the entire pump lifetime and
|
||||
torn down in `finally` so a crash in the source stream cannot leak a
|
||||
listener. `receiveStream` issues a `stream-ack` whenever its locally-
|
||||
buffered count reaches the window, not after every chunk — this matches
|
||||
the producer's pause threshold so throughput is steady rather than
|
||||
sawtoothed. `ArrayBuffer` payloads are coerced to `Uint8Array` to absorb
|
||||
runtime variation in how `MessagePort` surfaces transfers.
|
||||
@@ -0,0 +1,309 @@
|
||||
import { createInProcessPortPair } from "@suaveplan/api-worker-rpc/testing";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DEFAULT_STREAM_WINDOW,
|
||||
receiveStream,
|
||||
sendStream,
|
||||
} from "./streaming.js";
|
||||
|
||||
async function drain(
|
||||
stream: ReadableStream<Uint8Array>
|
||||
): Promise<Uint8Array[]> {
|
||||
const out: Uint8Array[] = [];
|
||||
const reader = stream.getReader();
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
if (value !== undefined) out.push(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("streaming", () => {
|
||||
it("DEFAULT_STREAM_WINDOW exposed", { timeout: 5000 }, () => {
|
||||
expect(DEFAULT_STREAM_WINDOW).toBe(16);
|
||||
});
|
||||
|
||||
it("round-trips a small stream", { timeout: 5000 }, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
try {
|
||||
const recv = receiveStream(ports.clientPort, 1);
|
||||
const src = new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
c.enqueue(new Uint8Array([1, 2]));
|
||||
c.enqueue(new Uint8Array([3]));
|
||||
c.close();
|
||||
},
|
||||
});
|
||||
await sendStream(ports.serverPort, 1, src);
|
||||
const got = await drain(recv);
|
||||
expect(got).toEqual([new Uint8Array([1, 2]), new Uint8Array([3])]);
|
||||
} finally {
|
||||
ports.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores undefined reader values", { timeout: 5000 }, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
try {
|
||||
const recv = receiveStream(ports.clientPort, 2);
|
||||
// Create a stream that yields an undefined value via a custom reader.
|
||||
const fake = {
|
||||
getReader: () => {
|
||||
let n = 0;
|
||||
return {
|
||||
read: async (): Promise<
|
||||
ReadableStreamReadResult<Uint8Array>
|
||||
> => {
|
||||
n++;
|
||||
if (n === 1)
|
||||
return {
|
||||
value: undefined as unknown as Uint8Array,
|
||||
done: false,
|
||||
};
|
||||
if (n === 2)
|
||||
return {
|
||||
value: new Uint8Array([9]),
|
||||
done: false,
|
||||
};
|
||||
return { value: undefined, done: true };
|
||||
},
|
||||
releaseLock: () => {},
|
||||
};
|
||||
},
|
||||
} as unknown as ReadableStream<Uint8Array>;
|
||||
await sendStream(ports.serverPort, 2, fake);
|
||||
const got = await drain(recv);
|
||||
expect(got).toEqual([new Uint8Array([9])]);
|
||||
} finally {
|
||||
ports.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("applies back-pressure via window/ack", { timeout: 5000 }, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
try {
|
||||
const recv = receiveStream(ports.clientPort, 3, { window: 2 });
|
||||
const src = new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
for (let i = 0; i < 5; i++) c.enqueue(new Uint8Array([i]));
|
||||
c.close();
|
||||
},
|
||||
});
|
||||
await sendStream(ports.serverPort, 3, src, { window: 2 });
|
||||
const got = await drain(recv);
|
||||
expect(got.length).toBe(5);
|
||||
} finally {
|
||||
ports.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("coerces ArrayBuffer chunks to Uint8Array", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
try {
|
||||
const recv = receiveStream(ports.clientPort, 4);
|
||||
ports.serverPort.postMessage({
|
||||
v: "1.0.0",
|
||||
kind: "stream-chunk",
|
||||
id: 4,
|
||||
chunk: new Uint8Array([7, 8]).buffer,
|
||||
});
|
||||
ports.serverPort.postMessage({
|
||||
v: "1.0.0",
|
||||
kind: "stream-end",
|
||||
id: 4,
|
||||
ok: true,
|
||||
});
|
||||
const got = await drain(recv);
|
||||
expect(got).toEqual([new Uint8Array([7, 8])]);
|
||||
} finally {
|
||||
ports.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("errors on non-binary chunk", { timeout: 5000 }, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
try {
|
||||
const recv = receiveStream(ports.clientPort, 5);
|
||||
ports.serverPort.postMessage({
|
||||
v: "1.0.0",
|
||||
kind: "stream-chunk",
|
||||
id: 5,
|
||||
chunk: "oops",
|
||||
});
|
||||
await expect(drain(recv)).rejects.toMatchObject({
|
||||
code: "ROUTER_BRIDGE.STREAM_INTERRUPTED",
|
||||
});
|
||||
} finally {
|
||||
ports.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores frames targeting other stream ids", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
try {
|
||||
const recv = receiveStream(ports.clientPort, 11);
|
||||
ports.serverPort.postMessage({
|
||||
v: "1.0.0",
|
||||
kind: "stream-chunk",
|
||||
id: 999,
|
||||
chunk: new Uint8Array([0]),
|
||||
});
|
||||
ports.serverPort.postMessage({
|
||||
v: "1.0.0",
|
||||
kind: "stream-chunk",
|
||||
id: 11,
|
||||
chunk: new Uint8Array([1]),
|
||||
});
|
||||
ports.serverPort.postMessage({
|
||||
v: "1.0.0",
|
||||
kind: "stream-end",
|
||||
id: 11,
|
||||
ok: true,
|
||||
});
|
||||
const got = await drain(recv);
|
||||
expect(got).toEqual([new Uint8Array([1])]);
|
||||
} finally {
|
||||
ports.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("propagates producer error via stream-end !ok", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
try {
|
||||
const recv = receiveStream(ports.clientPort, 6);
|
||||
const src = new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
c.enqueue(new Uint8Array([1]));
|
||||
c.error(new Error("boom"));
|
||||
},
|
||||
});
|
||||
const sendPromise = sendStream(ports.serverPort, 6, src);
|
||||
await expect(sendPromise).rejects.toThrow();
|
||||
await expect(drain(recv)).rejects.toBeInstanceOf(Error);
|
||||
} finally {
|
||||
ports.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("cancel detaches listener", { timeout: 5000 }, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
try {
|
||||
const recv = receiveStream(ports.clientPort, 7);
|
||||
await recv.cancel();
|
||||
} finally {
|
||||
ports.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores stream-ack for an unrelated stream id", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
try {
|
||||
const recv = receiveStream(ports.clientPort, 20, { window: 2 });
|
||||
const src = new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
c.enqueue(new Uint8Array([1]));
|
||||
c.enqueue(new Uint8Array([2]));
|
||||
c.close();
|
||||
},
|
||||
});
|
||||
// Inject a stray ack frame with a different id while producer runs.
|
||||
ports.clientPort.postMessage({
|
||||
v: "1.0.0",
|
||||
kind: "stream-ack",
|
||||
id: 99999,
|
||||
delta: 5,
|
||||
});
|
||||
await sendStream(ports.serverPort, 20, src, { window: 2 });
|
||||
const got = await drain(recv);
|
||||
expect(got.length).toBe(2);
|
||||
} finally {
|
||||
ports.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps non-Error producer throw as STREAM_INTERRUPTED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
try {
|
||||
const recv = receiveStream(ports.clientPort, 21);
|
||||
const src = new ReadableStream<Uint8Array>({
|
||||
pull() {
|
||||
// eslint-disable-next-line no-throw-literal
|
||||
throw "string-throw";
|
||||
},
|
||||
});
|
||||
const sendPromise = sendStream(ports.serverPort, 21, src);
|
||||
await expect(sendPromise).rejects.toMatchObject({
|
||||
code: "ROUTER_BRIDGE.STREAM_INTERRUPTED",
|
||||
});
|
||||
await expect(drain(recv)).rejects.toMatchObject({
|
||||
code: "ROUTER_BRIDGE.STREAM_INTERRUPTED",
|
||||
});
|
||||
} finally {
|
||||
ports.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores non-stream frames targeting same id", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
try {
|
||||
const recv = receiveStream(ports.clientPort, 30);
|
||||
// Send a request frame with the same id; receiver should ignore.
|
||||
ports.serverPort.postMessage({
|
||||
v: "1.0.0",
|
||||
kind: "request",
|
||||
id: 30,
|
||||
method: "x",
|
||||
params: [],
|
||||
});
|
||||
ports.serverPort.postMessage({
|
||||
v: "1.0.0",
|
||||
kind: "stream-chunk",
|
||||
id: 30,
|
||||
chunk: new Uint8Array([42]),
|
||||
});
|
||||
ports.serverPort.postMessage({
|
||||
v: "1.0.0",
|
||||
kind: "stream-end",
|
||||
id: 30,
|
||||
ok: true,
|
||||
});
|
||||
const got = await drain(recv);
|
||||
expect(got).toEqual([new Uint8Array([42])]);
|
||||
} finally {
|
||||
ports.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("stream-end !ok without error envelope errors with INTERRUPTED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ports = createInProcessPortPair();
|
||||
try {
|
||||
const recv = receiveStream(ports.clientPort, 8);
|
||||
ports.serverPort.postMessage({
|
||||
v: "1.0.0",
|
||||
kind: "stream-end",
|
||||
id: 8,
|
||||
ok: false,
|
||||
});
|
||||
await expect(drain(recv)).rejects.toMatchObject({
|
||||
code: "ROUTER_BRIDGE.STREAM_INTERRUPTED",
|
||||
});
|
||||
} finally {
|
||||
ports.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
envelopeToError,
|
||||
errorToEnvelope,
|
||||
} from "@suaveplan/api-worker-rpc/error-reconstruction";
|
||||
import {
|
||||
PROTOCOL_VERSION,
|
||||
type WorkerRpcFrame,
|
||||
} from "@suaveplan/api-worker-rpc/protocol";
|
||||
import type { PortLike } from "@suaveplan/api-worker-rpc/testing";
|
||||
import {
|
||||
ROUTER_BRIDGE_CODES,
|
||||
RouterBridgeError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/** Default window size (chunks) before requiring a `stream-ack`. */
|
||||
export const DEFAULT_STREAM_WINDOW = 16;
|
||||
|
||||
/** Options for {@link sendStream} / {@link receiveStream}. */
|
||||
export interface StreamOptions {
|
||||
/** Producer waits for an ack every `window` chunks. */
|
||||
readonly window?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pump a `ReadableStream<Uint8Array>` into `stream-chunk` frames on the
|
||||
* supplied port, ending with `stream-end:ok` (or `stream-end:!ok` on
|
||||
* producer error). Honors back-pressure: after every `window` outbound
|
||||
* chunks the pump waits for a `stream-ack` whose `delta` covers that
|
||||
* window. Receivers are expected to ack as they drain.
|
||||
*/
|
||||
export async function sendStream(
|
||||
port: PortLike,
|
||||
streamId: number,
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
options: StreamOptions = {}
|
||||
): Promise<void> {
|
||||
const window = options.window ?? DEFAULT_STREAM_WINDOW;
|
||||
let outstanding = 0;
|
||||
let ackWaiters: Array<() => void> = [];
|
||||
|
||||
const ackListener = (event: { data: unknown }): void => {
|
||||
const data = event.data as Partial<WorkerRpcFrame> & {
|
||||
id?: number;
|
||||
kind?: string;
|
||||
delta?: number;
|
||||
};
|
||||
if (
|
||||
data.kind === "stream-ack" &&
|
||||
data.id === streamId &&
|
||||
typeof data.delta === "number"
|
||||
) {
|
||||
outstanding = Math.max(0, outstanding - data.delta);
|
||||
const waiters = ackWaiters;
|
||||
ackWaiters = [];
|
||||
for (const w of waiters) w();
|
||||
}
|
||||
};
|
||||
port.addEventListener("message", ackListener);
|
||||
|
||||
const reader = stream.getReader();
|
||||
try {
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
if (value === undefined) continue;
|
||||
port.postMessage({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "stream-chunk",
|
||||
id: streamId,
|
||||
chunk: value,
|
||||
});
|
||||
outstanding++;
|
||||
while (outstanding >= window) {
|
||||
await new Promise<void>((resolve) => {
|
||||
ackWaiters.push(resolve);
|
||||
});
|
||||
}
|
||||
}
|
||||
port.postMessage({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "stream-end",
|
||||
id: streamId,
|
||||
ok: true,
|
||||
});
|
||||
} catch (cause) {
|
||||
const err =
|
||||
cause instanceof Error
|
||||
? cause
|
||||
: new RouterBridgeError(
|
||||
ROUTER_BRIDGE_CODES.STREAM_INTERRUPTED,
|
||||
"stream producer threw a non-Error"
|
||||
);
|
||||
port.postMessage({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "stream-end",
|
||||
id: streamId,
|
||||
ok: false,
|
||||
error: errorToEnvelope(err),
|
||||
});
|
||||
throw err;
|
||||
} finally {
|
||||
port.removeEventListener("message", ackListener);
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `ReadableStream<Uint8Array>` whose chunks come from `stream-chunk`
|
||||
* frames received on `port` for `streamId`. The stream completes on
|
||||
* `stream-end:ok` and errors on `stream-end:!ok` (with the reconstructed
|
||||
* remote error) or on the port closing mid-stream
|
||||
* (`ROUTER_BRIDGE.STREAM_INTERRUPTED`).
|
||||
*/
|
||||
export function receiveStream(
|
||||
port: PortLike,
|
||||
streamId: number,
|
||||
options: StreamOptions = {}
|
||||
): ReadableStream<Uint8Array> {
|
||||
const window = options.window ?? DEFAULT_STREAM_WINDOW;
|
||||
let received = 0;
|
||||
let detach: (() => void) | undefined;
|
||||
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller): void {
|
||||
const listener = (event: { data: unknown }): void => {
|
||||
const data = event.data as Partial<WorkerRpcFrame> & {
|
||||
id?: number;
|
||||
kind?: string;
|
||||
chunk?: unknown;
|
||||
ok?: boolean;
|
||||
error?: Parameters<typeof envelopeToError>[0];
|
||||
};
|
||||
if (data.id !== streamId) return;
|
||||
if (data.kind === "stream-chunk") {
|
||||
const chunk = data.chunk;
|
||||
if (chunk instanceof Uint8Array) {
|
||||
controller.enqueue(chunk);
|
||||
} else if (chunk instanceof ArrayBuffer) {
|
||||
controller.enqueue(new Uint8Array(chunk));
|
||||
} else {
|
||||
controller.error(
|
||||
new RouterBridgeError(
|
||||
ROUTER_BRIDGE_CODES.STREAM_INTERRUPTED,
|
||||
"non-binary stream chunk received"
|
||||
)
|
||||
);
|
||||
detach?.();
|
||||
return;
|
||||
}
|
||||
received++;
|
||||
if (received >= window) {
|
||||
port.postMessage({
|
||||
v: PROTOCOL_VERSION,
|
||||
kind: "stream-ack",
|
||||
id: streamId,
|
||||
delta: received,
|
||||
});
|
||||
received = 0;
|
||||
}
|
||||
} else if (data.kind === "stream-end") {
|
||||
if (data.ok === true) {
|
||||
controller.close();
|
||||
} else if (data.error !== undefined) {
|
||||
controller.error(envelopeToError(data.error));
|
||||
} else {
|
||||
controller.error(
|
||||
new RouterBridgeError(
|
||||
ROUTER_BRIDGE_CODES.STREAM_INTERRUPTED,
|
||||
"stream-end !ok without error envelope"
|
||||
)
|
||||
);
|
||||
}
|
||||
detach?.();
|
||||
}
|
||||
};
|
||||
port.addEventListener("message", listener);
|
||||
detach = () => port.removeEventListener("message", listener);
|
||||
},
|
||||
cancel(): void {
|
||||
detach?.();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { ConformanceRunner, InProcessBridge } from "./testing.js";
|
||||
export { createInProcessBridge, runBridgeConformance } from "./testing.js";
|
||||
@@ -0,0 +1,69 @@
|
||||
# `testing`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module hands consumers the in-process scaffolding needed to exercise
|
||||
the worker bridge under a real `MessageChannel` without spinning up an
|
||||
actual worker thread, plus a conformance harness that every alternate
|
||||
transport (in-process today, worker-thread tomorrow) must satisfy.
|
||||
|
||||
## Features
|
||||
|
||||
- `createInProcessBridge(handler, options?)` returns a ready-to-use pair:
|
||||
the main-side `dispatch`, the underlying ports, and a shared `close`
|
||||
that tears down both halves and the channel.
|
||||
- `runBridgeConformance(runner, factory)` exercises round-trip GET, JSON
|
||||
POST, and the header deny-list invariant. Test-runner-agnostic via the
|
||||
injected `ConformanceRunner` shape.
|
||||
- Built on `@suaveplan/api-worker-rpc/testing`'s
|
||||
`createInProcessPortPair`, so the conformance suite stays aligned with
|
||||
the underlying RPC contract.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createInProcessBridge } from
|
||||
"@suaveplan/api-router-worker-bridge/testing";
|
||||
|
||||
const bridge = createInProcessBridge(async () => new Response("pong"));
|
||||
const res = await bridge.dispatch(new Request("https://api.local/ping"));
|
||||
console.log(await res.text()); // "pong"
|
||||
bridge.close();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Plug into Vitest's conformance gates:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
createInProcessBridge,
|
||||
runBridgeConformance,
|
||||
} from "@suaveplan/api-router-worker-bridge/testing";
|
||||
|
||||
runBridgeConformance(
|
||||
{ describe, it, expect: expect as unknown as never },
|
||||
(handler) => createInProcessBridge(handler)
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createInProcessBridge(handler, options?) → InProcessBridge`.
|
||||
- `InProcessBridge { ports, dispatch, close }`.
|
||||
- `runBridgeConformance(runner, factory) → void`.
|
||||
- `ConformanceRunner { describe(name, body), it(name, opts, body), expect }`
|
||||
— matches vitest 4 / bun:test signatures.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The harness intentionally only asserts the invariants that ALL bridge
|
||||
implementations share. Transport-specific behaviour (e.g. worker-thread
|
||||
spin-up costs, transferList semantics, restart-on-crash) belongs in the
|
||||
transport package's own test file. Each scenario tears down via
|
||||
`bridge.close()` in `finally` to avoid leaking ports across tests.
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createInProcessBridge, runBridgeConformance } from "./testing.js";
|
||||
|
||||
describe("testing", () => {
|
||||
it("createInProcessBridge round-trips", { timeout: 5000 }, async () => {
|
||||
const bridge = createInProcessBridge(async () => new Response("hello"));
|
||||
try {
|
||||
const res = await bridge.dispatch(
|
||||
new Request("https://api.local/")
|
||||
);
|
||||
expect(await res.text()).toBe("hello");
|
||||
} finally {
|
||||
bridge.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("runBridgeConformance executes all suites", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const scenarios: Array<{
|
||||
name: string;
|
||||
run: () => Promise<void> | void;
|
||||
}> = [];
|
||||
const runner = {
|
||||
describe: (_n: string, body: () => void) => body(),
|
||||
it: (
|
||||
name: string,
|
||||
_options: { timeout?: number },
|
||||
run: () => Promise<void> | void
|
||||
) => {
|
||||
scenarios.push({ name, run });
|
||||
},
|
||||
expect: (value: unknown) => ({
|
||||
toBe(other: unknown) {
|
||||
if (value !== other)
|
||||
throw new Error(
|
||||
`expected ${String(value)} to be ${String(other)}`
|
||||
);
|
||||
},
|
||||
toEqual(other: unknown) {
|
||||
if (JSON.stringify(value) !== JSON.stringify(other))
|
||||
throw new Error("toEqual mismatch");
|
||||
},
|
||||
}),
|
||||
};
|
||||
runBridgeConformance(runner, (handler) =>
|
||||
createInProcessBridge(handler)
|
||||
);
|
||||
for (const s of scenarios) await s.run();
|
||||
expect(scenarios.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
createInProcessPortPair,
|
||||
type InProcessPortPair,
|
||||
} from "@suaveplan/api-worker-rpc/testing";
|
||||
import {
|
||||
type BridgeOptions,
|
||||
createMainBridge,
|
||||
createWorkerBridge,
|
||||
type WorkerHandler,
|
||||
} from "../bridge/bridge.js";
|
||||
|
||||
/** Fully wired in-process bridge pair for conformance tests. */
|
||||
export interface InProcessBridge {
|
||||
readonly ports: InProcessPortPair;
|
||||
readonly dispatch: (request: Request) => Promise<Response>;
|
||||
readonly close: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a bridge pair backed by a real {@link MessageChannel}: a main
|
||||
* bridge handed to the caller, a worker bridge wired to `handler`. Calls
|
||||
* to `dispatch` go through the channel, exercising the full marshal +
|
||||
* streaming pipeline.
|
||||
*/
|
||||
export function createInProcessBridge(
|
||||
handler: WorkerHandler,
|
||||
options: BridgeOptions = {}
|
||||
): InProcessBridge {
|
||||
const ports = createInProcessPortPair();
|
||||
const worker = createWorkerBridge(ports.serverPort, handler, options);
|
||||
const main = createMainBridge(ports.clientPort, options);
|
||||
return {
|
||||
ports,
|
||||
dispatch: (req) => main.dispatch(req),
|
||||
close: () => {
|
||||
main.close();
|
||||
worker.close();
|
||||
ports.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Suite hook supplied by conformance host runners. */
|
||||
export interface ConformanceRunner {
|
||||
describe(name: string, body: () => void): void;
|
||||
it(
|
||||
name: string,
|
||||
options: { timeout?: number },
|
||||
body: () => Promise<void> | void
|
||||
): void;
|
||||
expect: (value: unknown) => {
|
||||
toBe(other: unknown): void;
|
||||
toEqual(other: unknown): void;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Conformance suite that every concrete bridge implementation MUST pass.
|
||||
* Currently parameterised over the in-process bridge; future remote
|
||||
* transports can hand back the same `InProcessBridge` shape.
|
||||
*/
|
||||
export function runBridgeConformance(
|
||||
runner: ConformanceRunner,
|
||||
factory: (handler: WorkerHandler) => InProcessBridge
|
||||
): void {
|
||||
const { describe, it, expect } = runner;
|
||||
describe("api-router-worker-bridge conformance", () => {
|
||||
it("round-trips a plain GET", { timeout: 5000 }, async () => {
|
||||
const bridge = factory(
|
||||
async (req) =>
|
||||
new Response(`ok:${new URL(req.url).pathname}`, {
|
||||
status: 200,
|
||||
})
|
||||
);
|
||||
try {
|
||||
const res = await bridge.dispatch(
|
||||
new Request("https://api.local/ping")
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe("ok:/ping");
|
||||
} finally {
|
||||
bridge.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("round-trips a JSON POST", { timeout: 5000 }, async () => {
|
||||
const bridge = factory(async (req) => {
|
||||
const body = (await req.json()) as { n: number };
|
||||
return new Response(JSON.stringify({ doubled: body.n * 2 }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
});
|
||||
try {
|
||||
const res = await bridge.dispatch(
|
||||
new Request("https://api.local/double", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ n: 7 }),
|
||||
})
|
||||
);
|
||||
expect(await res.json()).toEqual({ doubled: 14 });
|
||||
} finally {
|
||||
bridge.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("strips Authorization header by default", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const bridge = factory(
|
||||
async (req) =>
|
||||
new Response(req.headers.get("authorization") ?? "none")
|
||||
);
|
||||
try {
|
||||
const res = await bridge.dispatch(
|
||||
new Request("https://api.local/auth", {
|
||||
headers: { authorization: "Bearer leak" },
|
||||
})
|
||||
);
|
||||
expect(await res.text()).toBe("none");
|
||||
} finally {
|
||||
bridge.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* §16 conformance proof — the worker-proxy package consumes the
|
||||
* `@suaveplan/api-worker-rpc` contract directly (client + server +
|
||||
* MessagePort transport). This test exercises the canonical
|
||||
* `runWorkerRpcConformance` harness with a minimal handler set to
|
||||
* prove the consumer is wire-compatible end-to-end (request frame,
|
||||
* response frame, abort propagation) using the same port transport
|
||||
* the package builds on top of in `src/main-side` / `src/worker-side`.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { runWorkerRpcConformance } from "../../../api-worker-rpc/src/testing/conformance.js";
|
||||
|
||||
const handles: Array<{ dispose(): void }> = [];
|
||||
afterEach(() => {
|
||||
while (handles.length > 0) handles.pop()?.dispose();
|
||||
});
|
||||
|
||||
describe("api-worker-rpc contract conformance (consumer-side)", () => {
|
||||
it("round-trips a request through the canonical harness", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = runWorkerRpcConformance(() => ({
|
||||
echo: (params: ReadonlyArray<unknown>) => params[0],
|
||||
}));
|
||||
handles.push(h);
|
||||
const result = await h.client.call<number>("echo", [42]);
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it("surfaces UNKNOWN_METHOD from the server", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = runWorkerRpcConformance(() => ({}));
|
||||
handles.push(h);
|
||||
await expect(h.client.call("missing", [])).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { marshalRequest } from "../src/marshal-request/marshal-request.js";
|
||||
|
||||
describe("no-raw-request invariant", () => {
|
||||
it("envelope contains no Request reference and JSON-serialises", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const wire = await marshalRequest(
|
||||
new Request("https://api.local/x", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: "Bearer leak",
|
||||
},
|
||||
body: JSON.stringify({ a: 1 }),
|
||||
})
|
||||
);
|
||||
const replaced = JSON.stringify(wire, (_k, v: unknown) => {
|
||||
if (v instanceof Uint8Array) return { __u8: Array.from(v) };
|
||||
return v;
|
||||
});
|
||||
// No "Request" prototype slipped in
|
||||
expect(replaced.includes("Request")).toBe(false);
|
||||
// No credentials cross the boundary
|
||||
expect(replaced.toLowerCase().includes("authorization")).toBe(false);
|
||||
expect(replaced.includes("Bearer leak")).toBe(false);
|
||||
// The envelope is a plain object — no Request instance
|
||||
expect((wire as unknown as object) instanceof Request).toBe(false);
|
||||
});
|
||||
|
||||
it("exposed keys match the documented surface", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const wire = await marshalRequest(new Request("https://api.local/"));
|
||||
expect(Object.keys(wire).sort()).toEqual([
|
||||
"body",
|
||||
"headers",
|
||||
"method",
|
||||
"url",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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-router-worker-bridge",
|
||||
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"],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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