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,62 @@
# @suaveplan/api-router-hono — concrete Hono router adapter for the @suaveplan/api plugin system. Implements the abstract Router contract from @suaveplan/api-router on top of a Hono instance, including the HonoBlueGreenSlot atomic-flip primitive and bidirectional Middleware <-> Hono MiddlewareHandler adapters. Passes runRouterConformance.
Stage-4 concrete Hono router adapter for `@suaveplan/api`. Implements the abstract `Router` contract from `@suaveplan/api-router` on top of a Hono `Hono` instance and provides the matching `BlueGreenSlot` implementation for hot-reload.
<!-- begin:auto-docs -->
## Documentation tree
- [adapter](./src/adapter/adapter.md) — `HonoRouter` is The concrete `Router` adapter for the API plugin system. It implements every public method…
- [adapter](./src/adapter/adapter.md) — `HonoRouter` is The concrete `Router` adapter for the API plugin system. It implements every public method…
- [blue-green-slot](./src/blue-green-slot/blue-green-slot.md) — `HonoBlueGreenSlot` is the per-plugin atomic flip primitive backing hot-reload for a Hono-backed `Router`. It holds at…
- [blue-green-slot](./src/blue-green-slot/blue-green-slot.md) — `HonoBlueGreenSlot` is the per-plugin atomic flip primitive backing hot-reload for a Hono-backed `Router`. It holds at…
- [error-codes](./src/error-codes/error-codes.md) — This module defines the canonical, frozen error-code namespace `ROUTER_HONO.*` shared by every production path inside `…
- [error-codes](./src/error-codes/error-codes.md) — This module defines the canonical, frozen error-code namespace `ROUTER_HONO.*` shared by every production path inside `…
- [middleware-adapter](./src/middleware-adapter/middleware-adapter.md) — This module bidirectionally bridges the onion-model `Middleware` type from `@suaveplan/api-router` and Hono's n…
- [middleware-adapter](./src/middleware-adapter/middleware-adapter.md) — This module bidirectionally bridges the onion-model `Middleware` type from `@suaveplan/api-router` and Hono's n…
- [pipeline](./src/pipeline/pipeline.md) — `@suaveplan/api-host`'s request pipeline drives its routing through a `PipelineRouter`-shaped seam — `match(method, url…
- [pipeline](./src/pipeline/pipeline.md) — `@suaveplan/api-host`'s request pipeline drives its routing through a `PipelineRouter`-shaped seam — `match(method, url…
<!-- end:auto-docs -->
## What it ships
- `HonoRouter` / `createHonoRouter()` — concrete `Router` adapter that subclasses the abstract base, mirrors every verb registration onto a private Hono app, and exposes `getHonoApp()` for ops integrations.
- `HonoBlueGreenSlot` — `BlueGreenSlot` impl with atomic flip, in-flight counter, AbortSignal-aware `dispose(timeoutMs)`.
- `toHonoMiddleware` / `fromHonoMiddleware` — bidirectional bridge between the onion-model `Middleware` and Hono's `MiddlewareHandler`.
- `ROUTER_HONO_CODES` / `RouterHonoError` — stable error-code registry (`ROUTER_HONO.HONO_INTERNAL_ERROR`, `ROUTER_HONO.INVALID_HONO_PEER`).
## Conformance
The package passes `runRouterConformance` from `@suaveplan/api-router/testing` — see `tests/conformance.test.ts`. Adding new behaviours requires re-running the harness against the adapter.
## Modules
- [`src/adapter/adapter.md`](./src/adapter/adapter.md) — adapter façade + factory.
- [`src/blue-green-slot/blue-green-slot.md`](./src/blue-green-slot/blue-green-slot.md) — hot-reload primitive.
- [`src/middleware-adapter/middleware-adapter.md`](./src/middleware-adapter/middleware-adapter.md) — middleware bridge.
- [`src/error-codes/error-codes.md`](./src/error-codes/error-codes.md) — error registry.
## Usage
```ts
import { createHonoRouter, HonoBlueGreenSlot } from "@suaveplan/api-router-hono";
const router = createHonoRouter({ plugin: "billing" });
router.get("/invoices/:id", () => null);
const slot = new HonoBlueGreenSlot("billing", () => "v1");
slot.setShadow(() => "v2");
const draining = slot.flip();
await slot.dispose(draining.slotId, { timeoutMs: 1_000 });
```
## Dependencies
- `@suaveplan/api-router` — abstract contract + conformance harness.
- `@suaveplan/api-types` — `OtelSpanLike`, `TenantId`, `Principal`.
- `@suaveplan/error` — `SuaveplanError` + `defineCodes`.
- `hono` (peer ≥4.0.0) — wire-side dispatch.
## Version
`0.1.0` — initial Stage-4 ship. Graduates to `1.0.0` when the plugin system as a whole is declared production-ready.

