Initial Commit
This commit is contained in:
98
packages/api-stack/api/api-router/README.md
Normal file
98
packages/api-stack/api/api-router/README.md
Normal 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
|
||||
|
||||
122
packages/api-stack/api/api-router/package.json
Normal file
122
packages/api-stack/api/api-router/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { RouterCode } from "./error-codes.js";
|
||||
export { ROUTER_CODES, RouterError } from "./error-codes.js";
|
||||
72
packages/api-stack/api/api-router/src/index.ts
Normal file
72
packages/api-stack/api/api-router/src/index.ts
Normal 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";
|
||||
@@ -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]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
6
packages/api-stack/api/api-router/src/request/index.ts
Normal file
6
packages/api-stack/api/api-router/src/request/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
HttpMethod,
|
||||
RouterRequest,
|
||||
RouterRequestHeaders,
|
||||
} from "./request.js";
|
||||
export { MapHeaders } from "./request.js";
|
||||
88
packages/api-stack/api/api-router/src/request/request.md
Normal file
88
packages/api-stack/api/api-router/src/request/request.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
74
packages/api-stack/api/api-router/src/request/request.ts
Normal file
74
packages/api-stack/api/api-router/src/request/request.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
11
packages/api-stack/api/api-router/src/response/index.ts
Normal file
11
packages/api-stack/api/api-router/src/response/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type {
|
||||
ResponseBuilderInit,
|
||||
RouterResponse,
|
||||
ServerTimingMetric,
|
||||
} from "./response.js";
|
||||
export {
|
||||
createResponse,
|
||||
jsonResponse,
|
||||
ok,
|
||||
ResponseBuilder,
|
||||
} from "./response.js";
|
||||
81
packages/api-stack/api/api-router/src/response/response.md
Normal file
81
packages/api-stack/api/api-router/src/response/response.md
Normal 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()`.
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
128
packages/api-stack/api/api-router/src/response/response.ts
Normal file
128
packages/api-stack/api/api-router/src/response/response.ts
Normal 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();
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { AnyRouteHandler, RouteMatch } from "./route-match.js";
|
||||
export { createRouteMatch } from "./route-match.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type { RouteOptions, RouteOptionsInput } from "./route-options.js";
|
||||
export {
|
||||
isZodSchemaShape,
|
||||
parseRouteOptions,
|
||||
routeOptionsSchema,
|
||||
safeParseRouteOptions,
|
||||
} from "./route-options.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
1
packages/api-stack/api/api-router/src/router/index.ts
Normal file
1
packages/api-stack/api/api-router/src/router/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Router } from "./router.js";
|
||||
110
packages/api-stack/api/api-router/src/router/router.md
Normal file
110
packages/api-stack/api/api-router/src/router/router.md
Normal 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.
|
||||
257
packages/api-stack/api/api-router/src/router/router.test.ts
Normal file
257
packages/api-stack/api/api-router/src/router/router.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
369
packages/api-stack/api/api-router/src/router/router.ts
Normal file
369
packages/api-stack/api/api-router/src/router/router.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
8
packages/api-stack/api/api-router/src/slot/index.ts
Normal file
8
packages/api-stack/api/api-router/src/slot/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
BlueGreenSlot,
|
||||
DrainingHandle,
|
||||
RouterSlot,
|
||||
SlotHandler,
|
||||
SlotPosition,
|
||||
} from "./slot.js";
|
||||
export { InMemoryBlueGreenSlot } from "./slot.js";
|
||||
98
packages/api-stack/api/api-router/src/slot/slot.md
Normal file
98
packages/api-stack/api/api-router/src/slot/slot.md
Normal 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.
|
||||
142
packages/api-stack/api/api-router/src/slot/slot.test.ts
Normal file
142
packages/api-stack/api/api-router/src/slot/slot.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
217
packages/api-stack/api/api-router/src/slot/slot.ts
Normal file
217
packages/api-stack/api/api-router/src/slot/slot.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
91
packages/api-stack/api/api-router/src/testing/conformance.md
Normal file
91
packages/api-stack/api/api-router/src/testing/conformance.md
Normal 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.
|
||||
@@ -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),
|
||||
});
|
||||
178
packages/api-stack/api/api-router/src/testing/conformance.ts
Normal file
178
packages/api-stack/api/api-router/src/testing/conformance.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
9
packages/api-stack/api/api-router/src/testing/index.ts
Normal file
9
packages/api-stack/api/api-router/src/testing/index.ts
Normal 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";
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
29
packages/api-stack/api/api-router/tests/dep-graph.test.ts
Normal file
29
packages/api-stack/api/api-router/tests/dep-graph.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
56
packages/api-stack/api/api-router/tests/subpaths.test.ts
Normal file
56
packages/api-stack/api/api-router/tests/subpaths.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
12
packages/api-stack/api/api-router/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-router/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
47
packages/api-stack/api/api-router/vite.config.ts
Normal file
47
packages/api-stack/api/api-router/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
26
packages/api-stack/api/api-router/vitest.config.ts
Normal file
26
packages/api-stack/api/api-router/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user