Initial Commit

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

View File

@@ -0,0 +1,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"
```

View 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."
}
}

View File

@@ -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.

View File

@@ -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();
}
});
});

View File

@@ -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);
},
};
}

View File

@@ -0,0 +1,6 @@
export type { BridgeOptions, WorkerHandler } from "./bridge.js";
export {
BRIDGE_DISPATCH_METHOD,
createMainBridge,
createWorkerBridge,
} from "./bridge.js";

View File

@@ -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).

View File

@@ -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();
});
});

View File

@@ -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";
}
}

View File

@@ -0,0 +1,2 @@
export type { RouterBridgeCode } from "./error-codes.js";
export { ROUTER_BRIDGE_CODES, RouterBridgeError } from "./error-codes.js";

View 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";

View File

@@ -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";

View File

@@ -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.

View File

@@ -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");
});
});

View File

@@ -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);
}

View File

@@ -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";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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,
});
}

View File

@@ -0,0 +1,6 @@
export type { StreamOptions } from "./streaming.js";
export {
DEFAULT_STREAM_WINDOW,
receiveStream,
sendStream,
} from "./streaming.js";

View File

@@ -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.

View File

@@ -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();
}
});
});

View File

@@ -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?.();
},
});
}

View File

@@ -0,0 +1,2 @@
export type { ConformanceRunner, InProcessBridge } from "./testing.js";
export { createInProcessBridge, runBridgeConformance } from "./testing.js";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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();
}
});
});
}

View File

@@ -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();
});
});

View File

@@ -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",
]);
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

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

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});