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,98 @@
# @suaveplan/api-router — contract package for the Suaveplan API plugin system. Defines the abstract Router interface (verb methods + use + mount + match), Request / Response host-side shapes, Zod-validated RouteOptions schema, Middleware composition helpers with insertion-point guard, RouteMatch shape, BlueGreenSlot interface for hot-reload, the frozen ROUTER_CODES registry, and the runRouterConformance harness every concrete adapter must pass.
Stage-1 contract package for the Suaveplan API plugin system.
This package defines the abstract `Router` interface, the host-side
`Request` / `Response` shapes, the Zod-validated `RouteOptions` schema, the
`Middleware` composition contract with insertion-point guard, the
`RouteMatch` shape, the `BlueGreenSlot` interface backing hot reload, the
frozen `ROUTER_CODES` registry, and the `runRouterConformance` harness
every concrete adapter must pass.
The reference in-memory `Router` and `InMemoryBlueGreenSlot` implementations
ship here so the conformance harness is self-checking and Stage-1 consumers
have a working router before any concrete adapter lands.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes` module is the single source of truth for every failure class this package surfaces. It exports the fr…
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes` module is the single source of truth for every failure class this package surfaces. It exports the fr…
- [middleware](./src/middleware/middleware.md) — The `middleware` module defines the onion-model `Middleware` contract and the `composeMiddleware` builder. Every adapte…
- [insertion-point](./src/middleware/insertion-point.md) — The `insertion-point` module enforces the security invariant from design doc §6: plugin-supplied middleware is allowed…
- [middleware](./src/middleware/middleware.md) — The `middleware` module defines the onion-model `Middleware` contract and the `composeMiddleware` builder. Every adapte…
- [request](./src/request/request.md) — The `request` module defines the host-side `RouterRequest` shape — the post-parse, post-validation view that route hand…
- [request](./src/request/request.md) — The `request` module defines the host-side `RouterRequest` shape — the post-parse, post-validation view that route hand…
- [response](./src/response/response.md) — The `response` module provides the host-side `RouterResponse` shape and a fluent `ResponseBuilder` that adapters serial…
- [response](./src/response/response.md) — The `response` module provides the host-side `RouterResponse` shape and a fluent `ResponseBuilder` that adapters serial…
- [route-match](./src/route-match/route-match.md) — The `route-match` module defines `RouteMatch`, the shape every concrete adapter yields after resolving an incoming `(me…
- [route-match](./src/route-match/route-match.md) — The `route-match` module defines `RouteMatch`, the shape every concrete adapter yields after resolving an incoming `(me…
- [route-options](./src/route-options/route-options.md) — The `route-options` module is the Zod-validated boundary for every per-route configuration block. It widens the minimal…
- [route-options](./src/route-options/route-options.md) — The `route-options` module is the Zod-validated boundary for every per-route configuration block. It widens the minimal…
- [router](./src/router/router.md) — The `router` module defines the abstract `Router` — the verb-method surface (`get` / `post` / `put` / `patch` / `delete…
- [router](./src/router/router.md) — The `router` module defines the abstract `Router` — the verb-method surface (`get` / `post` / `put` / `patch` / `delete…
- [slot](./src/slot/slot.md) — The `slot` module defines `BlueGreenSlot` — the per-plugin atomic mount point that backs hot-reload. It exposes three p…
- [slot](./src/slot/slot.md) — The `slot` module defines `BlueGreenSlot` — the per-plugin atomic mount point that backs hot-reload. It exposes three p…
- [testing](./src/testing/conformance.md) — The `testing/conformance` module exports `runRouterConformance(factory)` — the canonical conformance harness every conc…
- [pipeline-router-conformance](./src/testing/pipeline-router-conformance/pipeline-router-conformance.md) — `runPipelineRouterConformance(factory)` is the canonical contract suite every concrete `PipelineRouter` adapter MUST pa…
- [pipeline-router-conformance](./src/testing/pipeline-router-conformance/pipeline-router-conformance.md) — `runPipelineRouterConformance(factory)` is the canonical contract suite every concrete `PipelineRouter` adapter MUST pa…
- [conformance](./src/testing/conformance.md) — The `testing/conformance` module exports `runRouterConformance(factory)` — the canonical conformance harness every conc…
<!-- end:auto-docs -->
## Status
- Version: `0.1.0`
- Stage: 1 (Contracts) — see umbrella design.
- Dependents: none yet (host wires this in).
## Install
```bash
bun add @suaveplan/api-router
```
## Modules
- [`router`](./src/router/router.md) — abstract `Router` + reference in-memory implementation.
- [`request`](./src/request/request.md) — host-side `RouterRequest` + `MapHeaders`.
- [`response`](./src/response/response.md) — `RouterResponse` + fluent builder.
- [`route-options`](./src/route-options/route-options.md) — Zod-validated `RouteOptions`.
- [`middleware`](./src/middleware/middleware.md) — onion-model `Middleware` + `composeMiddleware`.
- [`middleware/insertion-point`](./src/middleware/insertion-point.md) — pipeline-step guard.
- [`route-match`](./src/route-match/route-match.md) — `RouteMatch` shape + factory.
- [`slot`](./src/slot/slot.md) — `BlueGreenSlot` interface + in-memory reference impl.
- [`error-codes`](./src/error-codes/error-codes.md) — `ROUTER_CODES` + `RouterError`.
- [`testing/conformance`](./src/testing/conformance.md) — `runRouterConformance` adapter harness.
## Error codes
The five frozen codes (every one thrown by a production path):
- `ROUTER.ROUTE_COLLISION`
- `ROUTER.INVALID_PREFIX`
- `ROUTER.MIDDLEWARE_INSERTION_DENIED`
- `ROUTER.SLOT_FLIP_FAILED`
- `ROUTER.SLOT_DISPOSE_TIMEOUT`
## Subpath exports
Every module has its own subpath export so consumers tree-shake to exactly
what they import:
```ts
import { Router } from "@suaveplan/api-router/router";
import { composeMiddleware } from "@suaveplan/api-router/middleware";
import { parseRouteOptions } from "@suaveplan/api-router/route-options";
import { runRouterConformance } from "@suaveplan/api-router/testing";
```
## Verification
- `bunx turbo lint typecheck test --filter=@suaveplan/api-router` — green.
- `bunx turbo test:coverage --filter=@suaveplan/api-router` — 100/100/100/100.
- `bunx turbo build --filter=@suaveplan/api-router` — green.
## License
MIT

View File

@@ -0,0 +1,122 @@
{
"name": "@suaveplan/api-router",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Stage-1 contract package for the Suaveplan API plugin foundation. Defines the abstract Router interface (verb methods + use + mount + match), Request / Response host-side shapes, Zod-validated RouteOptions schema, Middleware composition helpers with insertion-point guard, RouteMatch shape, BlueGreenSlot interface for hot-reload, the frozen ROUTER_CODES registry, and the runRouterConformance harness every concrete adapter must pass.",
"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"
}
},
"./router": {
"import": {
"types": "./dist/router/index.d.ts",
"default": "./dist/router/index.js"
}
},
"./request": {
"import": {
"types": "./dist/request/index.d.ts",
"default": "./dist/request/index.js"
}
},
"./response": {
"import": {
"types": "./dist/response/index.d.ts",
"default": "./dist/response/index.js"
}
},
"./route-options": {
"import": {
"types": "./dist/route-options/index.d.ts",
"default": "./dist/route-options/index.js"
}
},
"./middleware": {
"import": {
"types": "./dist/middleware/index.d.ts",
"default": "./dist/middleware/index.js"
}
},
"./route-match": {
"import": {
"types": "./dist/route-match/index.d.ts",
"default": "./dist/route-match/index.js"
}
},
"./slot": {
"import": {
"types": "./dist/slot/index.d.ts",
"default": "./dist/slot/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"
}
},
"./testing/pipeline-router-conformance": {
"import": {
"types": "./dist/testing/pipeline-router-conformance/index.d.ts",
"default": "./dist/testing/pipeline-router-conformance/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/api-types": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "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"
]
}

View File

@@ -0,0 +1,81 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `error-codes` module is the single source of truth for every failure
class this package surfaces. It exports the frozen `ROUTER_CODES` registry
(produced by `@suaveplan/error`'s `defineCodes`) plus the typed
`RouterError` subclass. Every code in the registry must be thrown from a
real production path; the spec-conformance test enumerates the registry and
asserts that no code is "decorative".
Centralising the codes here keeps the wire format stable across versions —
adapters and downstream consumers can pattern-match on `error.code` without
worrying about typos or namespace drift, and the umbrella API plugin
foundation can emit OpenAPI error documentation from a single registry.
## Features
- Five frozen codes: `ROUTE_COLLISION`, `INVALID_PREFIX`,
`MIDDLEWARE_INSERTION_DENIED`, `SLOT_FLIP_FAILED`, `SLOT_DISPOSE_TIMEOUT`.
- Codes are namespaced `ROUTER.<NAME>` via `defineCodes("ROUTER", […])`.
- `RouterError` subclass of `SuaveplanError` with `code`, `message`,
`cause`, and a `context` slot for call-site metadata.
- Type-level export `RouterCode` for exhaustive switches.
## Usage: Basic
```ts
import { ROUTER_CODES, RouterError } from "@suaveplan/api-router/error-codes";
throw new RouterError(
ROUTER_CODES.ROUTE_COLLISION,
"GET /users already registered",
{ context: { method: "GET", path: "/users", plugin: "auth" } },
);
```
## Usage: Advanced
```ts
import { RouterError, type RouterCode } from "@suaveplan/api-router/error-codes";
function handle(err: unknown): "retry" | "abort" {
if (!(err instanceof RouterError)) return "abort";
const code: RouterCode = err.code;
switch (code) {
case "ROUTER.SLOT_DISPOSE_TIMEOUT":
return "retry";
case "ROUTER.ROUTE_COLLISION":
case "ROUTER.INVALID_PREFIX":
case "ROUTER.MIDDLEWARE_INSERTION_DENIED":
case "ROUTER.SLOT_FLIP_FAILED":
return "abort";
}
}
```
## API Reference
- `ROUTER_CODES` — frozen object map; keys are short names (`ROUTE_COLLISION`),
values are fully-qualified namespaced strings (`"ROUTER.ROUTE_COLLISION"`).
- `RouterError` — `extends SuaveplanError<RouterCode>`; constructor accepts
`(code, message, options?)` where `options` carries optional `cause` and
`context` slots.
- `RouterCode` — type-level union of every code value.
## Implementation Notes
The registry is built with `as const` so the inferred `RouterCode` union is
exact — adding a code requires touching this file (and the matching
production throw site). The runtime cost is one object literal; the
registry is tree-shakeable for callers that only need the type.
`RouterError` keeps the constructor surface minimal: there's no overload
explosion, just `(code, message, options?)` mirroring `SuaveplanError`.
This is deliberate — adapters wrap their own errors with `cause` rather
than re-inventing fields.

View File

@@ -0,0 +1,97 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { assertPluginInsertionStep } from "../middleware/insertion-point.js";
import { Router } from "../router/router.js";
import { InMemoryBlueGreenSlot } from "../slot/slot.js";
import { ROUTER_CODES, RouterError } from "./error-codes.js";
/**
* Each code in `ROUTER_CODES` must be thrown by a production path. This
* test enumerates the registry and verifies every code is reachable.
*/
describe("ROUTER_CODES production-path coverage", () => {
it("ROUTE_COLLISION thrown by Router.<verb> duplicate registration", {
timeout: 5000,
}, () => {
const r = new Router();
r.get("/x", () => null);
try {
r.get("/x", () => null);
throw new Error("did not throw");
} catch (e) {
expect(e).toBeInstanceOf(RouterError);
if (e instanceof RouterError) {
expect(e.code).toBe(ROUTER_CODES.ROUTE_COLLISION);
}
}
});
it("INVALID_PREFIX thrown by mount() / compilePath", {
timeout: 5000,
}, () => {
const r = new Router();
const child = new Router();
child.get("/x", () => null);
try {
r.mount("nope", child);
throw new Error("did not throw");
} catch (e) {
if (e instanceof RouterError) {
expect(e.code).toBe(ROUTER_CODES.INVALID_PREFIX);
}
}
});
it("MIDDLEWARE_INSERTION_DENIED thrown by assertPluginInsertionStep", {
timeout: 5000,
}, () => {
try {
assertPluginInsertionStep("pre-auth");
throw new Error("did not throw");
} catch (e) {
if (e instanceof RouterError) {
expect(e.code).toBe(ROUTER_CODES.MIDDLEWARE_INSERTION_DENIED);
}
}
});
it("SLOT_FLIP_FAILED thrown by BlueGreenSlot.flip with no shadow", {
timeout: 5000,
}, () => {
const s = new InMemoryBlueGreenSlot("p", () => null);
try {
s.flip();
throw new Error("did not throw");
} catch (e) {
if (e instanceof RouterError) {
expect(e.code).toBe(ROUTER_CODES.SLOT_FLIP_FAILED);
}
}
});
it("SLOT_DISPOSE_TIMEOUT thrown when in-flight requests outlast timeout", {
timeout: 5000,
}, async () => {
const s = new InMemoryBlueGreenSlot("p", () => null);
s.acquire(s.live.slotId);
s.setShadow(() => null);
const draining = s.flip();
await expect(
s.dispose(draining.slotId, { timeoutMs: 10 })
).rejects.toMatchObject({ code: ROUTER_CODES.SLOT_DISPOSE_TIMEOUT });
});
it("every code key is reachable from a production throw site", {
timeout: 5000,
}, () => {
const reached = new Set([
ROUTER_CODES.ROUTE_COLLISION,
ROUTER_CODES.INVALID_PREFIX,
ROUTER_CODES.MIDDLEWARE_INSERTION_DENIED,
ROUTER_CODES.SLOT_FLIP_FAILED,
ROUTER_CODES.SLOT_DISPOSE_TIMEOUT,
]);
for (const value of Object.values(ROUTER_CODES)) {
expect(reached.has(value)).toBe(true);
}
});
});

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ROUTER_CODES, RouterError } from "./error-codes.js";
describe("ROUTER_CODES", () => {
it("registers every documented code", { timeout: 5000 }, () => {
expect(Object.keys(ROUTER_CODES).sort()).toEqual(
[
"ROUTE_COLLISION",
"INVALID_PREFIX",
"MIDDLEWARE_INSERTION_DENIED",
"SLOT_FLIP_FAILED",
"SLOT_DISPOSE_TIMEOUT",
].sort()
);
});
it("namespaces every code under ROUTER.", { timeout: 5000 }, () => {
for (const value of Object.values(ROUTER_CODES)) {
expect(value.startsWith("ROUTER.")).toBe(true);
}
});
});
describe("RouterError", () => {
it("carries code, message, and context", { timeout: 5000 }, () => {
const err = new RouterError(
ROUTER_CODES.ROUTE_COLLISION,
"duplicate route",
{ context: { method: "GET", path: "/users" } }
);
expect(err.code).toBe("ROUTER.ROUTE_COLLISION");
expect(err.message).toBe("duplicate route");
expect(err.context).toEqual({ method: "GET", path: "/users" });
expect(err.name).toBe("RouterError");
});
it("preserves cause", { timeout: 5000 }, () => {
const cause = new Error("inner");
const err = new RouterError(ROUTER_CODES.INVALID_PREFIX, "bad prefix", {
cause,
});
expect(err.cause).toBe(cause);
});
it("is throwable and instanceof RouterError", { timeout: 5000 }, () => {
expect(() => {
throw new RouterError(ROUTER_CODES.SLOT_FLIP_FAILED, "no shadow");
}).toThrow(RouterError);
});
});

View File

@@ -0,0 +1,40 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-router`.
*
* Codes follow the `ROUTER.<DOMAIN>` namespace as resolved by
* `@suaveplan/error`'s `defineCodes`. Every code in the registry MUST be
* thrown by a production path in this package — the spec-conformance test
* enumerates the registry and verifies each entry has a production throw
* site.
*/
export const ROUTER_CODES = defineCodes("ROUTER", [
"ROUTE_COLLISION",
"INVALID_PREFIX",
"MIDDLEWARE_INSERTION_DENIED",
"SLOT_FLIP_FAILED",
"SLOT_DISPOSE_TIMEOUT",
] as const);
/** Union of every code resolved through {@link ROUTER_CODES}. */
export type RouterCode = (typeof ROUTER_CODES)[keyof typeof ROUTER_CODES];
/**
* Typed error for every `@suaveplan/api-router` failure.
*
* Extends `SuaveplanError` so callers can discriminate errors from this
* package via `error instanceof RouterError` or the stable `error.code`
* property. The `context` slot carries call-site metadata
* (matched route, plugin id, slot id, etc.) without leaking internals.
*/
export class RouterError extends SuaveplanError<RouterCode> {
public constructor(
code: RouterCode,
message: string,
options?: { cause?: Error; context?: Record<string, unknown> }
) {
super(code, message, options);
this.name = "RouterError";
}
}

View File

@@ -0,0 +1,2 @@
export type { RouterCode } from "./error-codes.js";
export { ROUTER_CODES, RouterError } from "./error-codes.js";

View File

@@ -0,0 +1,72 @@
// Error codes
// Pipeline-router conformance harness — see openspec/AGENTS.md §16
// (contract + conformance harness colocate with the contract package).
// `PipelineRouter` / `PipelineRoute` / `PipelineRouteOptions` /
// `PipelineRouteRegistration` themselves live in
// `@suaveplan/api-types/pipeline-router` — the zero-runtime contract
// package; this barrel re-exports the registration type for ergonomic
// single-import consumption alongside the runtime harness.
export type { PipelineRouteRegistration } from "@suaveplan/api-types/pipeline-router";
export type { RouterCode } from "./error-codes/error-codes.js";
export { ROUTER_CODES, RouterError } from "./error-codes/error-codes.js";
export type { PipelineStep } from "./middleware/insertion-point.js";
// Middleware
export {
assertPluginInsertionStep,
PLUGIN_INSERTION_STEP,
} from "./middleware/insertion-point.js";
export type { Middleware } from "./middleware/middleware.js";
export { composeMiddleware } from "./middleware/middleware.js";
export type {
HttpMethod,
RouterRequest,
RouterRequestHeaders,
} from "./request/request.js";
// Request
export { MapHeaders } from "./request/request.js";
export type {
ResponseBuilderInit,
RouterResponse,
ServerTimingMetric,
} from "./response/response.js";
// Response
export {
createResponse,
jsonResponse,
ok,
ResponseBuilder,
} from "./response/response.js";
export type { AnyRouteHandler, RouteMatch } from "./route-match/route-match.js";
// Route match
export { createRouteMatch } from "./route-match/route-match.js";
export type {
RouteOptions,
RouteOptionsInput,
} from "./route-options/route-options.js";
// Route options
export {
parseRouteOptions,
routeOptionsSchema,
safeParseRouteOptions,
} from "./route-options/route-options.js";
// Router
export { Router } from "./router/router.js";
export type {
BlueGreenSlot,
DrainingHandle,
RouterSlot,
SlotHandler,
SlotPosition,
} from "./slot/slot.js";
// Slot
export { InMemoryBlueGreenSlot } from "./slot/slot.js";
export type { RouterConformanceFactory } from "./testing/conformance.js";
// Testing harness
export {
makeFixtureRequest,
makeNoopSpan,
runRouterConformance,
} from "./testing/conformance.js";
export { runPipelineRouterConformance } from "./testing/pipeline-router-conformance/pipeline-router-conformance.js";
export type { PipelineRouterConformanceFactory } from "./testing/pipeline-router-conformance/pipeline-router-conformance.types.js";

View File

@@ -0,0 +1,147 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MapHeaders, type RouterRequest } from "../request/request.js";
import { jsonResponse, type RouterResponse } from "../response/response.js";
import { composeMiddleware, type Middleware } from "./middleware.js";
function makeReq(): RouterRequest {
return {
method: "GET",
url: "/x",
headers: new MapHeaders(),
body: undefined,
params: {},
query: {},
tenantId: "t" as never,
principal: null,
requestId: "r1",
span: {
setAttribute() {
/* noop */
},
setStatus() {
/* noop */
},
recordException() {
/* noop */
},
end() {
/* noop */
},
},
};
}
describe("composeMiddleware", () => {
it("chain runs in declared order (A -> B -> terminal -> B -> A)", {
timeout: 5000,
}, async () => {
const trace: string[] = [];
const a: Middleware = async (_, next) => {
trace.push("A-in");
const r = await next();
trace.push("A-out");
return r;
};
const b: Middleware = async (_, next) => {
trace.push("B-in");
const r = await next();
trace.push("B-out");
return r;
};
const composed = composeMiddleware([a, b]);
await composed(makeReq(), async () => {
trace.push("terminal");
return jsonResponse(200, null);
});
expect(trace).toEqual(["A-in", "B-in", "terminal", "B-out", "A-out"]);
});
it("empty chain calls terminal directly", { timeout: 5000 }, async () => {
const composed = composeMiddleware([]);
const res = await composed(makeReq(), async () =>
jsonResponse(204, null)
);
expect(res.status).toBe(204);
});
it("short-circuit when middleware skips next()", {
timeout: 5000,
}, async () => {
const short: Middleware = async () =>
jsonResponse(401, { code: "UNAUTH" });
const after: Middleware = async (_, next) => {
// should not be reached
return next();
};
let terminalCalled = false;
const composed = composeMiddleware([short, after]);
const res = await composed(makeReq(), async () => {
terminalCalled = true;
return jsonResponse(200, null);
});
expect(res.status).toBe(401);
expect(terminalCalled).toBe(false);
});
it("propagates synchronous returns", { timeout: 5000 }, async () => {
const sync: Middleware = (_, _next): RouterResponse =>
jsonResponse(202, "sync");
const composed = composeMiddleware([sync]);
const res = await composed(makeReq(), async () =>
jsonResponse(200, null)
);
expect(res.status).toBe(202);
});
it("rejects when middleware calls next() twice", {
timeout: 5000,
}, async () => {
const bad: Middleware = async (_, next) => {
await next();
return next();
};
const composed = composeMiddleware([bad]);
await expect(
composed(makeReq(), async () => jsonResponse(200, null))
).rejects.toThrow(/next\(\) called multiple times/);
});
it("propagates errors thrown inside a middleware", {
timeout: 5000,
}, async () => {
const boom: Middleware = async () => {
throw new Error("boom");
};
const composed = composeMiddleware([boom]);
await expect(
composed(makeReq(), async () => jsonResponse(200, null))
).rejects.toThrow(/boom/);
});
it("preserves order for arbitrarily long chains (property)", {
timeout: 5000,
}, async () => {
for (let n = 1; n <= 8; n += 1) {
const trace: string[] = [];
const chain: Middleware[] = Array.from({ length: n }, (_, i) => {
return async (_req, next) => {
trace.push(`in-${i}`);
const r = await next();
trace.push(`out-${i}`);
return r;
};
});
const composed = composeMiddleware(chain);
await composed(makeReq(), async () => {
trace.push("terminal");
return jsonResponse(200, null);
});
const expectedIn = Array.from({ length: n }, (_, i) => `in-${i}`);
const expectedOut = Array.from(
{ length: n },
(_, i) => `out-${n - 1 - i}`
);
expect(trace).toEqual([...expectedIn, "terminal", ...expectedOut]);
}
});
});

View File

@@ -0,0 +1,7 @@
export type { PipelineStep } from "./insertion-point.js";
export {
assertPluginInsertionStep,
PLUGIN_INSERTION_STEP,
} from "./insertion-point.js";
export type { Middleware } from "./middleware.js";
export { composeMiddleware } from "./middleware.js";

View File

@@ -0,0 +1,83 @@
# `insertion-point`
## Purpose
The `insertion-point` module enforces the security invariant from design
doc §6: plugin-supplied middleware is allowed *only* at the boundary
between step 7 (post-validation) and step 8 (pre-handler). Insertion at
any earlier step would let a plugin observe a still-unvalidated body, an
unauthenticated principal, or an unresolved tenant — surfaces the design
doc explicitly calls out as "a CVE waiting to happen."
The module exports the literal step union, the constant boundary, and a
guard helper that throws the canonical `ROUTER.MIDDLEWARE_INSERTION_DENIED`
code on violation.
## Features
- `PipelineStep` — literal union of every step name in the per-request
pipeline (eleven entries).
- `PLUGIN_INSERTION_STEP` — the constant `"post-validation"`.
- `assertPluginInsertionStep(step)` — guard. Returns the validated step
on success, throws `RouterError` with the `MIDDLEWARE_INSERTION_DENIED`
code on failure.
## Usage: Basic
```ts
import {
assertPluginInsertionStep,
PLUGIN_INSERTION_STEP,
} from "@suaveplan/api-router/middleware";
// Verified — no throw
assertPluginInsertionStep(PLUGIN_INSERTION_STEP);
// Throws ROUTER.MIDDLEWARE_INSERTION_DENIED
try {
assertPluginInsertionStep("pre-auth");
} catch (e) {
// RouterError with code "ROUTER.MIDDLEWARE_INSERTION_DENIED"
}
```
## Usage: Advanced
```ts
import {
assertPluginInsertionStep,
type PipelineStep,
} from "@suaveplan/api-router/middleware";
interface InsertCall {
step: PipelineStep;
middleware: unknown;
}
function registerPluginMiddleware(call: InsertCall) {
// guard returns the validated step; chains into the registry update
const step = assertPluginInsertionStep(call.step);
pluginMiddlewareRegistry.set(step, call.middleware);
}
```
## API Reference
- `PipelineStep` — `"pre-route-match" | "pre-auth" | "post-auth" | "pre-tenant" | "post-tenant" | "pre-validation" | "post-validation" | "pre-handler" | "post-handler" | "pre-serialize" | "post-serialize"`.
- `PLUGIN_INSERTION_STEP: PipelineStep` — value `"post-validation"`.
- `assertPluginInsertionStep(step: PipelineStep): PipelineStep` — throws
`RouterError` with `ROUTER.MIDDLEWARE_INSERTION_DENIED` when `step`
differs from `PLUGIN_INSERTION_STEP`.
## Implementation Notes
The guard returns the validated step so call sites can chain it directly
into registry mutations without an extra branch. The constant lives in
this module (not in the registry) so the canonical step name is the same
across every adapter — a registry that imported its own constant could
drift on a renaming refactor.
The pipeline step union is closed (literal type) by design. Adding a step
requires editing this file *and* the design doc; the change is intended
to be loud. Test surfaces that need finer granularity should compose
existing steps rather than invent new ones.

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { RouterError } from "../error-codes/error-codes.js";
import {
assertPluginInsertionStep,
PLUGIN_INSERTION_STEP,
} from "./insertion-point.js";
describe("assertPluginInsertionStep", () => {
it("accepts the post-validation boundary", { timeout: 5000 }, () => {
expect(assertPluginInsertionStep("post-validation")).toBe(
"post-validation"
);
});
it("PLUGIN_INSERTION_STEP equals post-validation", {
timeout: 5000,
}, () => {
expect(PLUGIN_INSERTION_STEP).toBe("post-validation");
});
it("plugin middleware rejected before auth step", { timeout: 5000 }, () => {
expect(() => assertPluginInsertionStep("pre-auth")).toThrow(
RouterError
);
});
it("rejection carries ROUTER.MIDDLEWARE_INSERTION_DENIED", {
timeout: 5000,
}, () => {
try {
assertPluginInsertionStep("pre-validation");
throw new Error("did not throw");
} catch (e) {
expect(e).toBeInstanceOf(RouterError);
if (e instanceof RouterError) {
expect(e.code).toBe("ROUTER.MIDDLEWARE_INSERTION_DENIED");
expect(e.context?.step).toBe("pre-validation");
expect(e.context?.allowed).toBe("post-validation");
}
}
});
it("rejects every non-allowed step", { timeout: 5000 }, () => {
const denied = [
"pre-route-match",
"pre-auth",
"post-auth",
"pre-tenant",
"post-tenant",
"pre-validation",
"pre-handler",
"post-handler",
"pre-serialize",
"post-serialize",
] as const;
for (const step of denied) {
expect(() => assertPluginInsertionStep(step)).toThrow(RouterError);
}
});
});

View File

@@ -0,0 +1,41 @@
import { ROUTER_CODES, RouterError } from "../error-codes/error-codes.js";
/**
* Per-request pipeline steps (design doc §6). Plugin-supplied middleware is
* allowed only at the boundary between step 7 (post-validation) and step 8
* (pre-handler). Insertion at any earlier step is a security boundary
* violation — the host throws.
*/
export type PipelineStep =
| "pre-route-match"
| "pre-auth"
| "post-auth"
| "pre-tenant"
| "post-tenant"
| "pre-validation"
| "post-validation"
| "pre-handler"
| "post-handler"
| "pre-serialize"
| "post-serialize";
/** The only step at which plugin-supplied middleware may be inserted. */
export const PLUGIN_INSERTION_STEP: PipelineStep = "post-validation";
/**
* Guard: throw if a caller tries to insert plugin middleware at a step
* other than the allowed boundary.
*
* Returns the (validated) step name so the call site can chain the guard
* into a registry update without an extra branch.
*/
export function assertPluginInsertionStep(step: PipelineStep): PipelineStep {
if (step !== PLUGIN_INSERTION_STEP) {
throw new RouterError(
ROUTER_CODES.MIDDLEWARE_INSERTION_DENIED,
`Plugin middleware may only be inserted at "${PLUGIN_INSERTION_STEP}"; got "${step}"`,
{ context: { step, allowed: PLUGIN_INSERTION_STEP } }
);
}
return step;
}

View File

@@ -0,0 +1,88 @@
# `middleware`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `middleware` module defines the onion-model `Middleware` contract and
the `composeMiddleware` builder. Every adapter consumes the same shape so
middleware authored against the abstract contract drops into every router
implementation (Hono, in-memory, the worker-bridge router) unchanged.
The contract is intentionally narrow: a middleware receives the request
and a `next()` continuation, and must return a response. There is no
`ctx` mutation handle, no implicit state — middleware threads explicit
values via wrapping responses and inspecting the request shape.
## Features
- `Middleware<ReqBody, ResBody>` — type generic over the request body and
response body shapes so handler-level types flow through.
- `composeMiddleware([…])` — builds a single composed middleware from a
list; order is preserved.
- `next()` enforcement: a middleware that calls `next()` twice in a row
has its dispatch rejected. (Multiple sequential `next()` calls across
*different* middlewares are correct; only same-middleware repeats fail.)
- Synchronous return values are accepted (wrapped in `Promise.resolve`).
## Usage: Basic
```ts
import { composeMiddleware } from "@suaveplan/api-router/middleware";
const log = composeMiddleware([
async (req, next) => {
const res = await next();
return res; // pass-through with hook points
},
]);
```
## Usage: Advanced
```ts
import { composeMiddleware, type Middleware } from "@suaveplan/api-router/middleware";
const auth: Middleware = async (req, next) => {
if (req.headers.get("authorization") === null) {
return { status: 401, headers: {}, body: { code: "UNAUTH" }, streaming: false };
}
return next();
};
const trace: Middleware = async (req, next) => {
req.span.setAttribute("path", req.url);
const res = await next();
req.span.setAttribute("status", res.status);
return res;
};
const stack = composeMiddleware([trace, auth]);
// Execution: trace → auth → terminal → auth → trace
```
## API Reference
- `Middleware<ReqBody, ResBody>` — `(req, next) => RouterResponse | Promise<RouterResponse>`.
- `composeMiddleware(list)` — returns a single composed `Middleware`.
- `PipelineStep` — literal union of every per-request pipeline step name.
- `PLUGIN_INSERTION_STEP` — the constant `"post-validation"`; the single
insertion point plugin middleware is allowed.
- `assertPluginInsertionStep(step)` — throws `ROUTER.MIDDLEWARE_INSERTION_DENIED`
if `step` isn't the allowed boundary.
## Implementation Notes
`composeMiddleware` uses the textbook indexed-dispatch pattern: each call
to `next()` advances an internal index, and double-`next()` from the same
middleware is detected by comparing the new index to the recorded
high-watermark. The detection rejects the inner Promise rather than
throwing synchronously so the error propagates through the caller's
existing `.catch` / `try/await` machinery.
Synchronous middlewares are supported — the return value is wrapped in
`Promise.resolve` so callers don't have to choose between `async` and
plain functions. The terminal continuation is invoked with no arguments
and must produce the response; adapters wire this to the handler call.

View File

@@ -0,0 +1,42 @@
import type { RouterRequest } from "../request/request.js";
import type { RouterResponse } from "../response/response.js";
/**
* Middleware contract — onion model.
*
* Receives the current `RouterRequest` and a `next()` continuation. The
* middleware MUST call `next()` exactly once (or never, to short-circuit
* with a different response), and MUST return either a `RouterResponse`
* synchronously or a `Promise<RouterResponse>`.
*/
export type Middleware<ReqBody = unknown, ResBody = unknown> = (
req: RouterRequest<ReqBody>,
next: () => Promise<RouterResponse<ResBody>>
) => Promise<RouterResponse<ResBody>> | RouterResponse<ResBody>;
/**
* Compose a chain of middlewares into a single middleware. Order is
* preserved — a chain `[A, B, C]` executes as:
* A → B → C → terminal → C → B → A
* The `next()` of the *last* middleware receives the terminal continuation.
*/
export function composeMiddleware<ReqBody = unknown, ResBody = unknown>(
list: ReadonlyArray<Middleware<ReqBody, ResBody>>
): Middleware<ReqBody, ResBody> {
return (req, terminal) => {
let i = -1;
const dispatch = (index: number): Promise<RouterResponse<ResBody>> => {
if (index <= i) {
return Promise.reject(
new Error("composeMiddleware: next() called multiple times")
);
}
i = index;
const mw = list[index];
if (mw === undefined) return Promise.resolve(terminal());
const result = mw(req, () => dispatch(index + 1));
return Promise.resolve(result);
};
return dispatch(0);
};
}

View File

@@ -0,0 +1,6 @@
export type {
HttpMethod,
RouterRequest,
RouterRequestHeaders,
} from "./request.js";
export { MapHeaders } from "./request.js";

View File

@@ -0,0 +1,88 @@
# `request`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `request` module defines the host-side `RouterRequest` shape — the
post-parse, post-validation view that route handlers receive after every
pipeline step ahead of step 7 has run (auth, tenant resolution, Zod
validation, idempotency check, CSRF). Adapters parse the raw wire intake
and project it onto this interface so handler code stays adapter-agnostic.
The companion `MapHeaders` class is a minimal in-memory case-insensitive
headers store used by the conformance harness and by adapters that don't
have a native headers map. Headers are deliberately accessed through a
`get` / `has` contract rather than an iterable object so adapters with
multi-value semantics (`Set-Cookie`, `Forwarded`) can still expose a
canonical first value.
## Features
- `RouterRequest<Body>` generic over the validated body shape — the type
parameter lets handlers carry the Zod-inferred body type.
- `HttpMethod` literal union covering the seven methods every adapter
recognises.
- `RouterRequestHeaders` — minimal lookup interface.
- `MapHeaders` — case-insensitive `Map`-backed implementation.
## Usage: Basic
```ts
import { MapHeaders, type RouterRequest } from "@suaveplan/api-router/request";
const req: RouterRequest<{ name: string }> = {
method: "POST",
url: "/users",
headers: new MapHeaders({ "content-type": "application/json" }),
body: { name: "Ada" },
params: {},
query: {},
tenantId: "tenant-1" as never,
principal: null,
requestId: "req-001",
span: noopSpan,
};
```
## Usage: Advanced
```ts
import { MapHeaders } from "@suaveplan/api-router/request";
const headers = new MapHeaders({ "X-Trace-Id": "abc" });
headers.get("x-trace-id"); // "abc" — case-insensitive
headers.has("X-TRACE-ID"); // true
headers.get("Missing"); // null
// Adapters with native Headers objects implement the same interface:
class HonoHeaders implements RouterRequestHeaders {
constructor(private inner: Headers) {}
get(name: string) { return this.inner.get(name); }
has(name: string) { return this.inner.has(name); }
}
```
## API Reference
- `RouterRequest<Body = unknown>` — readonly record carrying `method`,
`url`, `headers`, `body`, `params`, `query`, `tenantId`, `principal`,
`requestId`, `span`.
- `HttpMethod` — `"GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS"`.
- `RouterRequestHeaders` — `{ get(name): string|null; has(name): boolean }`.
- `MapHeaders` — concrete case-insensitive headers store; constructor
accepts a `Record<string, string>` and normalises keys to lower-case.
## Implementation Notes
`MapHeaders` lower-cases keys at insertion and lookup so adapters can write
`headers.get("X-Trace-Id")` regardless of how the caller capitalised the
key. The class is intentionally minimal — no setter, no iteration, no
multi-value support — to keep adapters in charge of their own headers
implementation. The `RouterRequest` shape stays readonly: handlers receive
a frozen view of the request so they cannot mutate the request mid-pipeline
and confuse downstream middleware. Validated body shape is carried at the
type level via the `Body` generic so the inferred-from-Zod handler-side
type lines up at the call site.

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MapHeaders } from "./request.js";
describe("MapHeaders", () => {
it("returns the inserted value via case-insensitive get", {
timeout: 5000,
}, () => {
const h = new MapHeaders({ "Content-Type": "application/json" });
expect(h.get("content-type")).toBe("application/json");
expect(h.get("CONTENT-TYPE")).toBe("application/json");
});
it("returns null for missing keys", { timeout: 5000 }, () => {
const h = new MapHeaders({});
expect(h.get("nope")).toBeNull();
});
it("has() is case-insensitive", { timeout: 5000 }, () => {
const h = new MapHeaders({ "X-Trace-Id": "abc" });
expect(h.has("x-trace-id")).toBe(true);
expect(h.has("X-TRACE-ID")).toBe(true);
expect(h.has("y-trace-id")).toBe(false);
});
it("defaults to empty when no init supplied", { timeout: 5000 }, () => {
const h = new MapHeaders();
expect(h.get("anything")).toBeNull();
expect(h.has("anything")).toBe(false);
});
});

View File

@@ -0,0 +1,74 @@
/**
* Host-side `Request` shape.
*
* This is the post-parse, post-validation shape that flows through the
* per-request pipeline (design doc §6). The wire intake — parsing headers,
* decoding the body, applying CSRF — is the adapter's job. By the time a
* handler sees a `RouterRequest`, every field has been parsed.
*
* Generic over a `Body` parameter so the schema-validated body shape can be
* carried at the type level. Adapters supply a concrete body shape after
* Zod validation (step 6 of the pipeline).
*/
import type { OtelSpanLike } from "@suaveplan/api-types/plugin-context";
import type { Principal } from "@suaveplan/api-types/principal";
import type { TenantId } from "@suaveplan/api-types/tenant";
/** HTTP method tokens recognised by the router. */
export type HttpMethod =
| "GET"
| "POST"
| "PUT"
| "PATCH"
| "DELETE"
| "HEAD"
| "OPTIONS";
/** Map-like view of headers — adapter-supplied. */
export interface RouterRequestHeaders {
get(name: string): string | null;
has(name: string): boolean;
}
/**
* Host-side request.
*
* Plugin authors receive this through their handler's `RequestContext`; the
* field names mirror Fetch / Hono conventions to ease cross-adapter porting.
*/
export interface RouterRequest<Body = unknown> {
readonly method: HttpMethod;
readonly url: string;
readonly headers: RouterRequestHeaders;
readonly body: Body;
readonly params: Readonly<Record<string, string>>;
readonly query: Readonly<Record<string, string>>;
readonly tenantId: TenantId;
readonly principal: Principal | null;
readonly requestId: string;
readonly span: OtelSpanLike;
}
/**
* Minimal in-memory headers container — used by adapters that don't have a
* native headers map (testing, sandbox bridges).
*/
export class MapHeaders implements RouterRequestHeaders {
private readonly entries: Map<string, string>;
public constructor(init: Readonly<Record<string, string>> = {}) {
this.entries = new Map(
Object.entries(init).map(([k, v]) => [k.toLowerCase(), v] as const)
);
}
public get(name: string): string | null {
const value = this.entries.get(name.toLowerCase());
return value ?? null;
}
public has(name: string): boolean {
return this.entries.has(name.toLowerCase());
}
}

View File

@@ -0,0 +1,11 @@
export type {
ResponseBuilderInit,
RouterResponse,
ServerTimingMetric,
} from "./response.js";
export {
createResponse,
jsonResponse,
ok,
ResponseBuilder,
} from "./response.js";

View File

@@ -0,0 +1,81 @@
# `response`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `response` module provides the host-side `RouterResponse` shape and a
fluent `ResponseBuilder` that adapters serialise onto the wire. It carries
the fields the per-request pipeline writes between steps 9-11 (design doc
§6): status, headers, body, ETag, Server-Timing aggregator, and a streaming
toggle.
The builder is immutable — every chain call returns a fresh
`ResponseBuilder` so partial states can be shared across branches without
risk of mid-flight mutation. The shortcut helpers `ok` and `jsonResponse`
cover the two most-common patterns and are tree-shakeable.
## Features
- `RouterResponse<Body>` — readonly shape with `status`, `headers`, `body`,
optional `etag`, optional `serverTiming`, and `streaming` flag.
- `ResponseBuilder` — immutable fluent builder (every method returns new
instance).
- `ServerTimingMetric` — `{ name, duration?, description? }`, RFC-aligned.
- `ok(body)` — 200 response in one call.
- `jsonResponse(status, body)` — explicit-status response in one call.
## Usage: Basic
```ts
import { ok } from "@suaveplan/api-router/response";
const res = ok({ id: "u_1" });
// { status: 200, headers: {}, body: { id: "u_1" }, streaming: false, ... }
```
## Usage: Advanced
```ts
import { createResponse } from "@suaveplan/api-router/response";
const res = createResponse<{ items: string[] }>()
.status(200)
.header("Cache-Control", "no-store")
.header("Content-Type", "application/json")
.etag('W/"abc"')
.serverTiming([
{ name: "db", duration: 12.4, description: "query users" },
{ name: "render", duration: 0.3 },
])
.body({ items: ["a", "b"] })
.build();
```
## API Reference
- `RouterResponse<Body>` — `{ status, headers, body, etag?, serverTiming?, streaming }`.
- `ResponseBuilder<Body>` — fluent immutable builder. Methods: `status`,
`header`, `headers`, `body`, `etag`, `serverTiming`, `streaming`, `build`.
- `createResponse(init?)` — factory.
- `ok(body)` — shortcut for `createResponse().body(body).status(200).build()`.
- `jsonResponse(status, body)` — shortcut for an explicit status.
- `ServerTimingMetric` — `{ name: string, duration?: number, description?: string }`.
## Implementation Notes
The builder allocates a new instance on every call. This is deliberate —
adapters that branch on conditional headers (`if (etagMatches) …`) get
isolated copies and never accidentally share state across branches. The
runtime cost is one object-spread per call; modern V8 inlines this away
for short builder chains.
The `body` setter is typed as a *re-parameterising* method — calling
`builder.body<T>(value)` returns a `ResponseBuilder<T>`, picking up the
new body type. This lets adapters call `.body(zodValidatedShape)` and the
inferred `RouterResponse.body` lines up with the validation output. The
default body when `build()` is called without setting one is the runtime
value `undefined` cast to `Body` — adapters that need a stricter contract
should provide an explicit body before `build()`.

View File

@@ -0,0 +1,86 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createResponse,
jsonResponse,
ok,
ResponseBuilder,
} from "./response.js";
describe("ResponseBuilder", () => {
it("builds a 200 default with no fields", { timeout: 5000 }, () => {
const res = new ResponseBuilder().build();
expect(res.status).toBe(200);
expect(res.headers).toEqual({});
expect(res.streaming).toBe(false);
});
it("chains status / header / body", { timeout: 5000 }, () => {
const res = createResponse<{ x: number }>()
.status(201)
.header("Content-Type", "application/json")
.body({ x: 1 })
.build();
expect(res.status).toBe(201);
expect(res.headers["Content-Type"]).toBe("application/json");
expect(res.body).toEqual({ x: 1 });
});
it("headers() called first sets the map", { timeout: 5000 }, () => {
const res = createResponse().headers({ A: "1" }).build();
expect(res.headers).toEqual({ A: "1" });
});
it("headers() merges multiple at once", { timeout: 5000 }, () => {
const res = createResponse()
.header("A", "1")
.headers({ B: "2", C: "3" })
.build();
expect(res.headers).toEqual({ A: "1", B: "2", C: "3" });
});
it("etag and serverTiming round-trip", { timeout: 5000 }, () => {
const res = createResponse()
.etag('W/"abc"')
.serverTiming([{ name: "db", duration: 1.2 }])
.build();
expect(res.etag).toBe('W/"abc"');
expect(res.serverTiming).toEqual([{ name: "db", duration: 1.2 }]);
});
it("streaming toggle defaults false and accepts true", {
timeout: 5000,
}, () => {
const off = createResponse().build();
const on = createResponse().streaming(true).build();
expect(off.streaming).toBe(false);
expect(on.streaming).toBe(true);
});
it("is immutable across chain calls", { timeout: 5000 }, () => {
const base = createResponse().status(200);
const a = base.body({ a: 1 }).build();
const b = base.body({ a: 2 }).build();
expect(a.body).toEqual({ a: 1 });
expect(b.body).toEqual({ a: 2 });
});
it("respects init values supplied to constructor", {
timeout: 5000,
}, () => {
const res = createResponse({ status: 418, body: "teapot" }).build();
expect(res.status).toBe(418);
expect(res.body).toBe("teapot");
});
it("ok(body) is shortcut for 200 + body", { timeout: 5000 }, () => {
const res = ok({ id: "u" });
expect(res.status).toBe(200);
expect(res.body).toEqual({ id: "u" });
});
it("jsonResponse sets status + body", { timeout: 5000 }, () => {
const res = jsonResponse(404, { code: "NOT_FOUND" });
expect(res.status).toBe(404);
expect(res.body).toEqual({ code: "NOT_FOUND" });
});
});

View File

@@ -0,0 +1,128 @@
/**
* Host-side `RouterResponse` builder.
*
* Adapters serialise this shape onto the wire. It carries the fields the
* per-request pipeline writes to between steps 9-11: status, headers, body,
* an ETag for conditional GET, a Server-Timing aggregator, and a streaming
* toggle for handlers that keep the connection open.
*/
/** Fluent builder + plain object — adapters may consume either. */
export interface RouterResponse<Body = unknown> {
readonly status: number;
readonly headers: Readonly<Record<string, string>>;
readonly body: Body;
readonly etag?: string;
readonly serverTiming?: readonly ServerTimingMetric[];
readonly streaming: boolean;
}
export interface ServerTimingMetric {
readonly name: string;
/** Duration in milliseconds — optional per the RFC. */
readonly duration?: number;
readonly description?: string;
}
export interface ResponseBuilderInit<Body> {
readonly status?: number;
readonly headers?: Readonly<Record<string, string>>;
readonly body?: Body;
readonly etag?: string;
readonly serverTiming?: readonly ServerTimingMetric[];
readonly streaming?: boolean;
}
/**
* Builder for `RouterResponse`. Chainable; calls return a fresh builder so
* partial-builder values can be shared across branches.
*/
export class ResponseBuilder<Body = unknown> {
private readonly state: ResponseBuilderInit<Body>;
public constructor(init: ResponseBuilderInit<Body> = {}) {
this.state = init;
}
public status(value: number): ResponseBuilder<Body> {
return new ResponseBuilder({ ...this.state, status: value });
}
public header(name: string, value: string): ResponseBuilder<Body> {
return new ResponseBuilder({
...this.state,
headers: { ...(this.state.headers ?? {}), [name]: value },
});
}
public headers(
value: Readonly<Record<string, string>>
): ResponseBuilder<Body> {
return new ResponseBuilder({
...this.state,
headers: { ...(this.state.headers ?? {}), ...value },
});
}
public body<NewBody>(value: NewBody): ResponseBuilder<NewBody> {
return new ResponseBuilder<NewBody>({
...(this.state as unknown as ResponseBuilderInit<NewBody>),
body: value,
});
}
public etag(value: string): ResponseBuilder<Body> {
return new ResponseBuilder({ ...this.state, etag: value });
}
public serverTiming(
metrics: readonly ServerTimingMetric[]
): ResponseBuilder<Body> {
return new ResponseBuilder({ ...this.state, serverTiming: metrics });
}
public streaming(value: boolean): ResponseBuilder<Body> {
return new ResponseBuilder({ ...this.state, streaming: value });
}
public build(): RouterResponse<Body> {
const out: {
status: number;
headers: Readonly<Record<string, string>>;
body: Body;
streaming: boolean;
etag?: string;
serverTiming?: readonly ServerTimingMetric[];
} = {
status: this.state.status ?? 200,
headers: this.state.headers ?? {},
body: this.state.body as Body,
streaming: this.state.streaming ?? false,
};
if (this.state.etag !== undefined) out.etag = this.state.etag;
if (this.state.serverTiming !== undefined) {
out.serverTiming = this.state.serverTiming;
}
return out;
}
}
/** Construct a new builder. */
export function createResponse<Body = unknown>(
init: ResponseBuilderInit<Body> = {}
): ResponseBuilder<Body> {
return new ResponseBuilder<Body>(init);
}
/** Shortcut for `createResponse().body(value).status(200).build()`. */
export function ok<Body>(body: Body): RouterResponse<Body> {
return createResponse<Body>().body(body).status(200).build();
}
/** Shortcut for `createResponse().status(s).body(b).build()`. */
export function jsonResponse<Body>(
status: number,
body: Body
): RouterResponse<Body> {
return createResponse<Body>().status(status).body(body).build();
}

View File

@@ -0,0 +1,2 @@
export type { AnyRouteHandler, RouteMatch } from "./route-match.js";
export { createRouteMatch } from "./route-match.js";

View File

@@ -0,0 +1,74 @@
# `route-match`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `route-match` module defines `RouteMatch`, the shape every concrete
adapter yields after resolving an incoming `(method, url)` pair against the
router's registry. The host pipeline threads this value through middleware
into the handler — middleware can introspect `options` to react to per-route
flags (idempotent, rate-limit) without re-parsing.
`pluginOfRecord` is carried explicitly so the host can stamp telemetry
labels and the rollback journal with the plugin that owns the matched
route, even when middleware is mounted across plugin boundaries.
## Features
- `RouteMatch<Handler>` — generic over the handler type so adapters can
pin a specific call signature (Hono context vs. in-memory vs. worker
proxy).
- `params` — parsed path-segment captures, frozen.
- `options` — the parsed `RouteOptions` block (post-Zod defaults).
- `createRouteMatch(init)` — convenience factory that freezes the result.
## Usage: Basic
```ts
import { createRouteMatch } from "@suaveplan/api-router/route-match";
import { parseRouteOptions } from "@suaveplan/api-router/route-options";
const match = createRouteMatch({
method: "GET",
path: "/users/:id",
params: { id: "u_42" },
handler: async () => ({ ok: true }),
options: parseRouteOptions({ auth: "users.read" }),
pluginOfRecord: "auth",
});
```
## Usage: Advanced
```ts
import type { RouteMatch } from "@suaveplan/api-router/route-match";
type HonoHandler = (ctx: { req: unknown }) => Promise<Response>;
function dispatch(match: RouteMatch<HonoHandler>) {
// match.handler signature is narrowed to HonoHandler
return match.handler({ req: {} });
}
```
## API Reference
- `RouteMatch<Handler>` — `{ method, path, params, handler, options, pluginOfRecord }`.
- `AnyRouteHandler` — type-erased handler signature (`(...args) => unknown`).
- `createRouteMatch(init)` — returns a frozen `RouteMatch`.
## Implementation Notes
`Object.freeze` is applied in `createRouteMatch` so middleware that
accidentally tries to mutate the match throws in strict mode — this catches
a class of bugs where downstream middleware reaches into the upstream
match's `params` and mutates path captures. Adapters that construct
`RouteMatch` objects directly are expected to apply their own freezing
discipline; the helper is the recommended path.
The handler generic carries through to the consumer so generic dispatch
loops can keep type safety. Erasing to `AnyRouteHandler` is permitted at
the boundary between adapters (where the host can't know the concrete
signature), but per-adapter code should pin the type.

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { parseRouteOptions } from "../route-options/route-options.js";
import { createRouteMatch } from "./route-match.js";
describe("createRouteMatch", () => {
it("constructs and freezes a RouteMatch", { timeout: 5000 }, () => {
const m = createRouteMatch({
method: "GET",
path: "/x/:id",
params: { id: "42" },
handler: () => "ok",
options: parseRouteOptions({}),
pluginOfRecord: "test",
});
expect(m.method).toBe("GET");
expect(m.params).toEqual({ id: "42" });
expect(m.pluginOfRecord).toBe("test");
expect(Object.isFrozen(m)).toBe(true);
});
it("preserves options defaults", { timeout: 5000 }, () => {
const m = createRouteMatch({
method: "POST",
path: "/users",
params: {},
handler: () => undefined,
options: parseRouteOptions({ auth: "x" }),
pluginOfRecord: "p",
});
expect(m.options.runtimeValidation).toBe(true);
expect(m.options.openapi).toBe(true);
expect(m.options.streaming).toBe(false);
});
});

View File

@@ -0,0 +1,33 @@
/**
* Abstract `RouteMatch` — the shape `Router.match()` returns.
*
* Adapters resolve method + path to one of these, then the host pipeline
* threads it through middleware and into the handler. Carries the matched
* params (path-segment captures), the handler reference, the plugin id of
* record (which plugin owns the matched route), and the unparsed
* `RouteOptions` payload so middleware can introspect rate-limit, csrf,
* etc. without re-parsing.
*/
import type { HttpMethod } from "../request/request.js";
import type { RouteOptions } from "../route-options/route-options.js";
/** Type-erased route handler — adapters narrow this per their context. */
export type AnyRouteHandler = (...args: readonly unknown[]) => unknown;
/** The shape every concrete router yields after a successful match. */
export interface RouteMatch<Handler extends AnyRouteHandler = AnyRouteHandler> {
readonly method: HttpMethod;
readonly path: string;
readonly params: Readonly<Record<string, string>>;
readonly handler: Handler;
readonly options: RouteOptions;
readonly pluginOfRecord: string;
}
/** Convenience constructor — keeps the readonly shape consistent. */
export function createRouteMatch<Handler extends AnyRouteHandler>(
init: RouteMatch<Handler>
): RouteMatch<Handler> {
return Object.freeze({ ...init }) as RouteMatch<Handler>;
}

View File

@@ -0,0 +1,7 @@
export type { RouteOptions, RouteOptionsInput } from "./route-options.js";
export {
isZodSchemaShape,
parseRouteOptions,
routeOptionsSchema,
safeParseRouteOptions,
} from "./route-options.js";

View File

@@ -0,0 +1,92 @@
# `route-options`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `route-options` module is the Zod-validated boundary for every per-route
configuration block. It widens the minimal `RouteOptions` type from
`@suaveplan/api-types` with the policy switches every Stage-1-and-later
adapter must honour: runtime Zod validation toggle, OpenAPI inclusion,
streaming mode, CSRF, idempotency, and a discriminated rate-limit shape.
Centralising the schema here lets adapters, plugin-loader, and the host all
parse the same shape, so a route option declared in a plugin manifest, a
TypeScript builder, or a JSON config file gets the same defaults.
## Features
- `routeOptionsSchema` — `z.strictObject` rejecting unknown keys.
- Defaults: `runtimeValidation: true`, `openapi: true`, `streaming: false`.
- `request.body` / `request.params` / `request.query` accept any Zod schema
(structural check — no instanceof, since two `z` instances in the same
process may not share constructors).
- `responses` is a `Record<status, { body: ZodSchema }>` so adapters can map
each documented status to its validator.
- `rateLimit` is a discriminated union: either `{ rpm }` or `{ rps }`.
## Usage: Basic
```ts
import { parseRouteOptions } from "@suaveplan/api-router/route-options";
const opts = parseRouteOptions({ auth: "users.read", summary: "List users" });
// opts.runtimeValidation === true (default applied)
// opts.openapi === true
// opts.streaming === false
```
## Usage: Advanced
```ts
import z from "zod";
import { parseRouteOptions } from "@suaveplan/api-router/route-options";
const opts = parseRouteOptions({
auth: "users.write",
summary: "Create user",
tags: ["users", "admin"],
request: {
body: z.object({ name: z.string(), email: z.email() }),
query: z.object({ dryRun: z.boolean().optional() }),
},
responses: {
201: { body: z.object({ id: z.string() }) },
409: { body: z.object({ code: z.string() }) },
},
streaming: false,
csrf: true,
idempotent: true,
rateLimit: { rpm: 60 },
});
```
## API Reference
- `routeOptionsSchema: z.ZodObject<…>` — the validated schema. Use
`.parse(...)`, `.safeParse(...)`, or `.shape` for adapter-side
introspection.
- `parseRouteOptions(input: unknown): RouteOptions` — throw-on-fail.
- `safeParseRouteOptions(input: unknown): ZodSafeParseResult<RouteOptions>`
— non-throwing variant.
- `RouteOptions` — `z.output` of the schema (post-defaults shape).
- `RouteOptionsInput` — `z.input` (caller-supplied shape).
## Implementation Notes
The schema uses `z.custom` with a structural predicate (looking for
`safeParse` / `_zod`) rather than `instanceof z.ZodType` so plugins that
import zod through their own dependency cone still pass the check. This is
deliberate: two copies of zod in the dependency graph would otherwise break
`instanceof`-based validation in surprising ways.
`z.strictObject` rejects unknown keys so typos in a plugin's route options
surface at registration time, not at the first failing request. Adapters
that want a permissive parse can call `routeOptionsSchema.passthrough()`
instead — but the default contract is strict.
The `rateLimit` union is intentionally narrow: per-minute and per-second
are the two real-world axes. Other windows (per-hour, per-day) belong in
dedicated rate-limit plugins, not in the route schema.

View File

@@ -0,0 +1,44 @@
import type { RouteOptions as ApiTypesRouteOptions } from "@suaveplan/api-types/router";
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import { type RouteOptions, routeOptionsSchema } from "./route-options.js";
describe("RouteOptions / api-types compatibility", () => {
it("local RouteOptions covers every api-types core key", {
timeout: 5000,
}, () => {
// The schema's output shape must expose at least the core fields
// (`auth`, `summary`) that `@suaveplan/api-types` declares.
expectTypeOf<RouteOptions>().toHaveProperty("auth");
expectTypeOf<RouteOptions>().toHaveProperty("summary");
});
it("api-types-shaped objects parse successfully", { timeout: 5000 }, () => {
// Construct an object using only api-types-known keys; it must parse.
const apiTypesShaped: ApiTypesRouteOptions = {
auth: "users.read",
summary: "List users",
};
const result = routeOptionsSchema.safeParse(apiTypesShaped);
expect(result.success).toBe(true);
});
it("rejects unknown option keys", { timeout: 5000 }, () => {
const result = routeOptionsSchema.safeParse({ unknownKey: true });
expect(result.success).toBe(false);
});
it("Router declares every verb method with overloaded signatures", {
timeout: 5000,
}, async () => {
// This is the canonical type-test cited by the spec verification line.
const { Router } = await import("../router/router.js");
expectTypeOf<InstanceType<typeof Router>>().toHaveProperty("get");
expectTypeOf<InstanceType<typeof Router>>().toHaveProperty("post");
expectTypeOf<InstanceType<typeof Router>>().toHaveProperty("put");
expectTypeOf<InstanceType<typeof Router>>().toHaveProperty("patch");
expectTypeOf<InstanceType<typeof Router>>().toHaveProperty("delete");
expectTypeOf<InstanceType<typeof Router>>().toHaveProperty("head");
expectTypeOf<InstanceType<typeof Router>>().toHaveProperty("options");
expectTypeOf<InstanceType<typeof Router>>().toHaveProperty("all");
});
});

View File

@@ -0,0 +1,146 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import z from "zod";
import {
parseRouteOptions,
routeOptionsSchema,
safeParseRouteOptions,
} from "./route-options.js";
describe("routeOptionsSchema", () => {
it("defaults apply when fields absent", { timeout: 5000 }, () => {
const opts = parseRouteOptions({});
expect(opts.runtimeValidation).toBe(true);
expect(opts.openapi).toBe(true);
expect(opts.streaming).toBe(false);
});
it("preserves caller-supplied values", { timeout: 5000 }, () => {
const opts = parseRouteOptions({
auth: "users.read",
summary: "List users",
tags: ["users"],
runtimeValidation: false,
openapi: false,
streaming: true,
csrf: true,
idempotent: false,
});
expect(opts.auth).toBe("users.read");
expect(opts.summary).toBe("List users");
expect(opts.tags).toEqual(["users"]);
expect(opts.runtimeValidation).toBe(false);
expect(opts.openapi).toBe(false);
expect(opts.streaming).toBe(true);
expect(opts.csrf).toBe(true);
expect(opts.idempotent).toBe(false);
});
it("accepts request schemas (Zod instances)", { timeout: 5000 }, () => {
const opts = parseRouteOptions({
request: {
body: z.object({ x: z.number() }),
params: z.object({ id: z.string() }),
query: z.object({ q: z.string().optional() }),
},
});
expect(opts.request?.body).toBeDefined();
expect(opts.request?.params).toBeDefined();
expect(opts.request?.query).toBeDefined();
});
it("accepts a responses map", { timeout: 5000 }, () => {
const opts = parseRouteOptions({
responses: {
"201": { body: z.object({ id: z.string() }) },
"400": { body: z.object({ code: z.string() }) },
},
});
expect(opts.responses).toBeDefined();
expect(opts.responses?.["201"]).toBeDefined();
});
it("accepts rateLimit per-minute", { timeout: 5000 }, () => {
const opts = parseRouteOptions({ rateLimit: { rpm: 30 } });
expect(opts.rateLimit).toEqual({ rpm: 30 });
});
it("accepts rateLimit per-second", { timeout: 5000 }, () => {
const opts = parseRouteOptions({ rateLimit: { rps: 5 } });
expect(opts.rateLimit).toEqual({ rps: 5 });
});
it("rejects rateLimit with both rpm and rps", { timeout: 5000 }, () => {
expect(() =>
parseRouteOptions({ rateLimit: { rpm: 10, rps: 1 } as never })
).toThrow();
});
it("rejects unknown option keys (strict)", { timeout: 5000 }, () => {
const result = safeParseRouteOptions({ badKey: 1 });
expect(result.success).toBe(false);
if (!result.success) {
const serialised = JSON.stringify(result.error.issues);
expect(serialised).toContain("badKey");
}
});
it("rejects non-Zod request.body", { timeout: 5000 }, () => {
const result = safeParseRouteOptions({
request: { body: { not: "a-schema" } },
});
expect(result.success).toBe(false);
});
it("safeParse returns success for empty input", { timeout: 5000 }, () => {
const result = safeParseRouteOptions({});
expect(result.success).toBe(true);
});
it("rejects negative rate-limit numbers", { timeout: 5000 }, () => {
const result = safeParseRouteOptions({ rateLimit: { rpm: 0 } });
expect(result.success).toBe(false);
});
it("schema is exposed for adapter introspection", { timeout: 5000 }, () => {
expect(routeOptionsSchema).toBeDefined();
// strict-object schemas reject unknown keys
expect(routeOptionsSchema.safeParse({ x: 1 }).success).toBe(false);
});
});
describe("isZodSchemaShape", () => {
it("rejects null", { timeout: 5000 }, async () => {
const { isZodSchemaShape } = await import("./route-options.js");
expect(isZodSchemaShape(null)).toBe(false);
});
it("rejects undefined", { timeout: 5000 }, async () => {
const { isZodSchemaShape } = await import("./route-options.js");
expect(isZodSchemaShape(undefined)).toBe(false);
});
it("rejects primitives", { timeout: 5000 }, async () => {
const { isZodSchemaShape } = await import("./route-options.js");
expect(isZodSchemaShape(42)).toBe(false);
expect(isZodSchemaShape("hello")).toBe(false);
expect(isZodSchemaShape(true)).toBe(false);
});
it("rejects plain objects", { timeout: 5000 }, async () => {
const { isZodSchemaShape } = await import("./route-options.js");
expect(isZodSchemaShape({})).toBe(false);
expect(isZodSchemaShape({ x: 1 })).toBe(false);
});
it("accepts objects carrying _zod marker", { timeout: 5000 }, async () => {
const { isZodSchemaShape } = await import("./route-options.js");
expect(isZodSchemaShape({ _zod: {} })).toBe(true);
});
it("accepts objects carrying safeParse function", {
timeout: 5000,
}, async () => {
const { isZodSchemaShape } = await import("./route-options.js");
expect(isZodSchemaShape({ safeParse: () => ({}) })).toBe(true);
});
});

View File

@@ -0,0 +1,86 @@
import z from "zod";
/**
* Zod schema for the per-route options block.
*
* Wider than the minimal `RouteOptions` exposed by `@suaveplan/api-types`
* (which carries only the most-common OpenAPI hints): this Stage-1
* contract adds the policy switches every adapter must honour —
* `runtimeValidation`, `streaming`, `csrf`, `idempotent`, and `rateLimit`.
*
* Defaults are deliberate:
* - `runtimeValidation: true` — adapters MUST validate Zod schemas at
* runtime unless the route opts out (e.g. a hot path with the body
* already validated upstream).
* - `openapi: true` — routes contribute to the OpenAPI document unless
* hidden.
* - `streaming: false` — handlers may return a `Response` body; if
* streaming is on, adapters must keep the connection open and forward
* chunked writes.
*/
/** True when `value` carries the structural marks of a Zod schema. */
export function isZodSchemaShape(value: unknown): boolean {
if (value === null || value === undefined) return false;
if (typeof value !== "object") return false;
const candidate = value as { _zod?: unknown; safeParse?: unknown };
return (
typeof candidate.safeParse === "function" ||
candidate._zod !== undefined
);
}
const zodSchemaShape: z.ZodType<z.ZodTypeAny> = z.custom<z.ZodTypeAny>(
isZodSchemaShape,
{ message: "expected a Zod schema" }
);
const rateLimitSchema = z.union([
z.strictObject({ rpm: z.number().positive() }),
z.strictObject({ rps: z.number().positive() }),
]);
export const routeOptionsSchema = z.strictObject({
auth: z.string().optional(),
summary: z.string().optional(),
tags: z.array(z.string()).readonly().optional(),
request: z
.strictObject({
body: zodSchemaShape.optional(),
params: zodSchemaShape.optional(),
query: zodSchemaShape.optional(),
})
.optional(),
responses: z
.record(z.string(), z.strictObject({ body: zodSchemaShape }))
.optional(),
runtimeValidation: z.boolean().default(true),
openapi: z.boolean().default(true),
streaming: z.boolean().default(false),
csrf: z.boolean().optional(),
idempotent: z.boolean().optional(),
rateLimit: rateLimitSchema.optional(),
});
/** Output type — schema with defaults applied. */
export type RouteOptions = z.output<typeof routeOptionsSchema>;
/** Input type — accepted by `routeOptionsSchema.parse`. */
export type RouteOptionsInput = z.input<typeof routeOptionsSchema>;
/**
* Parse and validate an options object, applying defaults. Returns the
* normalised `RouteOptions`; throws `ZodError` on shape violations.
*/
export function parseRouteOptions(input: unknown): RouteOptions {
return routeOptionsSchema.parse(input);
}
/**
* Non-throwing parse — useful at the adapter boundary where the error
* envelope is shaped by the host.
*/
export function safeParseRouteOptions(
input: unknown
): z.ZodSafeParseResult<RouteOptions> {
return routeOptionsSchema.safeParse(input);
}

View File

@@ -0,0 +1 @@
export { Router } from "./router.js";

View File

@@ -0,0 +1,110 @@
# `router`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `router` module defines the abstract `Router` — the verb-method surface
(`get` / `post` / `put` / `patch` / `delete` / `head` / `options` / `all`)
plus `use(middleware)`, `mount(prefix, sub)`, `match(method, path)`, and
`collidesWith(method, path)`. Concrete adapters in Stages 4-7
(`api-router-hono`, `api-router-in-memory`, the worker-bridge router)
either subclass this or pin to its method signatures.
The implementation in this file is itself a fully working in-memory router
used by the conformance harness and by every other unit test in the
package. Plugin authors write their `routes(router, ctx)` body against
this surface; the host hands them an instance with a stable
`pluginOfRecord`.
## Features
- Verb methods (`get`, `post`, `put`, `patch`, `delete`, `head`, `options`)
plus `all(path, …)` shortcut.
- Two overloads per verb: `(path, handler)` and `(path, options, handler)`.
- `RouteOptions` are parsed at registration time so defaults
(`runtimeValidation`, `openapi`, `streaming`) materialise.
- `mount(prefix, child)` — rewrites child routes under the prefix. Prefix
must start with `/` and not end with `/` — violations throw
`ROUTER.INVALID_PREFIX`.
- `match(method, path)` — returns a `RouteMatch` or `null`; query strings
are stripped.
- `collidesWith(method, path)` — returns matches with the same compiled
path; same-plugin collision at registration throws
`ROUTER.ROUTE_COLLISION`.
- `use(middleware)` — appends to the chain; `chain()` returns a snapshot.
- Path parameter syntax: `:name` per segment. Param names extracted to
`RouteMatch.params` (URL-decoded).
## Usage: Basic
```ts
import { Router } from "@suaveplan/api-router/router";
const r = new Router({ plugin: "auth" });
r.get("/users/:id", async () => ({ id: "u_1" }));
r.post(
"/users",
{ auth: "users.write", summary: "Create user" },
async () => ({ ok: true }),
);
const match = r.match("GET", "/users/u_42?expand=profile");
match?.params.id; // "u_42"
```
## Usage: Advanced
```ts
import { Router } from "@suaveplan/api-router/router";
const parent = new Router({ plugin: "auth" });
const child = new Router({ plugin: "billing" });
child.get("/invoices", async () => ({ invoices: [] }));
child.get("/invoices/:invoiceId", async () => ({ invoice: null }));
parent.mount("/billing", child);
parent.match("GET", "/billing/invoices"); // { params: {}, ... }
parent.match("GET", "/billing/invoices/inv_42"); // { params: { invoiceId: "inv_42" }, ... }
// Collision detection
parent.get("/x", async () => null);
parent.get("/x", async () => null); // throws ROUTER.ROUTE_COLLISION
```
## API Reference
- `new Router(options?: { plugin?: string })` — constructs a fresh router
bound to a plugin id (default `"default"`).
- Verb methods: `(path, handler)` or `(path, options, handler)`.
- `all(path, …)` — registers the same handler against every method.
- `use(middleware)` — appends to chain.
- `chain()` — returns the chain snapshot.
- `mount(prefix, child)` — joins child routes.
- `match(method, path)` — returns `RouteMatch | null`.
- `collidesWith(method, path)` — returns matches sharing the compiled path.
## Implementation Notes
Paths compile to a small segment array of `{ kind, value }` records, so
matching is a linear pass over the registry — adequate for the bounded
per-plugin route counts every adapter targets. Production adapters
(`api-router-hono`) can wire the same surface onto a trie-backed matcher;
this implementation prioritises correctness and observability over raw
throughput.
`mount` does NOT hoist child middleware. Router composition joins route
tables, not chains — a parent's chain applies uniformly to mounted routes,
and the child's chain is the child's concern at construction time. This
keeps the steps-7-8 insertion contract honest: middleware only enters
through `use()`, and only at the post-validation boundary when supplied
by plugins.
Collision detection compares the *compiled* path key (`":id"` vs.
`":userId"` both normalise to `:`-form). Adapters that need parameter-name
preservation can introspect `RouteMatch.path` for the originally registered
form.

View File

@@ -0,0 +1,257 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { RouterError } from "../error-codes/error-codes.js";
import { Router } from "./router.js";
describe("Router verb registration", () => {
it("registers and matches GET", { timeout: 5000 }, () => {
const r = new Router({ plugin: "p" });
const handler = () => "ok";
r.get("/users", handler);
const match = r.match("GET", "/users");
expect(match?.handler).toBe(handler);
expect(match?.params).toEqual({});
expect(match?.pluginOfRecord).toBe("p");
});
it("registers POST with options", { timeout: 5000 }, () => {
const r = new Router();
r.post("/x", { auth: "users.write" }, () => null);
const m = r.match("POST", "/x");
expect(m?.options.auth).toBe("users.write");
expect(m?.options.runtimeValidation).toBe(true);
});
it("supports every verb method", { timeout: 5000 }, () => {
const r = new Router();
r.get("/g", () => null);
r.post("/p", () => null);
r.put("/u", () => null);
r.patch("/pa", () => null);
r.delete("/d", () => null);
r.head("/h", () => null);
r.options("/o", () => null);
expect(r.match("GET", "/g")).not.toBeNull();
expect(r.match("POST", "/p")).not.toBeNull();
expect(r.match("PUT", "/u")).not.toBeNull();
expect(r.match("PATCH", "/pa")).not.toBeNull();
expect(r.match("DELETE", "/d")).not.toBeNull();
expect(r.match("HEAD", "/h")).not.toBeNull();
expect(r.match("OPTIONS", "/o")).not.toBeNull();
});
it("supports all() across every method", { timeout: 5000 }, () => {
const r = new Router();
r.all("/x", () => "any");
for (const m of [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"HEAD",
"OPTIONS",
] as const) {
expect(r.match(m, "/x")).not.toBeNull();
}
});
it("all() with options also covers every verb", { timeout: 5000 }, () => {
const r = new Router();
r.all("/x", { auth: "y" }, () => null);
const m = r.match("PUT", "/x");
expect(m?.options.auth).toBe("y");
});
it("throws TypeError when options form omits handler", {
timeout: 5000,
}, () => {
const r = new Router();
expect(() => {
(r as unknown as { get: (p: string, o: unknown) => void }).get(
"/x",
{ auth: "y" }
);
}).toThrow(TypeError);
});
});
describe("Router param matching", () => {
it("extracts a single param", { timeout: 5000 }, () => {
const r = new Router();
r.get("/users/:id", () => null);
const m = r.match("GET", "/users/u_1");
expect(m?.params).toEqual({ id: "u_1" });
});
it("extracts multiple params", { timeout: 5000 }, () => {
const r = new Router();
r.get("/orgs/:org/users/:user", () => null);
const m = r.match("GET", "/orgs/acme/users/u_1");
expect(m?.params).toEqual({ org: "acme", user: "u_1" });
});
it("URL-decodes param values", { timeout: 5000 }, () => {
const r = new Router();
r.get("/items/:name", () => null);
const m = r.match("GET", "/items/hello%20world");
expect(m?.params.name).toBe("hello world");
});
it("strips query string before matching", { timeout: 5000 }, () => {
const r = new Router();
r.get("/x", () => null);
expect(r.match("GET", "/x?foo=bar")).not.toBeNull();
});
it("returns null on path mismatch", { timeout: 5000 }, () => {
const r = new Router();
r.get("/x", () => null);
expect(r.match("GET", "/y")).toBeNull();
});
it("returns null on method mismatch", { timeout: 5000 }, () => {
const r = new Router();
r.get("/x", () => null);
expect(r.match("POST", "/x")).toBeNull();
});
it("returns null on length mismatch", { timeout: 5000 }, () => {
const r = new Router();
r.get("/x/:id", () => null);
expect(r.match("GET", "/x")).toBeNull();
expect(r.match("GET", "/x/y/z")).toBeNull();
});
it("handles root path", { timeout: 5000 }, () => {
const r = new Router();
r.get("/", () => null);
expect(r.match("GET", "/")).not.toBeNull();
});
});
describe("Router collision", () => {
it("same-plugin route collision rejected", { timeout: 5000 }, () => {
const r = new Router();
r.get("/x", () => null);
expect(() => r.get("/x", () => null)).toThrow(RouterError);
});
it("collidesWith returns matches with same compiled path", {
timeout: 5000,
}, () => {
const r = new Router();
r.get("/users/:id", () => null);
const hits = r.collidesWith("GET", "/users/:userId");
expect(hits.length).toBe(1);
});
it("different methods do not collide", { timeout: 5000 }, () => {
const r = new Router();
r.get("/x", () => null);
r.post("/x", () => null);
expect(r.match("GET", "/x")).not.toBeNull();
expect(r.match("POST", "/x")).not.toBeNull();
});
it("collision error carries method + path in context", {
timeout: 5000,
}, () => {
const r = new Router();
r.get("/y", () => null);
try {
r.get("/y", () => null);
throw new Error("did not throw");
} catch (e) {
if (e instanceof RouterError) {
expect(e.code).toBe("ROUTER.ROUTE_COLLISION");
expect(e.context?.method).toBe("GET");
}
}
});
});
describe("Router middleware chain", () => {
it("use() appends to chain", { timeout: 5000 }, () => {
const r = new Router();
r.use(async (_, next) => next());
r.use(async (_, next) => next());
expect(r.chain().length).toBe(2);
});
it("chain() returns a snapshot (not the live array)", {
timeout: 5000,
}, () => {
const r = new Router();
const snapshot = r.chain();
r.use(async (_, next) => next());
expect(snapshot.length).toBe(0);
expect(r.chain().length).toBe(1);
});
});
describe("Router.mount", () => {
it("prefixes child routes", { timeout: 5000 }, () => {
const parent = new Router({ plugin: "parent" });
const child = new Router({ plugin: "child" });
child.get("/invoices", () => null);
parent.mount("/billing", child);
const m = parent.match("GET", "/billing/invoices");
expect(m).not.toBeNull();
// pluginOfRecord is the *mounting* router's plugin (parent owns the route)
expect(m?.pluginOfRecord).toBe("parent");
});
it("prefixes parametrised child paths", { timeout: 5000 }, () => {
const parent = new Router();
const child = new Router();
child.get("/items/:id", () => null);
parent.mount("/api", child);
const m = parent.match("GET", "/api/items/42");
expect(m?.params).toEqual({ id: "42" });
});
it("rejects prefix not starting with /", { timeout: 5000 }, () => {
const parent = new Router();
const child = new Router();
child.get("/x", () => null);
expect(() => parent.mount("api", child)).toThrow(RouterError);
});
it("rejects prefix ending with /", { timeout: 5000 }, () => {
const parent = new Router();
const child = new Router();
child.get("/x", () => null);
expect(() => parent.mount("/api/", child)).toThrow(RouterError);
});
it("empty prefix mounts child at the root", { timeout: 5000 }, () => {
const parent = new Router();
const child = new Router();
child.get("/x", () => null);
parent.mount("/", child);
expect(parent.match("GET", "/x")).not.toBeNull();
});
});
describe("compilePath validation", () => {
it("rejects paths not starting with /", { timeout: 5000 }, () => {
const r = new Router();
expect(() => r.get("nope", () => null)).toThrow(RouterError);
});
it("match() against a slash-less path still tokenises consistently", {
timeout: 5000,
}, () => {
const r = new Router();
r.get("/x", () => null);
// tokenisePath strips a leading slash if present, otherwise splits as-is —
// exercising the "no leading slash" branch.
expect(r.match("GET", "x")).not.toBeNull();
});
it("match() at root with empty query string", { timeout: 5000 }, () => {
const r = new Router();
r.get("/", () => null);
expect(r.match("GET", "/?")).not.toBeNull();
});
});

View File

@@ -0,0 +1,369 @@
import { ROUTER_CODES, RouterError } from "../error-codes/error-codes.js";
import type { Middleware } from "../middleware/middleware.js";
import type { HttpMethod } from "../request/request.js";
import {
type AnyRouteHandler,
createRouteMatch,
type RouteMatch,
} from "../route-match/route-match.js";
import {
parseRouteOptions,
type RouteOptions,
type RouteOptionsInput,
} from "../route-options/route-options.js";
type HandlerOrOptions<H extends AnyRouteHandler> = H | RouteOptionsInput;
interface InternalRoute<H extends AnyRouteHandler> {
readonly method: HttpMethod;
/** Compiled segments — see `compilePath`. */
readonly segments: readonly PathSegment[];
/** Original normalised path. */
readonly path: string;
readonly handler: H;
readonly options: RouteOptions;
readonly pluginOfRecord: string;
}
interface PathSegment {
readonly kind: "literal" | "param";
readonly value: string;
}
const ALL_METHODS: ReadonlyArray<HttpMethod> = [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"HEAD",
"OPTIONS",
];
function normalisePrefix(prefix: string): string {
if (prefix === "" || prefix === "/") return "";
if (!prefix.startsWith("/")) {
throw new RouterError(
ROUTER_CODES.INVALID_PREFIX,
`mount prefix must start with "/" — got "${prefix}"`,
{ context: { prefix } }
);
}
if (prefix.endsWith("/")) {
throw new RouterError(
ROUTER_CODES.INVALID_PREFIX,
`mount prefix must not end with "/" — got "${prefix}"`,
{ context: { prefix } }
);
}
return prefix;
}
function compilePath(path: string): readonly PathSegment[] {
if (path === "" || path === "/") return [];
if (!path.startsWith("/")) {
throw new RouterError(
ROUTER_CODES.INVALID_PREFIX,
`route path must start with "/" — got "${path}"`,
{ context: { path } }
);
}
const parts = path.slice(1).split("/");
return parts.map((part): PathSegment => {
if (part.startsWith(":")) {
return { kind: "param", value: part.slice(1) };
}
return { kind: "literal", value: part };
});
}
function pathKey(segments: readonly PathSegment[]): string {
return segments
.map((s) => (s.kind === "literal" ? s.value : `:${s.value}`))
.join("/");
}
/** Collision key — parameter names are erased so `:id` and `:userId` clash. */
function collisionKey(segments: readonly PathSegment[]): string {
return segments
.map((s) => (s.kind === "literal" ? s.value : ":"))
.join("/");
}
function matchSegments(
pattern: readonly PathSegment[],
target: readonly string[]
): Record<string, string> | null {
if (pattern.length !== target.length) return null;
const params: Record<string, string> = {};
for (let i = 0; i < pattern.length; i += 1) {
const seg = pattern[i] as PathSegment;
const t = target[i] as string;
if (seg.kind === "literal") {
if (seg.value !== t) return null;
} else {
params[seg.value] = decodeURIComponent(t);
}
}
return params;
}
function tokenisePath(path: string): readonly string[] {
const idx = path.indexOf("?");
const bare = idx === -1 ? path : path.slice(0, idx);
if (bare === "" || bare === "/") return [];
const trimmed = bare.startsWith("/") ? bare.slice(1) : bare;
return trimmed.split("/");
}
/**
* Abstract `Router` — the concrete adapters in Stages 4-7 implement this
* surface against Hono / in-memory / worker bridges. This class is itself
* a fully-working in-memory router used by the conformance harness and by
* unit tests; production code is expected to consume one of the adapter
* subclasses.
*/
export class Router {
private readonly routes: Array<InternalRoute<AnyRouteHandler>> = [];
private readonly middlewares: Array<Middleware> = [];
private readonly pluginOfRecord: string;
public constructor(options: { plugin?: string } = {}) {
this.pluginOfRecord = options.plugin ?? "default";
}
public get<H extends AnyRouteHandler>(path: string, handler: H): void;
public get<H extends AnyRouteHandler>(
path: string,
options: RouteOptionsInput,
handler: H
): void;
public get<H extends AnyRouteHandler>(
path: string,
a: HandlerOrOptions<H>,
b?: H
): void {
this.register("GET", path, a, b);
}
public post<H extends AnyRouteHandler>(path: string, handler: H): void;
public post<H extends AnyRouteHandler>(
path: string,
options: RouteOptionsInput,
handler: H
): void;
public post<H extends AnyRouteHandler>(
path: string,
a: HandlerOrOptions<H>,
b?: H
): void {
this.register("POST", path, a, b);
}
public put<H extends AnyRouteHandler>(path: string, handler: H): void;
public put<H extends AnyRouteHandler>(
path: string,
options: RouteOptionsInput,
handler: H
): void;
public put<H extends AnyRouteHandler>(
path: string,
a: HandlerOrOptions<H>,
b?: H
): void {
this.register("PUT", path, a, b);
}
public patch<H extends AnyRouteHandler>(path: string, handler: H): void;
public patch<H extends AnyRouteHandler>(
path: string,
options: RouteOptionsInput,
handler: H
): void;
public patch<H extends AnyRouteHandler>(
path: string,
a: HandlerOrOptions<H>,
b?: H
): void {
this.register("PATCH", path, a, b);
}
public delete<H extends AnyRouteHandler>(path: string, handler: H): void;
public delete<H extends AnyRouteHandler>(
path: string,
options: RouteOptionsInput,
handler: H
): void;
public delete<H extends AnyRouteHandler>(
path: string,
a: HandlerOrOptions<H>,
b?: H
): void {
this.register("DELETE", path, a, b);
}
public head<H extends AnyRouteHandler>(path: string, handler: H): void;
public head<H extends AnyRouteHandler>(
path: string,
options: RouteOptionsInput,
handler: H
): void;
public head<H extends AnyRouteHandler>(
path: string,
a: HandlerOrOptions<H>,
b?: H
): void {
this.register("HEAD", path, a, b);
}
public options<H extends AnyRouteHandler>(path: string, handler: H): void;
public options<H extends AnyRouteHandler>(
path: string,
options: RouteOptionsInput,
handler: H
): void;
public options<H extends AnyRouteHandler>(
path: string,
a: HandlerOrOptions<H>,
b?: H
): void {
this.register("OPTIONS", path, a, b);
}
/** Register the same handler against every HTTP method. */
public all<H extends AnyRouteHandler>(path: string, handler: H): void;
public all<H extends AnyRouteHandler>(
path: string,
options: RouteOptionsInput,
handler: H
): void;
public all<H extends AnyRouteHandler>(
path: string,
a: HandlerOrOptions<H>,
b?: H
): void {
for (const method of ALL_METHODS) {
this.register(method, path, a, b);
}
}
/** Append a middleware to this router's chain. */
public use(middleware: Middleware): void {
this.middlewares.push(middleware);
}
/** Snapshot of the chain (readonly). */
public chain(): ReadonlyArray<Middleware> {
return [...this.middlewares];
}
/**
* Mount another `Router` under a prefix. Routes are imported with their
* paths rewritten; middleware on the child is *not* hoisted (per design,
* router composition only joins the route table, not the chain).
*/
public mount(prefix: string, child: Router): void {
const normalised = normalisePrefix(prefix);
for (const route of child.routes) {
const rewritten = `${normalised}/${pathKey(route.segments)}`;
this.register(
route.method,
rewritten,
route.options,
route.handler
);
}
}
/**
* Resolve a (method, path) pair. Returns `null` when no route matches.
* Path may include a query string — it's stripped before matching.
*/
public match(method: HttpMethod, path: string): RouteMatch | null {
const target = tokenisePath(path);
for (const route of this.routes) {
if (route.method !== method) continue;
const params = matchSegments(route.segments, target);
if (params === null) continue;
return createRouteMatch({
method: route.method,
path: `/${pathKey(route.segments)}`,
params,
handler: route.handler,
options: route.options,
pluginOfRecord: route.pluginOfRecord,
});
}
return null;
}
/** Routes that conflict (same method + identical compiled path). */
public collidesWith(method: HttpMethod, path: string): RouteMatch[] {
const segs = compilePath(path);
const key = collisionKey(segs);
return this.routes
.filter(
(r) => r.method === method && collisionKey(r.segments) === key
)
.map((r) =>
createRouteMatch({
method: r.method,
path: `/${pathKey(r.segments)}`,
params: {},
handler: r.handler,
options: r.options,
pluginOfRecord: r.pluginOfRecord,
})
);
}
private register<H extends AnyRouteHandler>(
method: HttpMethod,
path: string,
a: HandlerOrOptions<H> | RouteOptions,
b?: H
): void {
let options: RouteOptions;
let handler: H;
if (typeof a === "function") {
options = parseRouteOptions({});
handler = a as H;
} else {
options = parseRouteOptions(a as RouteOptionsInput);
if (typeof b !== "function") {
throw new TypeError(
"Router.<verb>(path, options, handler): handler missing"
);
}
handler = b;
}
const segments = compilePath(path);
const key = collisionKey(segments);
for (const existing of this.routes) {
if (
existing.method === method &&
collisionKey(existing.segments) === key
) {
throw new RouterError(
ROUTER_CODES.ROUTE_COLLISION,
`Route ${method} /${key} already registered on plugin "${existing.pluginOfRecord}"`,
{
context: {
method,
path,
existing: existing.pluginOfRecord,
},
}
);
}
}
this.routes.push({
method,
segments,
path,
handler,
options,
pluginOfRecord: this.pluginOfRecord,
});
}
}

View File

@@ -0,0 +1,8 @@
export type {
BlueGreenSlot,
DrainingHandle,
RouterSlot,
SlotHandler,
SlotPosition,
} from "./slot.js";
export { InMemoryBlueGreenSlot } from "./slot.js";

View File

@@ -0,0 +1,98 @@
# `slot`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `slot` module defines `BlueGreenSlot` — the per-plugin atomic mount
point that backs hot-reload. It exposes three positions (`live`, `shadow`,
`draining`) plus a `flip()` primitive that promotes shadow to live in a
single observable step, returning a `DrainingHandle` for the previous live
slot so callers can wait for in-flight requests to settle before disposal.
Per design doc §8.1, the per-plugin version pointer is the single atomic
point that determines which module gets a given request. This module is
that pointer's contract. The reference implementation
`InMemoryBlueGreenSlot` lives here so the conformance harness and Stage-1
testing can exercise the full lifecycle; production `api-host` is expected
to compose this primitive with its registry.
## Features
- `BlueGreenSlot` interface — abstract contract every adapter pins to.
- `RouterSlot` — `{ slotId, handler, version }` triple; ids are
deterministic (`<plugin>.v<n>`).
- `DrainingHandle` — `{ slotId, settled, abort() }` returned from `flip()`.
- `flip()` is atomic — sets shadow→null, live→shadow, and returns the
previous-live slot as draining in one synchronous step.
- `dispose(slotId, { timeoutMs })` — Promise.race against settlement;
fires `ROUTER.SLOT_DISPOSE_TIMEOUT` and wires AbortSignal on timeout.
- `flip()` with `shadow === null` throws `ROUTER.SLOT_FLIP_FAILED`.
- Pending-request bookkeeping via `acquire(slotId)` / `release(slotId)`
so the host pipeline can decrement on response settlement.
## Usage: Basic
```ts
import { InMemoryBlueGreenSlot } from "@suaveplan/api-router/slot";
const slot = new InMemoryBlueGreenSlot("auth", () => "v1");
slot.setShadow(() => "v2");
const draining = slot.flip();
// slot.live is now v2; draining points at v1
await slot.dispose(draining.slotId, { timeoutMs: 5_000 });
```
## Usage: Advanced
```ts
import { InMemoryBlueGreenSlot, type DrainingHandle } from "@suaveplan/api-router/slot";
const slot = new InMemoryBlueGreenSlot("billing", () => "v1");
// Two in-flight requests against v1
slot.acquire(slot.live.slotId);
slot.acquire(slot.live.slotId);
slot.setShadow(() => "v2");
const draining: DrainingHandle = slot.flip();
// Race: dispose with short timeout will throw SLOT_DISPOSE_TIMEOUT and
// wire the abort signal onto the still-pending requests.
try {
await slot.dispose(draining.slotId, { timeoutMs: 50 });
} catch (e) {
// RouterError code "ROUTER.SLOT_DISPOSE_TIMEOUT"; in-flight requests
// see draining.settled remain pending, but their abort signal is
// tripped.
}
```
## API Reference
- `BlueGreenSlot` — interface with `live`, `shadow`, `setShadow`, `flip`,
`dispose`, `acquire`, `release`.
- `RouterSlot` — `{ slotId: string, handler: SlotHandler, version: number }`.
- `SlotPosition` — `"live" | "shadow" | "draining"`.
- `DrainingHandle` — `{ slotId, settled: Promise<void>, abort(): void }`.
- `SlotHandler` — `(...args: unknown[]) => unknown` (type-erased).
- `InMemoryBlueGreenSlot` — reference impl; constructor takes
`(plugin: string, initialHandler: SlotHandler)`.
## Implementation Notes
`flip()` is implemented as a synchronous three-line atomic: read shadow,
clear shadow, swap live. JavaScript's single-threaded execution makes this
observably atomic to all callers within the same realm. The previous-live
slot's state (including its abort controller and pending counter) is
moved into the `draining` map so subsequent `dispose` / `acquire` /
`release` calls can address it by slot id.
`dispose` races a timeout against a settlement promise — once all pending
requests `release`, the promise resolves and dispose returns cleanly. On
timeout, the slot's `AbortController` is tripped; existing request flows
that wired their fetch onto `signalFor(slotId)` will observe the abort
and abandon their work. The thrown `RouterError` carries the slot id and
remaining pending count in `context` for diagnostic logging.

View File

@@ -0,0 +1,142 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { RouterError } from "../error-codes/error-codes.js";
import { InMemoryBlueGreenSlot } from "./slot.js";
describe("InMemoryBlueGreenSlot", () => {
it("starts with v1 as live and no shadow", { timeout: 5000 }, () => {
const slot = new InMemoryBlueGreenSlot("p", () => "v1");
expect(slot.live.slotId).toBe("p.v1");
expect(slot.live.version).toBe(1);
expect(slot.shadow).toBeNull();
});
it("setShadow stages a new handler", { timeout: 5000 }, () => {
const slot = new InMemoryBlueGreenSlot("p", () => "v1");
slot.setShadow(() => "v2");
expect(slot.shadow?.slotId).toBe("p.v2");
});
it("flip atomically promotes shadow and returns draining handle", {
timeout: 5000,
}, () => {
const slot = new InMemoryBlueGreenSlot("p", () => "v1");
slot.setShadow(() => "v2");
const draining = slot.flip();
expect(slot.live.slotId).toBe("p.v2");
expect(slot.shadow).toBeNull();
expect(draining.slotId).toBe("p.v1");
});
it("flip with no shadow throws SLOT_FLIP_FAILED", { timeout: 5000 }, () => {
const slot = new InMemoryBlueGreenSlot("p", () => "v1");
expect(() => slot.flip()).toThrow(RouterError);
try {
slot.flip();
} catch (e) {
if (e instanceof RouterError) {
expect(e.code).toBe("ROUTER.SLOT_FLIP_FAILED");
}
}
});
it("dispose resolves immediately when no pending requests", {
timeout: 5000,
}, async () => {
const slot = new InMemoryBlueGreenSlot("p", () => "v1");
slot.setShadow(() => "v2");
const draining = slot.flip();
await expect(
slot.dispose(draining.slotId, { timeoutMs: 100 })
).resolves.toBeUndefined();
});
it("dispose waits for pending requests to settle", {
timeout: 5000,
}, async () => {
const slot = new InMemoryBlueGreenSlot("p", () => "v1");
const liveId = slot.live.slotId;
slot.acquire(liveId);
slot.setShadow(() => "v2");
const draining = slot.flip();
// Release the pending in 30ms — dispose should resolve under 100ms timeout
setTimeout(() => slot.release(draining.slotId), 30);
await expect(
slot.dispose(draining.slotId, { timeoutMs: 1000 })
).resolves.toBeUndefined();
});
it("dispose throws SLOT_DISPOSE_TIMEOUT when pending exceeds timeout", {
timeout: 5000,
}, async () => {
const slot = new InMemoryBlueGreenSlot("p", () => "v1");
slot.acquire(slot.live.slotId);
slot.setShadow(() => "v2");
const draining = slot.flip();
await expect(
slot.dispose(draining.slotId, { timeoutMs: 20 })
).rejects.toMatchObject({ code: "ROUTER.SLOT_DISPOSE_TIMEOUT" });
});
it("dispose timeout trips abort signal", { timeout: 5000 }, async () => {
const slot = new InMemoryBlueGreenSlot("p", () => "v1");
const liveId = slot.live.slotId;
slot.acquire(liveId);
slot.setShadow(() => "v2");
const draining = slot.flip();
await expect(
slot.dispose(draining.slotId, { timeoutMs: 10 })
).rejects.toBeInstanceOf(RouterError);
const signal = slot.signalFor(draining.slotId);
expect(signal?.aborted).toBe(true);
});
it("DrainingHandle.abort() trips the abort signal", {
timeout: 5000,
}, () => {
const slot = new InMemoryBlueGreenSlot("p", () => "v1");
slot.setShadow(() => "v2");
const draining = slot.flip();
const signal = slot.signalFor(draining.slotId);
expect(signal?.aborted).toBe(false);
draining.abort();
expect(signal?.aborted).toBe(true);
});
it("acquire/release on unknown slot is a no-op", { timeout: 5000 }, () => {
const slot = new InMemoryBlueGreenSlot("p", () => "v1");
// Should not throw.
slot.acquire("nope");
slot.release("nope");
expect(slot.signalFor("nope")).toBeNull();
});
it("dispose on unknown slot is a no-op", { timeout: 5000 }, async () => {
const slot = new InMemoryBlueGreenSlot("p", () => "v1");
await expect(
slot.dispose("nope", { timeoutMs: 50 })
).resolves.toBeUndefined();
});
it("settled promise resolves when last release fires", {
timeout: 5000,
}, async () => {
const slot = new InMemoryBlueGreenSlot("p", () => "v1");
slot.acquire(slot.live.slotId);
slot.acquire(slot.live.slotId);
slot.setShadow(() => "v2");
const draining = slot.flip();
const settled = draining.settled;
slot.release(draining.slotId);
slot.release(draining.slotId);
await expect(settled).resolves.toBeUndefined();
});
it("release floor at 0 (over-release tolerated)", { timeout: 5000 }, () => {
const slot = new InMemoryBlueGreenSlot("p", () => "v1");
slot.release(slot.live.slotId);
slot.release(slot.live.slotId);
// No throw, no negative pending observable
slot.acquire(slot.live.slotId);
slot.release(slot.live.slotId);
});
});

View File

@@ -0,0 +1,217 @@
import { ROUTER_CODES, RouterError } from "../error-codes/error-codes.js";
/** A per-plugin handler reference held in a slot position. */
export type SlotHandler = (...args: readonly unknown[]) => unknown;
/** Identifies one of the three positions a `BlueGreenSlot` can hold. */
export type SlotPosition = "live" | "shadow" | "draining";
/** Opaque handle representing a draining slot — returned by `flip()`. */
export interface DrainingHandle {
readonly slotId: string;
/** Promise that resolves once all in-flight requests against this slot settle. */
readonly settled: Promise<void>;
/** Signals abort to the still-pending requests. */
abort(): void;
}
/** Per-plugin router mount point. */
export interface RouterSlot {
readonly slotId: string;
readonly handler: SlotHandler;
readonly version: number;
}
/**
* Atomic flip primitive backing hot-reload. Three positions:
* - `live` (currently serving)
* - `shadow` (imported, not yet serving)
* - `draining` (former `live`, finishing in-flight)
*
* Per design §8.1: the per-plugin version pointer is the single atomic
* point that decides which module gets a request. This interface is that
* pointer's contract; concrete impls live in `api-host`.
*/
export interface BlueGreenSlot {
/** Current `live` slot. Throws if disposed. */
readonly live: RouterSlot;
/** Currently-staged shadow, or null. */
readonly shadow: RouterSlot | null;
/** Set or replace the shadow handler. */
setShadow(handler: SlotHandler): void;
/**
* Atomically promote `shadow` to `live`, returning a draining handle
* for the previous live slot. Throws `ROUTER.SLOT_FLIP_FAILED` if no
* shadow is set.
*/
flip(): DrainingHandle;
/**
* Wait for a draining slot to settle, or abort if the timeout elapses.
* Throws `ROUTER.SLOT_DISPOSE_TIMEOUT` on timeout AND signals abort to
* still-pending requests.
*/
dispose(slotId: string, opts: { timeoutMs: number }): Promise<void>;
/** Increment pending counter for the named slot — used by request acquire. */
acquire(slotId: string): void;
/** Decrement pending counter — used after request settles. */
release(slotId: string): void;
}
interface SlotState {
slot: RouterSlot;
pending: number;
waiters: Array<() => void>;
abortController: AbortController;
}
/**
* Reference, in-memory implementation of `BlueGreenSlot`.
*
* `api-host` is expected to compose this primitive with its router
* registry; tests and the conformance harness use it directly. Slot IDs
* are derived deterministically from `<plugin>.<version>` so log lines
* and span tags stay readable.
*/
export class InMemoryBlueGreenSlot implements BlueGreenSlot {
private readonly plugin: string;
private liveState: SlotState;
private shadowSlot: RouterSlot | null = null;
private nextVersion: number;
private readonly draining = new Map<string, SlotState>();
public constructor(plugin: string, initialHandler: SlotHandler) {
this.plugin = plugin;
this.nextVersion = 1;
const slot = this.makeSlot(initialHandler);
this.liveState = this.makeState(slot);
}
public get live(): RouterSlot {
return this.liveState.slot;
}
public get shadow(): RouterSlot | null {
return this.shadowSlot;
}
public setShadow(handler: SlotHandler): void {
this.shadowSlot = this.makeSlot(handler);
}
public flip(): DrainingHandle {
if (this.shadowSlot === null) {
throw new RouterError(
ROUTER_CODES.SLOT_FLIP_FAILED,
`BlueGreenSlot(${this.plugin}).flip() called with no shadow`,
{ context: { plugin: this.plugin } }
);
}
const previous = this.liveState;
const newLive = this.shadowSlot;
this.shadowSlot = null;
this.liveState = this.makeState(newLive);
this.draining.set(previous.slot.slotId, previous);
const handle: DrainingHandle = {
slotId: previous.slot.slotId,
settled: this.settledPromise(previous),
abort: () => previous.abortController.abort(),
};
return handle;
}
public async dispose(
slotId: string,
opts: { timeoutMs: number }
): Promise<void> {
const state = this.draining.get(slotId);
if (state === undefined) return;
if (state.pending === 0) {
this.draining.delete(slotId);
return;
}
const timerRef: { value: ReturnType<typeof setTimeout> | undefined } = {
value: undefined,
};
const timeout = new Promise<"timeout">((resolve) => {
timerRef.value = setTimeout(
() => resolve("timeout"),
opts.timeoutMs
);
});
const settled = this.settledPromise(state).then(
() => "settled" as const
);
const winner = await Promise.race([timeout, settled]);
clearTimeout(timerRef.value);
if (winner === "timeout") {
state.abortController.abort();
throw new RouterError(
ROUTER_CODES.SLOT_DISPOSE_TIMEOUT,
`BlueGreenSlot.dispose(${slotId}) timed out after ${opts.timeoutMs}ms with ${state.pending} pending`,
{
context: {
slotId,
pending: state.pending,
timeoutMs: opts.timeoutMs,
},
}
);
}
this.draining.delete(slotId);
}
public acquire(slotId: string): void {
const state = this.lookup(slotId);
if (state === null) return;
state.pending += 1;
}
public release(slotId: string): void {
const state = this.lookup(slotId);
if (state === null) return;
state.pending -= 1;
if (state.pending <= 0) {
state.pending = 0;
const drainWaiters = state.waiters.splice(0, state.waiters.length);
for (const waiter of drainWaiters) waiter();
}
}
/** Returns abort signal callers can wire onto in-flight requests on a draining slot. */
public signalFor(slotId: string): AbortSignal | null {
const state = this.lookup(slotId);
return state === null ? null : state.abortController.signal;
}
private lookup(slotId: string): SlotState | null {
if (this.liveState.slot.slotId === slotId) return this.liveState;
const draining = this.draining.get(slotId);
return draining ?? null;
}
private makeSlot(handler: SlotHandler): RouterSlot {
const version = this.nextVersion;
this.nextVersion += 1;
return {
slotId: `${this.plugin}.v${version}`,
handler,
version,
};
}
private makeState(slot: RouterSlot): SlotState {
return {
slot,
pending: 0,
waiters: [],
abortController: new AbortController(),
};
}
private settledPromise(state: SlotState): Promise<void> {
if (state.pending === 0) return Promise.resolve();
return new Promise<void>((resolve) => {
state.waiters.push(resolve);
});
}
}

View File

@@ -0,0 +1,91 @@
# `testing/conformance`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `testing/conformance` module exports `runRouterConformance(factory)` —
the canonical conformance harness every concrete router adapter MUST pass.
Stages 4-7 deliver adapters (`api-router-hono`, `api-router-in-memory`, the
worker-bridge router); each pins to this harness so behaviour stays
identical across the matrix.
The harness exercises every required surface: verb registration, mount
rewriting, middleware chain ordering, route-collision rejection, param
parsing, blue-green slot lifecycle, and error propagation. Adapters that
add their own behaviours layer those tests on top — never instead of — the
harness suite.
## Features
- `RouterConformanceFactory` — `{ router: () => Router, slot: (plugin, handler) => BlueGreenSlot, label: string }`.
- One `describe` block per factory invocation, labelled with `factory.label`.
- Eight required `it` cases — verb declarations, mount, middleware
ordering, collision rejection, param parse, slot lifecycle, flip-with-no-shadow,
error propagation.
- Reuses `composeMiddleware` and the reference `Request`/`Response` so
adapters get the canonical chain semantics for free.
## Usage: Basic
```ts
import { runRouterConformance } from "@suaveplan/api-router/testing";
import { Router } from "@suaveplan/api-router/router";
import { InMemoryBlueGreenSlot } from "@suaveplan/api-router/slot";
runRouterConformance({
label: "InMemory",
router: () => new Router({ plugin: "test" }),
slot: (plugin, handler) => new InMemoryBlueGreenSlot(plugin, handler),
});
```
## Usage: Advanced
```ts
// Inside an adapter package (e.g. api-router-hono):
import { runRouterConformance } from "@suaveplan/api-router/testing";
import { HonoRouter } from "./hono-router.js";
import { InMemoryBlueGreenSlot } from "@suaveplan/api-router/slot";
// Adapter-specific tests
describe("HonoRouter — adapter-specific", () => {
/* … hono-only assertions … */
});
// Then run the canonical suite
runRouterConformance({
label: "Hono",
router: () => new HonoRouter({ plugin: "test" }),
slot: (plugin, handler) => new InMemoryBlueGreenSlot(plugin, handler),
});
```
## API Reference
- `runRouterConformance(factory: RouterConformanceFactory): void` — drops
a fully-formed `describe` block into the host test file. The block
contains eight `it` cases covering every required surface.
- `RouterConformanceFactory` — `{ router, slot, label }`.
## Implementation Notes
The harness imports `@suaveplan/testing/runner` so the same source file
runs under both Vitest and `bun:test` (per the project's runner
abstraction). Each `it` carries the standard `{ timeout: 5000 }` envelope
so any deadlock in an adapter's implementation surfaces as a clean
timeout failure rather than an indefinitely-hanging test run.
The `slot` factory parameter exists so adapters that have their own
`BlueGreenSlot` implementation (e.g. a worker-RPC-backed one) can wire it
into the same lifecycle tests. Adapters without a custom slot
implementation pass `InMemoryBlueGreenSlot` from this package — the
reference impl is intentionally complete enough to be used directly in
production-equivalent test scenarios.
The harness deliberately uses synthetic `RouterRequest` objects built
with `MapHeaders` — the conformance contract is router-level, not
adapter-intake-level. Adapters that want to exercise their wire intake
(parsing, body decoding) add those tests separately.

View File

@@ -0,0 +1,9 @@
import { Router } from "../router/router.js";
import { InMemoryBlueGreenSlot } from "../slot/slot.js";
import { runRouterConformance } from "./conformance.js";
runRouterConformance({
label: "InMemory (reference impl)",
router: () => new Router({ plugin: "conformance" }),
slot: (plugin, handler) => new InMemoryBlueGreenSlot(plugin, handler),
});

View File

@@ -0,0 +1,178 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { RouterError } from "../error-codes/error-codes.js";
import {
composeMiddleware,
type Middleware,
} from "../middleware/middleware.js";
import { MapHeaders, type RouterRequest } from "../request/request.js";
import { jsonResponse } from "../response/response.js";
import type { Router } from "../router/router.js";
import type { BlueGreenSlot, SlotHandler } from "../slot/slot.js";
/**
* Factory bundle the conformance harness consumes — a concrete adapter
* supplies one of these and the harness exercises every required case.
*/
export interface RouterConformanceFactory {
/** Build a fresh `Router` (or adapter-equivalent) for each test. */
readonly router: () => Router;
/**
* Build a fresh `BlueGreenSlot` — adapter may use the reference impl or
* its own implementation. The harness passes `(plugin, handler)`.
*/
readonly slot: (plugin: string, handler: SlotHandler) => BlueGreenSlot;
/** Display label used in test names. */
readonly label: string;
}
/** Noop `OtelSpanLike` — usable in test scenarios that don't assert on spans. */
export function makeNoopSpan(): RouterRequest["span"] {
return {
setAttribute() {
/* noop */
},
setStatus() {
/* noop */
},
recordException() {
/* noop */
},
end() {
/* noop */
},
};
}
/** Build a synthetic `RouterRequest` — the harness uses this internally. */
export function makeFixtureRequest(
overrides: Partial<RouterRequest> = {}
): RouterRequest {
return {
method: "GET",
url: "/x",
headers: new MapHeaders(),
body: undefined,
params: {},
query: {},
tenantId: "t" as never,
principal: null,
requestId: "r",
span: makeNoopSpan(),
...overrides,
};
}
function makeReq(): RouterRequest {
return makeFixtureRequest();
}
/**
* Runs the canonical conformance suite against the supplied factory.
*
* Every adapter in Stages 4-7 (`api-router-hono`, `api-router-in-memory`,
* the worker-bridge router) MUST pass this suite. The harness exercises:
* - every verb method
* - `mount(prefix, child)` rewriting
* - middleware chain ordering via `composeMiddleware`
* - route collision detection
* - `BlueGreenSlot` lifecycle (set → flip → drain → dispose)
* - param parsing
* - error propagation
*/
export function runRouterConformance(factory: RouterConformanceFactory): void {
describe(`Router conformance — ${factory.label}`, () => {
it("declares every verb method", { timeout: 5000 }, () => {
const r = factory.router();
r.get("/g", () => null);
r.post("/p", () => null);
r.put("/u", () => null);
r.patch("/pa", () => null);
r.delete("/d", () => null);
r.head("/h", () => null);
r.options("/o", () => null);
expect(r.match("GET", "/g")).not.toBeNull();
expect(r.match("POST", "/p")).not.toBeNull();
expect(r.match("PUT", "/u")).not.toBeNull();
expect(r.match("PATCH", "/pa")).not.toBeNull();
expect(r.match("DELETE", "/d")).not.toBeNull();
expect(r.match("HEAD", "/h")).not.toBeNull();
expect(r.match("OPTIONS", "/o")).not.toBeNull();
});
it("rewrites paths under mount(prefix, sub)", { timeout: 5000 }, () => {
const parent = factory.router();
const child = factory.router();
child.get("/invoices", () => null);
parent.mount("/billing", child);
expect(parent.match("GET", "/billing/invoices")).not.toBeNull();
});
it("composes middleware in declared order", {
timeout: 5000,
}, async () => {
const trace: string[] = [];
const a: Middleware = async (_, next) => {
trace.push("a");
const r = await next();
trace.push("a-out");
return r;
};
const b: Middleware = async (_, next) => {
trace.push("b");
const r = await next();
trace.push("b-out");
return r;
};
const composed = composeMiddleware([a, b]);
await composed(makeReq(), async () => {
trace.push("terminal");
return jsonResponse(200, null);
});
expect(trace).toEqual(["a", "b", "terminal", "b-out", "a-out"]);
});
it("rejects same-plugin route collisions", { timeout: 5000 }, () => {
const r = factory.router();
r.get("/x", () => null);
expect(() => r.get("/x", () => null)).toThrow(RouterError);
});
it("parses path parameters", { timeout: 5000 }, () => {
const r = factory.router();
r.get("/users/:id", () => null);
const m = r.match("GET", "/users/u_42");
expect(m?.params).toEqual({ id: "u_42" });
});
it("supports blue-green slot lifecycle", {
timeout: 5000,
}, async () => {
const slot = factory.slot("conf", () => "v1");
slot.setShadow(() => "v2");
const draining = slot.flip();
expect(slot.live.slotId).not.toBe(draining.slotId);
await expect(
slot.dispose(draining.slotId, { timeoutMs: 100 })
).resolves.toBeUndefined();
});
it("flip with no shadow throws SLOT_FLIP_FAILED", {
timeout: 5000,
}, () => {
const slot = factory.slot("conf", () => null);
expect(() => slot.flip()).toThrow(RouterError);
});
it("propagates errors through the middleware chain", {
timeout: 5000,
}, async () => {
const boom: Middleware = async () => {
throw new Error("conformance-boom");
};
const composed = composeMiddleware([boom]);
await expect(
composed(makeReq(), async () => jsonResponse(200, null))
).rejects.toThrow(/conformance-boom/);
});
});
}

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { makeFixtureRequest, makeNoopSpan } from "./conformance.js";
describe("makeNoopSpan", () => {
it("provides callable noop methods", { timeout: 5000 }, () => {
const span = makeNoopSpan();
// Each call must execute without throwing.
span.setAttribute("k", "v");
span.setAttribute("n", 1);
span.setAttribute("b", true);
span.setStatus({ code: 0 });
span.setStatus({ code: 1, message: "ok" });
span.recordException(new Error("boom"));
span.end();
span.end(123);
// sanity — the test runs to here, every method returned undefined
expect(true).toBe(true);
});
});
describe("makeFixtureRequest", () => {
it("returns a frozen-shape RouterRequest with defaults", {
timeout: 5000,
}, () => {
const req = makeFixtureRequest();
expect(req.method).toBe("GET");
expect(req.url).toBe("/x");
expect(req.body).toBeUndefined();
expect(req.principal).toBeNull();
});
it("accepts overrides", { timeout: 5000 }, () => {
const req = makeFixtureRequest({ method: "POST", url: "/y" });
expect(req.method).toBe("POST");
expect(req.url).toBe("/y");
});
});

View File

@@ -0,0 +1,9 @@
export type { PipelineRouteRegistration } from "@suaveplan/api-types/pipeline-router";
export type { RouterConformanceFactory } from "./conformance.js";
export {
makeFixtureRequest,
makeNoopSpan,
runRouterConformance,
} from "./conformance.js";
export { runPipelineRouterConformance } from "./pipeline-router-conformance/pipeline-router-conformance.js";
export type { PipelineRouterConformanceFactory } from "./pipeline-router-conformance/pipeline-router-conformance.types.js";

View File

@@ -0,0 +1,3 @@
export type { PipelineRouteRegistration } from "@suaveplan/api-types/pipeline-router";
export { runPipelineRouterConformance } from "./pipeline-router-conformance.js";
export type { PipelineRouterConformanceFactory } from "./pipeline-router-conformance.types.js";

View File

@@ -0,0 +1,115 @@
# `pipeline-router-conformance` — adapter test harness
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`runPipelineRouterConformance(factory)` is the canonical contract suite
every concrete `PipelineRouter` adapter MUST pass. It is the
complement of `runRouterConformance` from `@suaveplan/api-router/testing`
— that harness covers the abstract `Router` surface; this one covers the
host-pipeline surface that `@suaveplan/api-host`'s `RequestPipeline`
actually consumes at runtime. The harness lives in `@suaveplan/api-router`
because the `PipelineRouter` contract itself lives in
`@suaveplan/api-types/pipeline-router` — the zero-runtime contract
package every adapter and the host orchestration core depend on. The
harness shipping from api-router (rather than from api-host) keeps every
router adapter (Hono, in-memory, Stage-7 engines) free of a build-graph
edge on the orchestration core; api-host re-exports the harness from
`./testing` for back-compat.
## Features
- **Factory-driven** — the harness asks the adapter for a fresh
`(router, register)` pair on every test, so cross-test state can never
leak.
- **Reload support** — an optional `reload()` hook on the factory lets
the harness verify plugin ownership survives a hot-reload cycle.
- **Six required cases** — register → match, 404, statusMap propagation,
plugin ownership across reload, param parsing, collision detection.
- **No runtime peer-dep** — the harness imports only
`@suaveplan/testing/runner` plus the contract's structural types, so
any adapter (Hono, in-memory, worker bridge) can wire it in without
pulling extra Hono / Bun runtime peer.
- **Back-compat aliased** — re-exported from
`@suaveplan/api-host/testing` so existing adapter wiring continues to
work without code changes.
## Usage: Basic
```ts
import { runPipelineRouterConformance } from "@suaveplan/api-router/testing/pipeline-router-conformance";
import { HonoRouter } from "@suaveplan/api-router-hono/adapter";
runPipelineRouterConformance({
label: "HonoRouter pipeline facet",
build: () => {
const r = new HonoRouter();
return {
router: { match: (method, url) => r.matchPipeline(method, url) },
register: (reg) => r.registerPipelineRoute(reg),
};
},
});
```
## Usage: Advanced
```ts
import { runPipelineRouterConformance } from "@suaveplan/api-router/testing/pipeline-router-conformance";
import { HonoRouter } from "@suaveplan/api-router-hono/adapter";
// Verify the reload-cycle path — the harness replays registrations
// against a fresh router and asserts plugin ownership survives.
runPipelineRouterConformance({
label: "HonoRouter pipeline facet w/ reload",
build: () => {
let r = new HonoRouter();
const replay: Array<Parameters<typeof r.registerPipelineRoute>[0]> = [];
return {
router: {
// Bind `match` to the CURRENT `r` so the call goes through
// the post-reload instance every time.
match: (method, url) => r.matchPipeline(method, url),
},
register: (reg) => {
r.registerPipelineRoute(reg);
replay.push(reg);
},
reload: () => {
r = new HonoRouter();
for (const reg of replay) r.registerPipelineRoute(reg);
},
};
},
});
```
## API Reference
- `interface PipelineRouterConformanceFactory` —
- `label: string`
- `build(): { router: PipelineRouter; register: (reg: PipelineRouteRegistration) => void; reload?: () => void }`
- `interface PipelineRouteRegistration` — `{ plugin, method, path, handler, options?, statusMap? }`.
- `function runPipelineRouterConformance(factory): void` — declares one
`describe()` block with six required `it()` cases.
## Implementation Notes
The harness deliberately uses the structural `PipelineRouter` interface
exported from `@suaveplan/api-types/pipeline-router` — adapters do not need to
instance-check, and bag types like a "router with `registerPipelineRoute`"
never appear in the contract. This keeps the harness portable across
radically different adapter shapes (Hono, radix, worker-bridge).
The reload-cycle assertion is gated behind `factory.reload?.()` — adapters
that have no concept of reload (e.g. a purely-static in-memory table)
still pass the suite by omitting the hook. The harness then asserts the
match against the same single-build router, which already covers the
plugin-ownership invariant for static cases.
All `it()` cases declare an explicit `{ timeout: 5000 }` per
testing rules. No `vi.mock`/`jest.mock` or other mock primitives are used
— the harness drives a real adapter through real registrations.

View File

@@ -0,0 +1,142 @@
import type {
PipelineRoute,
PipelineRouteRegistration,
PipelineRouter,
} from "@suaveplan/api-types/pipeline-router";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { runPipelineRouterConformance } from "./pipeline-router-conformance.js";
/**
* Reference in-memory implementation of `PipelineRouter` — exists ONLY
* to self-test `runPipelineRouterConformance`. Production code consumes
* the harness with adapter-supplied factories (the `HonoRouter`
* pipeline facet, `api-router-in-memory`, etc.).
*/
class ReferencePipelineRouter implements PipelineRouter {
private readonly entries: Map<string, PipelineRouteRegistration> =
new Map();
public register(reg: PipelineRouteRegistration): void {
const key = `${reg.method.toUpperCase()}:${shapeKey(reg.path)}`;
const existing = this.entries.get(key);
if (existing !== undefined && existing.plugin !== reg.plugin) {
throw new Error(
`pipeline route ${reg.method} ${reg.path} already registered by "${existing.plugin}"`
);
}
this.entries.set(key, reg);
}
public match(method: string, url: string): PipelineRoute | null {
const m = method.toUpperCase();
const path = new URL(url, "http://h.local").pathname;
const target = tokenise(path);
for (const reg of this.entries.values()) {
if (reg.method.toUpperCase() !== m) continue;
const segments = compile(reg.path);
const params = matchSegments(segments, target);
if (params === null) continue;
return {
plugin: reg.plugin,
method: reg.method.toUpperCase(),
path: reg.path,
options: reg.options ?? {},
handler: reg.handler,
params,
statusMap: reg.statusMap ?? {},
};
}
return null;
}
}
interface Seg {
kind: "literal" | "param";
value: string;
}
function compile(path: string): Seg[] {
if (path === "/" || path === "") return [];
return path
.slice(1)
.split("/")
.map(
(p): Seg =>
p.startsWith(":")
? { kind: "param", value: p.slice(1) }
: { kind: "literal", value: p }
);
}
function tokenise(path: string): string[] {
if (path === "/" || path === "") return [];
return path.slice(1).split("/");
}
function shapeKey(path: string): string {
if (path === "/" || path === "") return "/";
return `/${path
.slice(1)
.split("/")
.map((p) => (p.startsWith(":") ? ":" : p))
.join("/")}`;
}
function matchSegments(
pattern: readonly Seg[],
target: readonly string[]
): Record<string, string> | null {
if (pattern.length !== target.length) return null;
const params: Record<string, string> = {};
for (let i = 0; i < pattern.length; i += 1) {
const seg = pattern[i] as Seg;
const t = target[i] as string;
if (seg.kind === "literal") {
if (seg.value !== t) return null;
} else {
params[seg.value] = decodeURIComponent(t);
}
}
return params;
}
runPipelineRouterConformance({
label: "ReferencePipelineRouter (self-test)",
build: () => {
const r = new ReferencePipelineRouter();
const replay: PipelineRouteRegistration[] = [];
return {
router: r,
register: (reg) => {
r.register(reg);
replay.push(reg);
},
reload: () => {
// The reference impl is reload-stable; replay against
// the same instance with no teardown still satisfies
// the harness's ownership-preservation invariant.
for (const reg of replay) {
try {
r.register(reg);
} catch {
// Same-plugin re-register is a no-op for the
// reference impl (collision-throws same-plugin
// would be a bug — the harness covers that
// separately).
}
}
},
};
},
});
describe("runPipelineRouterConformance — direct surface", () => {
it("factory without reload still satisfies the suite", {
timeout: 5000,
}, () => {
// Smoke-only: the suite already ran above. Assert the harness
// export itself is a function so the registry is non-empty.
expect(typeof runPipelineRouterConformance).toBe("function");
});
});

View File

@@ -0,0 +1,106 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { PipelineRouterConformanceFactory } from "./pipeline-router-conformance.types.js";
/**
* Canonical conformance suite every concrete `PipelineRouter` adapter
* MUST pass. Exercises:
* - register → match round-trip
* - 404 (null) for unmatched routes
* - statusMap propagation through `match()`
* - plugin ownership preserved across a reload cycle
* - param parsing for `:id` segments
* - collision detection (same method+path + different plugin → throw)
*/
export function runPipelineRouterConformance(
factory: PipelineRouterConformanceFactory
): void {
describe(`PipelineRouter conformance — ${factory.label}`, () => {
it("register → match round-trip", { timeout: 5000 }, () => {
const { router, register } = factory.build();
const handler = () => "ok";
register({
plugin: "p",
method: "GET",
path: "/x",
handler,
});
const m = router.match("GET", "http://h.local/x");
expect(m).not.toBeNull();
expect(m?.plugin).toBe("p");
expect(m?.path).toBe("/x");
expect(m?.method).toBe("GET");
expect(m?.handler).toBe(handler);
});
it("returns null for unmatched routes (404 path)", {
timeout: 5000,
}, () => {
const { router } = factory.build();
expect(router.match("GET", "http://h.local/missing")).toBeNull();
});
it("statusMap propagates through match()", {
timeout: 5000,
}, () => {
const { router, register } = factory.build();
register({
plugin: "p",
method: "POST",
path: "/teapot",
handler: () => null,
statusMap: { "P.TEAPOT": 418 },
});
const m = router.match("POST", "http://h.local/teapot");
expect(m?.statusMap).toEqual({ "P.TEAPOT": 418 });
});
it("plugin ownership preserved across reload", {
timeout: 5000,
}, () => {
const built = factory.build();
built.register({
plugin: "billing",
method: "GET",
path: "/invoices/:id",
handler: () => null,
});
built.reload?.();
const m = built.router.match("GET", "http://h.local/invoices/i_42");
expect(m?.plugin).toBe("billing");
expect(m?.params).toEqual({ id: "i_42" });
});
it("parses :param segments", { timeout: 5000 }, () => {
const { router, register } = factory.build();
register({
plugin: "p",
method: "GET",
path: "/u/:id",
handler: () => null,
});
const m = router.match("GET", "http://h.local/u/u_42");
expect(m?.params).toEqual({ id: "u_42" });
});
it("collision: same method+path + different plugin → throw", {
timeout: 5000,
}, () => {
const { register } = factory.build();
register({
plugin: "p",
method: "GET",
path: "/x",
handler: () => null,
});
expect(() =>
register({
plugin: "q",
method: "GET",
path: "/x",
handler: () => null,
})
).toThrow();
});
});
}

View File

@@ -0,0 +1,39 @@
import type {
PipelineRouteRegistration,
PipelineRouter,
} from "@suaveplan/api-types/pipeline-router";
/**
* Description of a pipeline-router implementation under test. Adapters
* (such as `@suaveplan/api-router-hono`'s `HonoRouter` pipeline facet or
* the in-memory adapter shipped from `@suaveplan/api-router-in-memory`)
* supply a factory that produces a fresh `(router, register)` pair on
* every call.
*
* The wire-level contract types (`PipelineRoute`, `PipelineRouteOptions`,
* `PipelineRouteRegistration`, `PipelineRouter`) live in
* `@suaveplan/api-types/pipeline-router` — the zero-runtime contract
* package every adapter and the host orchestration core depend on. The
* harness in this package consumes them as a downstream contract.
*/
export interface PipelineRouterConformanceFactory {
/**
* Build a fresh `(router, register)` pair. `router` is the
* production `PipelineRouter` surface the host's request pipeline
* consumes; `register` is the adapter-specific registration callback
* the harness invokes to populate the table.
*/
readonly build: () => {
readonly router: PipelineRouter;
readonly register: (reg: PipelineRouteRegistration) => void;
/**
* Adapter-specific hot-reload-style reload — the harness calls
* this to verify plugin ownership survives a reload cycle. Most
* adapters implement this by replaying the same registrations
* against the same `router` instance; pass-through is allowed.
*/
readonly reload?: () => void;
};
/** Display label used in test names. */
readonly label: string;
}

View File

@@ -0,0 +1,29 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("dep-graph", () => {
it("pins exact runtime dependency set", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(new URL("../package.json", import.meta.url), "utf8")
) as {
dependencies: Record<string, string>;
};
expect(Object.keys(pkg.dependencies).sort()).toEqual(
[
"@suaveplan/api-types",
"@suaveplan/error",
"@suaveplan/types",
"tslib",
"zod",
].sort()
);
});
it("root barrel has no export-star", { timeout: 5000 }, () => {
const src = readFileSync(
new URL("../src/index.ts", import.meta.url),
"utf8"
);
expect(src.includes("export *")).toBe(false);
});
});

View File

@@ -0,0 +1,7 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("subpath exports", () => {
it("router subpath imports", { timeout: 5000 }, async () => {
const mod = await import("../src/router/index.js");
expect(mod.Router).toBeDefined();
});
it("request subpath imports", { timeout: 5000 }, async () => {
const mod = await import("../src/request/index.js");
expect(mod.MapHeaders).toBeDefined();
});
it("response subpath imports", { timeout: 5000 }, async () => {
const mod = await import("../src/response/index.js");
expect(mod.createResponse).toBeDefined();
expect(mod.ResponseBuilder).toBeDefined();
expect(mod.ok).toBeDefined();
expect(mod.jsonResponse).toBeDefined();
});
it("route-options subpath imports", { timeout: 5000 }, async () => {
const mod = await import("../src/route-options/index.js");
expect(mod.routeOptionsSchema).toBeDefined();
expect(mod.parseRouteOptions).toBeDefined();
expect(mod.safeParseRouteOptions).toBeDefined();
});
it("middleware subpath imports", { timeout: 5000 }, async () => {
const mod = await import("../src/middleware/index.js");
expect(mod.composeMiddleware).toBeDefined();
expect(mod.assertPluginInsertionStep).toBeDefined();
expect(mod.PLUGIN_INSERTION_STEP).toBe("post-validation");
});
it("route-match subpath imports", { timeout: 5000 }, async () => {
const mod = await import("../src/route-match/index.js");
expect(mod.createRouteMatch).toBeDefined();
});
it("slot subpath imports", { timeout: 5000 }, async () => {
const mod = await import("../src/slot/index.js");
expect(mod.InMemoryBlueGreenSlot).toBeDefined();
});
it("error-codes subpath imports", { timeout: 5000 }, async () => {
const mod = await import("../src/error-codes/index.js");
expect(mod.ROUTER_CODES).toBeDefined();
expect(mod.RouterError).toBeDefined();
});
it("testing subpath imports", { timeout: 5000 }, async () => {
const mod = await import("../src/testing/index.js");
expect(mod.runRouterConformance).toBeDefined();
});
});

View File

@@ -0,0 +1,28 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) walk(full, out);
else if (entry.endsWith(".ts")) out.push(full);
}
return out;
}
describe("zod import form", () => {
it("every src file uses default-import z", { timeout: 5000 }, () => {
const srcDir = new URL("../src", import.meta.url).pathname;
const offenders: string[] = [];
for (const file of walk(srcDir)) {
const content = readFileSync(file, "utf8");
// Match `import { z }` or `import { z, ... }` from "zod"
if (/import\s*\{\s*z[\s,}]/.test(content)) {
offenders.push(file);
}
}
expect(offenders).toEqual([]);
});
});

View File

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

View File

@@ -0,0 +1,47 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/api-router",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"testing/index": "src/testing/index.ts",
"testing/pipeline-router-conformance/index":
"src/testing/pipeline-router-conformance/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/api-types",
"@suaveplan/error",
"@suaveplan/types",
"@suaveplan/testing",
"@suaveplan/testing/runner",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,26 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
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/testing/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});