first commit
This commit is contained in:
98
docs/api-stack/api/api-router/README.md
Normal file
98
docs/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
|
||||
|
||||
81
docs/api-stack/api/api-router/src/error-codes/error-codes.md
Normal file
81
docs/api-stack/api/api-router/src/error-codes/error-codes.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `error-codes` module is the single source of truth for every failure
|
||||
class this package surfaces. It exports the frozen `ROUTER_CODES` registry
|
||||
(produced by `@suaveplan/error`'s `defineCodes`) plus the typed
|
||||
`RouterError` subclass. Every code in the registry must be thrown from a
|
||||
real production path; the spec-conformance test enumerates the registry and
|
||||
asserts that no code is "decorative".
|
||||
|
||||
Centralising the codes here keeps the wire format stable across versions —
|
||||
adapters and downstream consumers can pattern-match on `error.code` without
|
||||
worrying about typos or namespace drift, and the umbrella API plugin
|
||||
foundation can emit OpenAPI error documentation from a single registry.
|
||||
|
||||
## Features
|
||||
|
||||
- Five frozen codes: `ROUTE_COLLISION`, `INVALID_PREFIX`,
|
||||
`MIDDLEWARE_INSERTION_DENIED`, `SLOT_FLIP_FAILED`, `SLOT_DISPOSE_TIMEOUT`.
|
||||
- Codes are namespaced `ROUTER.<NAME>` via `defineCodes("ROUTER", […])`.
|
||||
- `RouterError` subclass of `SuaveplanError` with `code`, `message`,
|
||||
`cause`, and a `context` slot for call-site metadata.
|
||||
- Type-level export `RouterCode` for exhaustive switches.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ROUTER_CODES, RouterError } from "@suaveplan/api-router/error-codes";
|
||||
|
||||
throw new RouterError(
|
||||
ROUTER_CODES.ROUTE_COLLISION,
|
||||
"GET /users already registered",
|
||||
{ context: { method: "GET", path: "/users", plugin: "auth" } },
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { RouterError, type RouterCode } from "@suaveplan/api-router/error-codes";
|
||||
|
||||
function handle(err: unknown): "retry" | "abort" {
|
||||
if (!(err instanceof RouterError)) return "abort";
|
||||
const code: RouterCode = err.code;
|
||||
switch (code) {
|
||||
case "ROUTER.SLOT_DISPOSE_TIMEOUT":
|
||||
return "retry";
|
||||
case "ROUTER.ROUTE_COLLISION":
|
||||
case "ROUTER.INVALID_PREFIX":
|
||||
case "ROUTER.MIDDLEWARE_INSERTION_DENIED":
|
||||
case "ROUTER.SLOT_FLIP_FAILED":
|
||||
return "abort";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ROUTER_CODES` — frozen object map; keys are short names (`ROUTE_COLLISION`),
|
||||
values are fully-qualified namespaced strings (`"ROUTER.ROUTE_COLLISION"`).
|
||||
- `RouterError` — `extends SuaveplanError<RouterCode>`; constructor accepts
|
||||
`(code, message, options?)` where `options` carries optional `cause` and
|
||||
`context` slots.
|
||||
- `RouterCode` — type-level union of every code value.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is built with `as const` so the inferred `RouterCode` union is
|
||||
exact — adding a code requires touching this file (and the matching
|
||||
production throw site). The runtime cost is one object literal; the
|
||||
registry is tree-shakeable for callers that only need the type.
|
||||
|
||||
`RouterError` keeps the constructor surface minimal: there's no overload
|
||||
explosion, just `(code, message, options?)` mirroring `SuaveplanError`.
|
||||
This is deliberate — adapters wrap their own errors with `cause` rather
|
||||
than re-inventing fields.
|
||||
@@ -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.
|
||||
88
docs/api-stack/api/api-router/src/middleware/middleware.md
Normal file
88
docs/api-stack/api/api-router/src/middleware/middleware.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# `middleware`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `middleware` module defines the onion-model `Middleware` contract and
|
||||
the `composeMiddleware` builder. Every adapter consumes the same shape so
|
||||
middleware authored against the abstract contract drops into every router
|
||||
implementation (Hono, in-memory, the worker-bridge router) unchanged.
|
||||
|
||||
The contract is intentionally narrow: a middleware receives the request
|
||||
and a `next()` continuation, and must return a response. There is no
|
||||
`ctx` mutation handle, no implicit state — middleware threads explicit
|
||||
values via wrapping responses and inspecting the request shape.
|
||||
|
||||
## Features
|
||||
|
||||
- `Middleware<ReqBody, ResBody>` — type generic over the request body and
|
||||
response body shapes so handler-level types flow through.
|
||||
- `composeMiddleware([…])` — builds a single composed middleware from a
|
||||
list; order is preserved.
|
||||
- `next()` enforcement: a middleware that calls `next()` twice in a row
|
||||
has its dispatch rejected. (Multiple sequential `next()` calls across
|
||||
*different* middlewares are correct; only same-middleware repeats fail.)
|
||||
- Synchronous return values are accepted (wrapped in `Promise.resolve`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { composeMiddleware } from "@suaveplan/api-router/middleware";
|
||||
|
||||
const log = composeMiddleware([
|
||||
async (req, next) => {
|
||||
const res = await next();
|
||||
return res; // pass-through with hook points
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { composeMiddleware, type Middleware } from "@suaveplan/api-router/middleware";
|
||||
|
||||
const auth: Middleware = async (req, next) => {
|
||||
if (req.headers.get("authorization") === null) {
|
||||
return { status: 401, headers: {}, body: { code: "UNAUTH" }, streaming: false };
|
||||
}
|
||||
return next();
|
||||
};
|
||||
|
||||
const trace: Middleware = async (req, next) => {
|
||||
req.span.setAttribute("path", req.url);
|
||||
const res = await next();
|
||||
req.span.setAttribute("status", res.status);
|
||||
return res;
|
||||
};
|
||||
|
||||
const stack = composeMiddleware([trace, auth]);
|
||||
// Execution: trace → auth → terminal → auth → trace
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `Middleware<ReqBody, ResBody>` — `(req, next) => RouterResponse | Promise<RouterResponse>`.
|
||||
- `composeMiddleware(list)` — returns a single composed `Middleware`.
|
||||
- `PipelineStep` — literal union of every per-request pipeline step name.
|
||||
- `PLUGIN_INSERTION_STEP` — the constant `"post-validation"`; the single
|
||||
insertion point plugin middleware is allowed.
|
||||
- `assertPluginInsertionStep(step)` — throws `ROUTER.MIDDLEWARE_INSERTION_DENIED`
|
||||
if `step` isn't the allowed boundary.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`composeMiddleware` uses the textbook indexed-dispatch pattern: each call
|
||||
to `next()` advances an internal index, and double-`next()` from the same
|
||||
middleware is detected by comparing the new index to the recorded
|
||||
high-watermark. The detection rejects the inner Promise rather than
|
||||
throwing synchronously so the error propagates through the caller's
|
||||
existing `.catch` / `try/await` machinery.
|
||||
|
||||
Synchronous middlewares are supported — the return value is wrapped in
|
||||
`Promise.resolve` so callers don't have to choose between `async` and
|
||||
plain functions. The terminal continuation is invoked with no arguments
|
||||
and must produce the response; adapters wire this to the handler call.
|
||||
88
docs/api-stack/api/api-router/src/request/request.md
Normal file
88
docs/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.
|
||||
81
docs/api-stack/api/api-router/src/response/response.md
Normal file
81
docs/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()`.
|
||||
74
docs/api-stack/api/api-router/src/route-match/route-match.md
Normal file
74
docs/api-stack/api/api-router/src/route-match/route-match.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# `route-match`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `route-match` module defines `RouteMatch`, the shape every concrete
|
||||
adapter yields after resolving an incoming `(method, url)` pair against the
|
||||
router's registry. The host pipeline threads this value through middleware
|
||||
into the handler — middleware can introspect `options` to react to per-route
|
||||
flags (idempotent, rate-limit) without re-parsing.
|
||||
|
||||
`pluginOfRecord` is carried explicitly so the host can stamp telemetry
|
||||
labels and the rollback journal with the plugin that owns the matched
|
||||
route, even when middleware is mounted across plugin boundaries.
|
||||
|
||||
## Features
|
||||
|
||||
- `RouteMatch<Handler>` — generic over the handler type so adapters can
|
||||
pin a specific call signature (Hono context vs. in-memory vs. worker
|
||||
proxy).
|
||||
- `params` — parsed path-segment captures, frozen.
|
||||
- `options` — the parsed `RouteOptions` block (post-Zod defaults).
|
||||
- `createRouteMatch(init)` — convenience factory that freezes the result.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createRouteMatch } from "@suaveplan/api-router/route-match";
|
||||
import { parseRouteOptions } from "@suaveplan/api-router/route-options";
|
||||
|
||||
const match = createRouteMatch({
|
||||
method: "GET",
|
||||
path: "/users/:id",
|
||||
params: { id: "u_42" },
|
||||
handler: async () => ({ ok: true }),
|
||||
options: parseRouteOptions({ auth: "users.read" }),
|
||||
pluginOfRecord: "auth",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { RouteMatch } from "@suaveplan/api-router/route-match";
|
||||
|
||||
type HonoHandler = (ctx: { req: unknown }) => Promise<Response>;
|
||||
function dispatch(match: RouteMatch<HonoHandler>) {
|
||||
// match.handler signature is narrowed to HonoHandler
|
||||
return match.handler({ req: {} });
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RouteMatch<Handler>` — `{ method, path, params, handler, options, pluginOfRecord }`.
|
||||
- `AnyRouteHandler` — type-erased handler signature (`(...args) => unknown`).
|
||||
- `createRouteMatch(init)` — returns a frozen `RouteMatch`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`Object.freeze` is applied in `createRouteMatch` so middleware that
|
||||
accidentally tries to mutate the match throws in strict mode — this catches
|
||||
a class of bugs where downstream middleware reaches into the upstream
|
||||
match's `params` and mutates path captures. Adapters that construct
|
||||
`RouteMatch` objects directly are expected to apply their own freezing
|
||||
discipline; the helper is the recommended path.
|
||||
|
||||
The handler generic carries through to the consumer so generic dispatch
|
||||
loops can keep type safety. Erasing to `AnyRouteHandler` is permitted at
|
||||
the boundary between adapters (where the host can't know the concrete
|
||||
signature), but per-adapter code should pin the type.
|
||||
@@ -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.
|
||||
110
docs/api-stack/api/api-router/src/router/router.md
Normal file
110
docs/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.
|
||||
98
docs/api-stack/api/api-router/src/slot/slot.md
Normal file
98
docs/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.
|
||||
91
docs/api-stack/api/api-router/src/testing/conformance.md
Normal file
91
docs/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,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.
|
||||
Reference in New Issue
Block a user