View File

@@ -0,0 +1,64 @@
# `adapter` — Hono-backed `Router` adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`HonoRouter` is The concrete `Router` adapter for the API plugin system. It implements every public method of the abstract `Router` contract from `@suaveplan/api-router` (all 7 verb methods, `all`, `use`, `mount`, `match`, `collidesWith`, `chain`) and mirrors every route registration onto a private Hono `Hono` instance. The abstract base owns the match table and collision detection; the Hono instance owns the wire-side dispatch when the host wires a real listener via `getHonoApp()`.
The adapter exists so a plugin's `RouteSpec[]` can serve traffic through Hono's fast trie router while still being introspectable by the host's collision detector, blue-green slot manager, and OpenAPI emitter — all of which speak the abstract `Router` surface.
## Features
- Subclasses `Router`, inheriting `match`/`collidesWith`/`mount` verbatim.
- Mirrors registrations onto a private Hono app with a 501 stub handler so the abstract pipeline owns dispatch.
- `getHonoApp()` exposes the underlying Hono instance for ops integrations and direct middleware installation.
- `createHonoRouter()` factory returns the adapter typed as the abstract `Router` so the `runRouterConformance` harness accepts it unchanged.
- Wraps Hono-internal registration throws in `RouterHonoError` (`ROUTER_HONO.HONO_INTERNAL_ERROR`).
## Usage: Basic
```ts
import { createHonoRouter } from "@suaveplan/api-router-hono/adapter";
const router = createHonoRouter({ plugin: "billing" });
router.get("/invoices/:id", () => null);
const match = router.match("GET", "/invoices/inv_42");
```
## Usage: Advanced
```ts
import { HonoRouter } from "@suaveplan/api-router-hono/adapter";
import { Hono } from "hono";
const router = new HonoRouter({ plugin: "tenants" });
router.post("/tenants", { auth: { required: true } }, () => null);
router.mount(
"/admin",
(() => {
const child = new HonoRouter({ plugin: "admin" });
child.get("/healthz", () => null);
return child;
})()
);
const app: Hono = router.getHonoApp();
app.use("*", async (c, next) => {
c.header("x-app", "suaveplan");
await next();
});
```
## API Reference
- `class HonoRouter extends Router` — public constructor `new HonoRouter({ plugin? })`.
- `router.getHonoApp(): Hono` — accessor.
- `createHonoRouter(options?): Router` — factory returning the conformance-friendly `Router` view.
- Throws `RouterHonoError` with `ROUTER_HONO.HONO_INTERNAL_ERROR` on a Hono-side registration failure.
## Implementation Notes
`mirror()` normalises trailing slashes and dispatches to the appropriate Hono verb method. `HEAD` is registered via `Hono.on("HEAD", path, ...)` because the runtime Hono surface omits a dedicated `.head()` shorthand. The mirror stub returns a 501 because in-process dispatch goes through the abstract `Router.match()` path — the Hono app is wired only when the host attaches a `fetch` adapter. The match table from the abstract base is the single source of truth for the per-request pipeline (design doc §6).

View File

