first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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