@@ -0,0 +1,63 @@
# `blue-green-slot` — Hono-flavoured atomic hot-reload primitive
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`HonoBlueGreenSlot` is the per-plugin atomic flip primitive backing hot-reload for a Hono-backed `Router`. It holds at most three positions — `live`, `shadow`, and a map of `draining` slots — and lets the host swap a plugin's route table in O(1) by replacing a single mutable reference. The pending-request counter tracks in-flight work on draining slots so the host can wait for traffic to bleed off before the old module is GC'd.
Two invariants govern correctness: every `flip()` produces a `DrainingHandle` whose `settled` promise resolves once `release()` has matched every `acquire()`, and `dispose(slotId, { timeoutMs })` races a timer against that settle — on timeout it aborts the in-flight requests via the slot's `AbortController`.
## Features
- O(1) atomic flip via a single mutable `liveState` reference.
- Pending-request counter (`acquire`/`release`) with FIFO drain-waiters.
- `signalFor(slotId)` exposes the per-slot `AbortSignal` for streaming handlers.
- Slot IDs derived deterministically from `<plugin>.v<version>` for readable logs.
- Throws `ROUTER.SLOT_FLIP_FAILED` when no shadow is set.
- Throws `ROUTER.SLOT_DISPOSE_TIMEOUT` after aborting on timeout.
## Usage: Basic
```ts
import { HonoBlueGreenSlot } from "@suaveplan/api-router-hono/blue-green-slot";
const slot = new HonoBlueGreenSlot("billing", () => "v1");
slot.setShadow(() => "v2");
const draining = slot.flip();
await slot.dispose(draining.slotId, { timeoutMs: 1_000 });
```
## Usage: Advanced
```ts
import { HonoBlueGreenSlot } from "@suaveplan/api-router-hono/blue-green-slot";
const slot = new HonoBlueGreenSlot("invoices", () => "v1");
slot.acquire(slot.live.slotId); // long-running request begins
slot.setShadow(() => "v2");
const draining = slot.flip();
const disposal = slot.dispose(draining.slotId, { timeoutMs: 5_000 });
// later, when the long-running request finishes:
slot.release(draining.slotId);
await disposal;
```
## API Reference
- `new HonoBlueGreenSlot(plugin, handler)`.
- `slot.live: RouterSlot`.
- `slot.shadow: RouterSlot | null`.
- `slot.setShadow(handler)`.
- `slot.flip(): DrainingHandle`.
- `slot.dispose(slotId, { timeoutMs }): Promise<void>`.
- `slot.acquire(slotId)`, `slot.release(slotId)`.
- `slot.signalFor(slotId): AbortSignal | null`.
## Implementation Notes
The pending counter clamps at zero so over-release is a no-op rather than a thrown error — the host is the single owner of the counter and tests that double-release should fail at the host layer, not silently here. `signalFor` is layered on top of the `AbortController` per draining slot so handlers can wire `req.signal.addEventListener("abort", ...)` to terminate streaming responses immediately after a timed-out dispose.

View File

@@ -0,0 +1,52 @@
# `error-codes` — stable Router-Hono error registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module defines the canonical, frozen error-code namespace `ROUTER_HONO.*` shared by every production path inside `@suaveplan/api-router-hono`. Stable codes are how downstream observers (`@suaveplan/api-host`, structured-log consumers, OpenTelemetry dashboards) discriminate adapter failures from generic application errors without resorting to brittle string matching on `error.message`.
The registry is intentionally narrow — Hono itself owns most failure modes (404, 405, body parse, etc.) and surfaces them as ordinary HTTP responses. The two `ROUTER_HONO.*` codes carved out here cover the two scenarios where the adapter cannot delegate cleanly to Hono: a Hono-internal throw bubbling through the bridge, and a programmer-supplied peer (`getHonoApp()` consumers handing back a non-Hono shim).
## Features
- `ROUTER_HONO.HONO_INTERNAL_ERROR` — wraps any unexpected throw originating inside the Hono request/response bridge.
- `ROUTER_HONO.INVALID_HONO_PEER` — guard for the `getHonoApp()` accessor and middleware-adapter when a non-Hono value is supplied.
- `RouterHonoError` extends `SuaveplanError<RouterHonoCode>` so cause chains and Zod-validated wire format flow through `@suaveplan/error` unchanged.
- `defineCodes` produces a frozen, namespaced map — duplicate codes fail at module load.
## Usage: Basic
```ts
import { ROUTER_HONO_CODES, RouterHonoError } from "@suaveplan/api-router-hono/error-codes";
throw new RouterHonoError(
ROUTER_HONO_CODES.HONO_INTERNAL_ERROR,
"hono raised during dispatch",
{ cause: caught },
);
```
## Usage: Advanced
```ts
import { RouterHonoError } from "@suaveplan/api-router-hono/error-codes";
import type { RouterHonoCode } from "@suaveplan/api-router-hono/error-codes";
function handle(err: unknown): RouterHonoCode | null {
if (err instanceof RouterHonoError) return err.code;
return null;
}
```
## API Reference
- `ROUTER_HONO_CODES` — `Readonly<Record<"HONO_INTERNAL_ERROR" | "INVALID_HONO_PEER", "ROUTER_HONO.HONO_INTERNAL_ERROR" | "ROUTER_HONO.INVALID_HONO_PEER">>`.
- `RouterHonoCode` — string-literal union of the resolved codes.
- `RouterHonoError` — `SuaveplanError<RouterHonoCode>` subclass with `code`, `message`, optional `cause`, optional `context`.
## Implementation Notes
`defineCodes` enforces alphabetisation and uniqueness at build time. Adding a code requires extending the tuple here AND wiring at least one production throw site (see `adapter.ts` and `middleware-adapter.ts`). The registry is consumed by the `error-codes.test.ts` enumeration which asserts every entry maps to a value of the form `ROUTER_HONO.<NAME>` so observers can pattern-match the namespace prefix.

View File

@@ -0,0 +1,65 @@
# `middleware-adapter` — Middleware ⇄ Hono MiddlewareHandler bridge
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module bidirectionally bridges the onion-model `Middleware` type from `@suaveplan/api-router` and Hono's native `MiddlewareHandler`. The bridge is what lets a `Router` adapter installed on top of a Hono app participate in the same per-request pipeline — inbound headers become `RouterRequest`, outbound builder results become Web `Response` objects, and downstream Hono middlewares observe the `next()` boundary as their own.
The conversion is intentionally one-directional in effect: `toHonoMiddleware` runs the suaveplan middleware ahead of Hono's downstream chain, while `fromHonoMiddleware` defers strictly to the `next()` because Hono context cannot be safely synthesised outside a real dispatch. Real Hono middlewares get installed via `getHonoApp().use(...)` directly.
## Features
- `toHonoMiddleware(mw)` — adapts a `Middleware` to a `MiddlewareHandler` Hono can install.
- `fromHonoMiddleware(handler)` — produces a `Middleware` that defers to the chain; throws `ROUTER_HONO.INVALID_HONO_PEER` for non-functions.
- Header/query reflection from `c.req.raw` to `MapHeaders` so handlers run against the canonical suaveplan
- Wraps Hono-internal throws in `RouterHonoError` with code `HONO_INTERNAL_ERROR` and a cause-chain.
## Usage: Basic
```ts
import { toHonoMiddleware } from "@suaveplan/api-router-hono/middleware-adapter";
import type { Middleware } from "@suaveplan/api-router";
import { Hono } from "hono";
const trace: Middleware = async (req, next) => {
console.log(req.requestId);
return next();
};
const app = new Hono();
app.use("*", toHonoMiddleware(trace));
```
## Usage: Advanced
```ts
import {
fromHonoMiddleware,
toHonoMiddleware,
} from "@suaveplan/api-router-hono/middleware-adapter";
import { createHonoRouter } from "@suaveplan/api-router-hono/adapter";
const router = createHonoRouter();
router.use(async (req, next) => {
const res = await next();
return { ...res, headers: { ...res.headers, "x-trace": req.requestId } };
});
const app = router.getHonoApp();
app.use("*", toHonoMiddleware(async (_req, next) => next()));
```
## API Reference
- `toHonoMiddleware(mw: Middleware): MiddlewareHandler` — wraps a suaveplan middleware.
- `fromHonoMiddleware(handler: MiddlewareHandler): Middleware` — wraps a Hono middleware.
Both helpers preserve the `RouterError` / `RouterHonoError` cause chains so observability stays end-to-end.
## Implementation Notes
`contextToRouterRequest` reads `x-tenant-id` and `x-request-id` headers (case-insensitive) to populate the corresponding fields. Both default to safe stand-ins (`"default"` tenant, time-derived request id) so unit tests bypass middleware setup. `routerResponseToWebResponse` JSON-stringifies non-string bodies and sets `content-type: application/json` when no body content-type is already present.

View File

@@ -0,0 +1,130 @@
# `pipeline` — `HonoRouter`'s `PipelineRouter` facet
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/api-host`'s request pipeline drives its routing through a
`PipelineRouter`-shaped seam — `match(method, url) → PipelineRoute | null` —
where `PipelineRoute` carries `plugin`, `path`, `options`, `handler`,
`params`, `statusMap`. That shape is **NOT** the same as
`@suaveplan/api-router`'s abstract `Router.match(...)` (which returns a
`RouteMatch`), so the original `HonoRouter` adapter did not satisfy the host
contract. Foundation-coherence Gap #1 closes that mismatch by adding a
**second facet** to `HonoRouter` — `registerPipelineRoute(...)` +
`matchPipeline(...)` — backed by a parallel metadata table that lives next
to the existing abstract-router table.
The facet is additive and structural. The host depends on
`@suaveplan/api-router-hono` not at all; the wiring is purely shape-driven —
`HonoRouter.matchPipeline()` returns a value assignable to
`@suaveplan/api-types/pipeline-router`'s `PipelineRoute` interface because every required
field is present at the same path-type.
## Features
- **Parallel metadata table** — `PipelineRouteTable` keyed by
`${method}:${normalisedPath}`. Distinct from the abstract router's
`routes[]`, so the two facets evolve independently.
- **Plugin ownership** — every registration carries a `plugin` field; the
host pipeline reads it directly off the matched route.
- **Status map propagation** — each route stores an optional
`Record<string, number>` (the manifest's plugin-error → HTTP status
overrides) and the host reads it on failure-envelope construction.
- **Collision detection** — registering the same `(method, path-shape)`
under a DIFFERENT plugin throws
`ROUTER_HONO.PIPELINE_ROUTE_COLLISION`. Re-registering by the same
plugin overwrites (the host reload-driver hot-reload flow).
- **Param parsing** — `:id` segments are decoded via
`decodeURIComponent` and surfaced in the returned route's `params`.
- **Hot-reload teardown** — `unregisterPipelinePlugin(plugin)` removes
every entry owned by `plugin` in a single call; used by the host
reload driver between versions.
- **Snapshot** — `listPipelineRoutes()` returns a readonly view for admin
tooling.
## Usage: Basic
```ts
import { HonoRouter } from "@suaveplan/api-router-hono/adapter";
const r = new HonoRouter();
r.registerPipelineRoute({
plugin: "billing",
method: "GET",
path: "/invoices/:id",
handler: async (ctx) => ({ id: (ctx as { params: { id: string } }).params.id }),
statusMap: { "BILLING.NOT_FOUND": 404 },
});
const match = r.matchPipeline("GET", "/invoices/inv_42");
// → { plugin: "billing", path: "/invoices/:id", params: { id: "inv_42" }, ... }
```
## Usage: Advanced
```ts
import { HonoRouter, PipelineRouteTable } from "@suaveplan/api-router-hono";
// Pre-build a metadata table outside the router (e.g. inside a host
// reload driver) and replay it into a fresh router on flip.
const table = new PipelineRouteTable();
table.register({
plugin: "p",
method: "POST",
path: "/x",
handler: () => null,
options: { auditable: true, idempotent: true },
});
const r = new HonoRouter();
for (const route of table.list()) {
r.registerPipelineRoute(route);
}
// Hot-reload: tear down the prior version of `billing` then load the new
// route set.
r.unregisterPipelinePlugin("billing");
r.registerPipelineRoute({
plugin: "billing",
method: "GET",
path: "/invoices/:id",
handler: newHandler,
});
```
## API Reference
- `class PipelineRouteTable` —
- `register(reg: HonoPipelineRegistration): void`
- `unregister(method: string, path: string): void`
- `unregisterPlugin(plugin: string): void`
- `list(): readonly HonoPipelineRoute[]`
- `match(method: string, url: string): HonoPipelineRoute | null`
- `size(): number`
- `interface HonoPipelineRoute` — `{ plugin, method, path, options, handler, params, statusMap }`. Structurally compatible with `@suaveplan/api-types/pipeline-router`'s `PipelineRoute`.
- `interface HonoPipelineRegistration` — `{ plugin, method, path, handler, options?, statusMap? }`.
- `interface HonoPipelineRouteOptions` — narrow subset of the host's `PipelineRouteOptions`: `auth`, `csrf`, `streaming`, `idempotent`, `runtimeValidation`, `auditable`, `rateLimit`, `request`, `responses`.
- `interface HonoPipelineHandler` — `(ctx: unknown) => Promise<unknown> | unknown`. `ctx` is the host's `RequestContext` at runtime; the adapter does not enforce the type to avoid an inverted dep on `@suaveplan/api-types`.
## Implementation Notes
The table stores a `Map<shapeKey, StoredEntry>` where `shapeKey` is
`${method}:${path-with-:-erased}` — `/users/:id` and `/users/:other` map
to the same key, mirroring `@suaveplan/api-router`'s collision-detection
semantics. Path normalisation strips trailing slashes and tolerates both
bare paths and full URLs in `match()`.
Param decoding uses `decodeURIComponent`. Non-URL-shaped inputs to
`match()` fall back to a manual `?`-split so the implementation handles
the host pipeline's pass-through of `request.url` (always a full URL) and
the test harness's path-only calls (`"/x"`).
The facet is intentionally decoupled from Hono's HTTP dispatch path. Hono
still owns request handling for callers that invoke `app.fetch(req)`
directly (ops integrations, raw-Hono middleware). The pipeline facet is a
metadata sidecar — the host's `RequestPipeline.handle(...)` consults the
sidecar before invoking the matched handler with its own constructed
`RequestContext`.