Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,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,96 @@
{
"name": "@suaveplan/api-router-hono",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Stage-4 concrete Hono router adapter for the @suaveplan/api plugin foundation. 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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./adapter": {
"import": {
"types": "./dist/adapter/index.d.ts",
"default": "./dist/adapter/index.js"
}
},
"./pipeline": {
"import": {
"types": "./dist/pipeline/index.d.ts",
"default": "./dist/pipeline/index.js"
}
},
"./blue-green-slot": {
"import": {
"types": "./dist/blue-green-slot/index.d.ts",
"default": "./dist/blue-green-slot/index.js"
}
},
"./middleware-adapter": {
"import": {
"types": "./dist/middleware-adapter/index.d.ts",
"default": "./dist/middleware-adapter/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/api-router": "workspace:^",
"@suaveplan/api-types": "workspace:^",
"@suaveplan/error": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"hono": "catalog:",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"peerDependencies": {
"hono": "^4.0.0"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

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,179 @@
import { RouterError } from "@suaveplan/api-router";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import { RouterHonoError } from "../error-codes/error-codes.js";
import { createHonoRouter, HonoRouter } from "./adapter.js";
describe("HonoRouter adapter", () => {
it("createHonoRouter returns a HonoRouter instance", {
timeout: 5000,
}, () => {
const r = createHonoRouter();
expect(r).toBeInstanceOf(HonoRouter);
});
it("registers every verb on both abstract router and Hono", {
timeout: 5000,
}, () => {
const r = new HonoRouter();
r.get("/g", () => null);
r.post("/p", () => null);
r.put("/u", () => null);
r.patch("/pa", () => null);
r.delete("/d", () => null);
r.head("/h", () => null);
r.options("/o", () => null);
expect(r.match("GET", "/g")).not.toBeNull();
expect(r.match("POST", "/p")).not.toBeNull();
expect(r.match("PUT", "/u")).not.toBeNull();
expect(r.match("PATCH", "/pa")).not.toBeNull();
expect(r.match("DELETE", "/d")).not.toBeNull();
expect(r.match("HEAD", "/h")).not.toBeNull();
expect(r.match("OPTIONS", "/o")).not.toBeNull();
expect(r.getHonoApp()).toBeInstanceOf(Hono);
});
it("`all` registers across every method", { timeout: 5000 }, () => {
const r = new HonoRouter();
r.all("/multi", () => null);
expect(r.match("GET", "/multi")).not.toBeNull();
expect(r.match("DELETE", "/multi")).not.toBeNull();
expect(r.match("OPTIONS", "/multi")).not.toBeNull();
expect(r.match("HEAD", "/multi")).not.toBeNull();
});
it("verb registration with options binds options object", {
timeout: 5000,
}, () => {
const r = new HonoRouter();
r.get("/secured", { auth: "required", summary: "x" }, () => null);
const m = r.match("GET", "/secured");
expect(m?.options.auth).toBe("required");
expect(m?.options.summary).toBe("x");
});
it("collisions throw RouterError", { timeout: 5000 }, () => {
const r = new HonoRouter();
r.get("/same", () => null);
expect(() => r.get("/same", () => null)).toThrow(RouterError);
});
it("mount rewrites paths", { timeout: 5000 }, () => {
const parent = new HonoRouter();
const child = new HonoRouter();
child.get("/leaf", () => null);
parent.mount("/branch", child);
expect(parent.match("GET", "/branch/leaf")).not.toBeNull();
});
it("parses path params", { timeout: 5000 }, () => {
const r = new HonoRouter();
r.get("/u/:id", () => null);
expect(r.match("GET", "/u/abc")?.params).toEqual({ id: "abc" });
});
it("collidesWith reports same-method same-shape routes", {
timeout: 5000,
}, () => {
const r = new HonoRouter();
r.get("/users/:id", () => null);
const conflicts = r.collidesWith("GET", "/users/:other");
expect(conflicts).toHaveLength(1);
});
it("trailing-slash root path mirrors as '/'", { timeout: 5000 }, () => {
const r = new HonoRouter();
r.get("/", () => null);
expect(r.match("GET", "/")).not.toBeNull();
});
it("wraps Hono registration errors in RouterHonoError", {
timeout: 5000,
}, () => {
const r = new HonoRouter();
const app = r.getHonoApp() as unknown as Record<string, unknown>;
const verbs = [
"get",
"post",
"put",
"patch",
"delete",
"options",
"on",
];
for (const v of verbs) {
app[v] = () => {
throw new Error(`hono ${v} kaboom`);
};
}
expect(() => r.get("/x", () => null)).toThrow(RouterHonoError);
expect(() => r.post("/x", () => null)).toThrow(RouterHonoError);
expect(() => r.put("/x", () => null)).toThrow(RouterHonoError);
expect(() => r.patch("/x", () => null)).toThrow(RouterHonoError);
expect(() => r.delete("/x", () => null)).toThrow(RouterHonoError);
expect(() => r.options("/x", () => null)).toThrow(RouterHonoError);
expect(() => r.head("/x", () => null)).toThrow(RouterHonoError);
});
it("Hono mirror stub returns 501 when dispatched", {
timeout: 5000,
}, async () => {
const r = new HonoRouter();
r.get("/g", () => null);
const app = r.getHonoApp();
const res = await app.request("http://localhost/g");
expect(res.status).toBe(501);
expect(res.headers.get("x-router")).toBe("stub");
});
it("pipeline facet — register/match/list/unregister/unregisterPlugin", {
timeout: 5000,
}, () => {
const r = new HonoRouter();
r.registerPipelineRoute({
plugin: "a",
method: "GET",
path: "/a1",
handler: () => null,
});
r.registerPipelineRoute({
plugin: "a",
method: "POST",
path: "/a2",
handler: () => null,
});
r.registerPipelineRoute({
plugin: "b",
method: "GET",
path: "/b1",
handler: () => null,
});
expect(r.matchPipeline("GET", "/a1")).not.toBeNull();
expect(r.listPipelineRoutes()).toHaveLength(3);
r.unregisterPipelineRoute("GET", "/a1");
expect(r.matchPipeline("GET", "/a1")).toBeNull();
expect(r.listPipelineRoutes()).toHaveLength(2);
r.unregisterPipelinePlugin("a");
expect(r.matchPipeline("POST", "/a2")).toBeNull();
expect(r.matchPipeline("GET", "/b1")).not.toBeNull();
expect(r.listPipelineRoutes()).toHaveLength(1);
});
it("wraps non-Error throw with no cause", { timeout: 5000 }, () => {
const r = new HonoRouter();
const app = r.getHonoApp() as unknown as { get: () => void };
app.get = () => {
throw "string-thrown";
};
try {
r.get("/y", () => null);
expect(false).toBe(true);
} catch (err) {
expect(err).toBeInstanceOf(RouterHonoError);
expect((err as RouterHonoError).cause).toBeUndefined();
}
});
});

View File

@@ -0,0 +1,216 @@
import {
type AnyRouteHandler,
type HttpMethod,
type RouteOptionsInput,
Router,
} from "@suaveplan/api-router";
import { Hono } from "hono";
import {
ROUTER_HONO_CODES,
RouterHonoError,
} from "../error-codes/error-codes.js";
import { PipelineRouteTable } from "../pipeline/pipeline.js";
import type {
HonoPipelineRegistration,
HonoPipelineRoute,
} from "../pipeline/pipeline.types.js";
type HandlerOrOptions<H extends AnyRouteHandler> = H | RouteOptionsInput;
function honoPath(path: string): string {
if (path === "" || path === "/") return "/";
return path.replace(/\/+$/, "");
}
/**
* Concrete Hono-backed `Router`.
*
* Extends the abstract `Router` so all `match` / `collidesWith` / `mount`
* semantics are inherited verbatim, while every verb registration is
* mirrored onto a private Hono `Hono` instance. Consumers needing direct
* access to the Hono app (ops integrations, native middleware install)
* call `getHonoApp()`.
*
* `use(middleware)` registers the middleware on both the abstract chain
* (visible to `chain()`) AND on the underlying Hono app via
* `toHonoMiddleware`, so the same middleware participates in both the
* onion-model dispatch and any direct Hono `fetch(req)` call.
*/
export class HonoRouter extends Router {
private readonly honoApp: Hono;
private readonly pipelineTable: PipelineRouteTable;
public constructor(options: { plugin?: string } = {}) {
super(options);
this.honoApp = new Hono();
this.pipelineTable = new PipelineRouteTable();
}
/** Direct access to the underlying Hono app for ops integrations. */
public getHonoApp(): Hono {
return this.honoApp;
}
/**
* Register a route on the pipeline facet — the metadata sidecar
* consumed by `@suaveplan/api-types/pipeline-router`'s `PipelineRouter`. This call is
* additive to the abstract `Router` verb methods; the host's request
* pipeline drives `matchPipeline(...)` directly while Hono's `app`
* still owns HTTP dispatch for ops integrations.
*
* Throws `ROUTER_HONO.PIPELINE_ROUTE_COLLISION` when the same
* `(method, path-shape)` is already registered by a different plugin.
*/
public registerPipelineRoute(reg: HonoPipelineRegistration): void {
this.pipelineTable.register(reg);
}
/** Remove a single pipeline-facet entry — no-op when absent. */
public unregisterPipelineRoute(method: string, path: string): void {
this.pipelineTable.unregister(method, path);
}
/**
* Remove every pipeline-facet entry owned by `plugin`. Used by the
* host's reload driver during a hot-reload teardown.
*/
public unregisterPipelinePlugin(plugin: string): void {
this.pipelineTable.unregisterPlugin(plugin);
}
/** Snapshot of every pipeline-facet entry — used by admin tools. */
public listPipelineRoutes(): readonly HonoPipelineRoute[] {
return this.pipelineTable.list();
}
/**
* Resolve `(method, url)` on the pipeline facet. Returns a route
* structurally compatible with `@suaveplan/api-types/pipeline-router`'s `PipelineRoute`
* — `plugin`, `path`, `options`, `handler`, `params`, `statusMap` all
* carry the host-pipeline-required fields. Returns `null` on miss.
*/
public matchPipeline(
method: string,
url: string
): HonoPipelineRoute | null {
return this.pipelineTable.match(method, url);
}
public override get<H extends AnyRouteHandler>(
path: string,
a: HandlerOrOptions<H>,
b?: H
): void {
super.get(path, a as never, b as never);
this.mirror("GET", path);
}
public override post<H extends AnyRouteHandler>(
path: string,
a: HandlerOrOptions<H>,
b?: H
): void {
super.post(path, a as never, b as never);
this.mirror("POST", path);
}
public override put<H extends AnyRouteHandler>(
path: string,
a: HandlerOrOptions<H>,
b?: H
): void {
super.put(path, a as never, b as never);
this.mirror("PUT", path);
}
public override patch<H extends AnyRouteHandler>(
path: string,
a: HandlerOrOptions<H>,
b?: H
): void {
super.patch(path, a as never, b as never);
this.mirror("PATCH", path);
}
public override delete<H extends AnyRouteHandler>(
path: string,
a: HandlerOrOptions<H>,
b?: H
): void {
super.delete(path, a as never, b as never);
this.mirror("DELETE", path);
}
public override head<H extends AnyRouteHandler>(
path: string,
a: HandlerOrOptions<H>,
b?: H
): void {
super.head(path, a as never, b as never);
this.mirror("HEAD", path);
}
public override options<H extends AnyRouteHandler>(
path: string,
a: HandlerOrOptions<H>,
b?: H
): void {
super.options(path, a as never, b as never);
this.mirror("OPTIONS", path);
}
private mirror(method: HttpMethod, path: string): void {
const hp = honoPath(path);
const stub = (): Response =>
new Response(null, {
status: 501,
headers: { "x-router": "stub" },
});
try {
switch (method) {
case "GET":
this.honoApp.get(hp, stub);
break;
case "POST":
this.honoApp.post(hp, stub);
break;
case "PUT":
this.honoApp.put(hp, stub);
break;
case "PATCH":
this.honoApp.patch(hp, stub);
break;
case "DELETE":
this.honoApp.delete(hp, stub);
break;
case "OPTIONS":
this.honoApp.options(hp, stub);
break;
case "HEAD":
// Hono doesn't expose `.head` directly — install via on().
this.honoApp.on("HEAD", hp, stub);
break;
}
} catch (cause) {
const opts: { cause?: Error; context: Record<string, unknown> } = {
context: { method, path: hp },
};
if (cause instanceof Error) opts.cause = cause;
throw new RouterHonoError(
ROUTER_HONO_CODES.HONO_INTERNAL_ERROR,
`Hono threw while registering ${method} ${hp}`,
opts
);
}
}
}
/**
* Factory — returns a fresh `HonoRouter` typed as the abstract `Router` so
* conformance harnesses accept it unchanged. Cast back to `HonoRouter` to
* access `getHonoApp()`.
*/
export function createHonoRouter(options: { plugin?: string } = {}): Router {
return new HonoRouter(options);
}

View File

@@ -0,0 +1 @@
export { createHonoRouter, HonoRouter } from "./adapter.js";

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,125 @@
import { RouterError } from "@suaveplan/api-router";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { HonoBlueGreenSlot } from "./blue-green-slot.js";
describe("HonoBlueGreenSlot", () => {
it("exposes a live slot from construction", { timeout: 5000 }, () => {
const s = new HonoBlueGreenSlot("p", () => "v1");
expect(s.live.version).toBe(1);
expect(s.live.slotId).toBe("p.v1");
expect(s.shadow).toBeNull();
});
it("setShadow stages a new handler", { timeout: 5000 }, () => {
const s = new HonoBlueGreenSlot("p", () => "v1");
s.setShadow(() => "v2");
expect(s.shadow?.version).toBe(2);
});
it("flip with no shadow throws SLOT_FLIP_FAILED", { timeout: 5000 }, () => {
const s = new HonoBlueGreenSlot("p", () => "v1");
expect(() => s.flip()).toThrow(RouterError);
});
it("flip promotes shadow and returns draining handle", {
timeout: 5000,
}, async () => {
const s = new HonoBlueGreenSlot("p", () => "v1");
s.setShadow(() => "v2");
const previous = s.live.slotId;
const draining = s.flip();
expect(draining.slotId).toBe(previous);
expect(s.live.slotId).not.toBe(previous);
await expect(
s.dispose(draining.slotId, { timeoutMs: 50 })
).resolves.toBeUndefined();
});
it("dispose resolves when pending zero", { timeout: 5000 }, async () => {
const s = new HonoBlueGreenSlot("p", () => "v1");
s.setShadow(() => "v2");
const draining = s.flip();
await s.dispose(draining.slotId, { timeoutMs: 100 });
// second dispose is a no-op
await s.dispose(draining.slotId, { timeoutMs: 100 });
});
it("dispose waits for release", { timeout: 5000 }, async () => {
const s = new HonoBlueGreenSlot("p", () => "v1");
s.setShadow(() => "v2");
const liveId = s.live.slotId;
s.acquire(liveId);
const draining = s.flip();
// counter moved over with the slot — release on draining ID.
setTimeout(() => s.release(draining.slotId), 10);
await s.dispose(draining.slotId, { timeoutMs: 1000 });
});
it("dispose times out and aborts pending", {
timeout: 5000,
}, async () => {
const s = new HonoBlueGreenSlot("p", () => "v1");
s.setShadow(() => "v2");
const liveId = s.live.slotId;
s.acquire(liveId);
const draining = s.flip();
await expect(
s.dispose(draining.slotId, { timeoutMs: 20 })
).rejects.toThrow(RouterError);
const sig = s.signalFor(draining.slotId);
expect(sig?.aborted).toBe(true);
});
it("abort() on handle signals the underlying controller", {
timeout: 5000,
}, () => {
const s = new HonoBlueGreenSlot("p", () => "v1");
s.setShadow(() => "v2");
const draining = s.flip();
draining.abort();
expect(s.signalFor(draining.slotId)?.aborted).toBe(true);
});
it("acquire/release on unknown slot is a no-op", {
timeout: 5000,
}, () => {
const s = new HonoBlueGreenSlot("p", () => "v1");
s.acquire("bogus");
s.release("bogus");
expect(s.signalFor("bogus")).toBeNull();
});
it("over-release clamps at zero and fires waiters once", {
timeout: 5000,
}, async () => {
const s = new HonoBlueGreenSlot("p", () => "v1");
s.setShadow(() => "v2");
const liveId = s.live.slotId;
s.acquire(liveId);
const draining = s.flip();
s.release(draining.slotId);
s.release(draining.slotId); // over-release
await s.dispose(draining.slotId, { timeoutMs: 100 });
});
it("release with pending > 1 keeps counter positive", {
timeout: 5000,
}, () => {
const s = new HonoBlueGreenSlot("p", () => "v1");
const liveId = s.live.slotId;
s.acquire(liveId);
s.acquire(liveId);
s.release(liveId);
// After one release, pending should still be 1 — visible only via
// the contract: signalFor should still return a live signal.
expect(s.signalFor(liveId)?.aborted).toBe(false);
});
it("signalFor returns live signal", { timeout: 5000 }, () => {
const s = new HonoBlueGreenSlot("p", () => "v1");
const sig = s.signalFor(s.live.slotId);
expect(sig).not.toBeNull();
expect(sig?.aborted).toBe(false);
});
});

View File

@@ -0,0 +1,171 @@
import {
type BlueGreenSlot,
type DrainingHandle,
ROUTER_CODES,
RouterError,
type RouterSlot,
type SlotHandler,
} from "@suaveplan/api-router";
interface SlotState {
slot: RouterSlot;
pending: number;
waiters: Array<() => void>;
abortController: AbortController;
}
/**
* Hono-flavoured `BlueGreenSlot` implementation.
*
* Mirrors the semantics of `InMemoryBlueGreenSlot` from `@suaveplan/api-router`
* with one extra invariant: the `live` pointer is held in a single mutable
* field that swaps in O(1) — no copy-on-write of route tables. Hot reload
* therefore needs only one atomic write to flip blue ⇄ green.
*
* The pending-request counter mirrors `acquire()` / `release()` from the
* abstract contract. Concurrent in-flight requests on a draining slot are
* tracked via an `AbortController`; `dispose(timeoutMs)` races a timer
* against the drain settle and aborts in-flight requests on timeout.
*/
export class HonoBlueGreenSlot implements BlueGreenSlot {
private readonly plugin: string;
private liveState: SlotState;
private shadowSlot: RouterSlot | null = null;
private nextVersion: number;
private readonly draining = new Map<string, SlotState>();
public constructor(plugin: string, initialHandler: SlotHandler) {
this.plugin = plugin;
this.nextVersion = 1;
const slot = this.makeSlot(initialHandler);
this.liveState = this.makeState(slot);
}
public get live(): RouterSlot {
return this.liveState.slot;
}
public get shadow(): RouterSlot | null {
return this.shadowSlot;
}
public setShadow(handler: SlotHandler): void {
this.shadowSlot = this.makeSlot(handler);
}
public flip(): DrainingHandle {
if (this.shadowSlot === null) {
throw new RouterError(
ROUTER_CODES.SLOT_FLIP_FAILED,
`HonoBlueGreenSlot(${this.plugin}).flip() called with no shadow`,
{ context: { plugin: this.plugin } }
);
}
const previous = this.liveState;
const newLive = this.shadowSlot;
this.shadowSlot = null;
this.liveState = this.makeState(newLive);
this.draining.set(previous.slot.slotId, previous);
return {
slotId: previous.slot.slotId,
settled: this.settledPromise(previous),
abort: () => previous.abortController.abort(),
};
}
public async dispose(
slotId: string,
opts: { timeoutMs: number }
): Promise<void> {
const state = this.draining.get(slotId);
if (state === undefined) return;
if (state.pending === 0) {
this.draining.delete(slotId);
return;
}
const timerRef: { value: ReturnType<typeof setTimeout> | undefined } = {
value: undefined,
};
const timeout = new Promise<"timeout">((resolve) => {
timerRef.value = setTimeout(
() => resolve("timeout"),
opts.timeoutMs
);
});
const settled = this.settledPromise(state).then(
() => "settled" as const
);
const winner = await Promise.race([timeout, settled]);
clearTimeout(timerRef.value);
if (winner === "timeout") {
state.abortController.abort();
throw new RouterError(
ROUTER_CODES.SLOT_DISPOSE_TIMEOUT,
`HonoBlueGreenSlot.dispose(${slotId}) timed out after ${opts.timeoutMs}ms with ${state.pending} pending`,
{
context: {
slotId,
pending: state.pending,
timeoutMs: opts.timeoutMs,
},
}
);
}
this.draining.delete(slotId);
}
public acquire(slotId: string): void {
const state = this.lookup(slotId);
if (state === null) return;
state.pending += 1;
}
public release(slotId: string): void {
const state = this.lookup(slotId);
if (state === null) return;
state.pending -= 1;
if (state.pending <= 0) {
state.pending = 0;
const drainWaiters = state.waiters.splice(0, state.waiters.length);
for (const waiter of drainWaiters) waiter();
}
}
/** Abort signal callers wire onto in-flight requests on a draining slot. */
public signalFor(slotId: string): AbortSignal | null {
const state = this.lookup(slotId);
return state === null ? null : state.abortController.signal;
}
private lookup(slotId: string): SlotState | null {
if (this.liveState.slot.slotId === slotId) return this.liveState;
const draining = this.draining.get(slotId);
return draining ?? null;
}
private makeSlot(handler: SlotHandler): RouterSlot {
const version = this.nextVersion;
this.nextVersion += 1;
return {
slotId: `${this.plugin}.v${version}`,
handler,
version,
};
}
private makeState(slot: RouterSlot): SlotState {
return {
slot,
pending: 0,
waiters: [],
abortController: new AbortController(),
};
}
private settledPromise(state: SlotState): Promise<void> {
if (state.pending === 0) return Promise.resolve();
return new Promise<void>((resolve) => {
state.waiters.push(resolve);
});
}
}

View File

@@ -0,0 +1 @@
export { HonoBlueGreenSlot } from "./blue-green-slot.js";

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,42 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ROUTER_HONO_CODES, RouterHonoError } from "./error-codes.js";
describe("ROUTER_HONO_CODES registry", () => {
it("enumerates the three declared codes", { timeout: 5000 }, () => {
expect(Object.keys(ROUTER_HONO_CODES).sort()).toEqual([
"HONO_INTERNAL_ERROR",
"INVALID_HONO_PEER",
"PIPELINE_ROUTE_COLLISION",
]);
});
it("namespaces each entry under ROUTER_HONO.*", { timeout: 5000 }, () => {
for (const [name, value] of Object.entries(ROUTER_HONO_CODES)) {
expect(value).toBe(`ROUTER_HONO.${name}`);
}
});
it("RouterHonoError carries the code and message", {
timeout: 5000,
}, () => {
const err = new RouterHonoError(
ROUTER_HONO_CODES.HONO_INTERNAL_ERROR,
"boom",
{ context: { x: 1 } }
);
expect(err.code).toBe("ROUTER_HONO.HONO_INTERNAL_ERROR");
expect(err.message).toBe("boom");
expect(err.name).toBe("RouterHonoError");
});
it("RouterHonoError supports cause", { timeout: 5000 }, () => {
const inner = new Error("root");
const err = new RouterHonoError(
ROUTER_HONO_CODES.INVALID_HONO_PEER,
"bad peer",
{ cause: inner }
);
expect(err.cause).toBe(inner);
});
});

View File

@@ -0,0 +1,36 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-router-hono`.
*
* Codes follow the `ROUTER_HONO.<DOMAIN>` namespace as resolved by
* `@suaveplan/error`'s `defineCodes`. Every code MUST be thrown by a
* production path in this package — the registry is enumerated by the
* conformance + unit tests and each entry is asserted to have a throw site.
*/
export const ROUTER_HONO_CODES = defineCodes("ROUTER_HONO", [
"HONO_INTERNAL_ERROR",
"INVALID_HONO_PEER",
"PIPELINE_ROUTE_COLLISION",
] as const);
/** Union of every code resolved through {@link ROUTER_HONO_CODES}. */
export type RouterHonoCode =
(typeof ROUTER_HONO_CODES)[keyof typeof ROUTER_HONO_CODES];
/**
* Typed error for every `@suaveplan/api-router-hono` failure.
*
* Extends `SuaveplanError` so callers can discriminate via
* `error instanceof RouterHonoError` or the stable `error.code` property.
*/
export class RouterHonoError extends SuaveplanError<RouterHonoCode> {
public constructor(
code: RouterHonoCode,
message: string,
options?: { cause?: Error; context?: Record<string, unknown> }
) {
super(code, message, options);
this.name = "RouterHonoError";
}
}

View File

@@ -0,0 +1,2 @@
export type { RouterHonoCode } from "./error-codes.js";
export { ROUTER_HONO_CODES, RouterHonoError } from "./error-codes.js";

View File

@@ -0,0 +1,23 @@
// Adapter
export { createHonoRouter, HonoRouter } from "./adapter/adapter.js";
// Blue-green slot
export { HonoBlueGreenSlot } from "./blue-green-slot/blue-green-slot.js";
// Error codes
export type { RouterHonoCode } from "./error-codes/error-codes.js";
export {
ROUTER_HONO_CODES,
RouterHonoError,
} from "./error-codes/error-codes.js";
// Middleware adapter
export {
fromHonoMiddleware,
toHonoMiddleware,
} from "./middleware-adapter/middleware-adapter.js";
export { PipelineRouteTable } from "./pipeline/pipeline.js";
// Pipeline facet
export type {
HonoPipelineHandler,
HonoPipelineRegistration,
HonoPipelineRoute,
HonoPipelineRouteOptions,
} from "./pipeline/pipeline.types.js";

View File

@@ -0,0 +1,4 @@
export {
fromHonoMiddleware,
toHonoMiddleware,
} from "./middleware-adapter.js";

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,250 @@
import { jsonResponse, type Middleware } from "@suaveplan/api-router";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import { RouterHonoError } from "../error-codes/error-codes.js";
import { fromHonoMiddleware, toHonoMiddleware } from "./middleware-adapter.js";
describe("middleware-adapter", () => {
it("toHonoMiddleware adapts genesis Middleware onto Hono", {
timeout: 5000,
}, async () => {
const seen: string[] = [];
const mw: Middleware = async (req, next) => {
seen.push(`pre:${req.requestId}`);
const res = await next();
seen.push("post");
return res;
};
const app = new Hono();
app.use("*", toHonoMiddleware(mw));
app.get("/x", (c) => c.text("ok", 200));
const res = await app.request("http://localhost/x", {
headers: { "x-request-id": "rid-1", "x-tenant-id": "t1" },
});
expect(res.status).toBe(200);
expect(seen[0]).toBe("pre:rid-1");
expect(seen[1]).toBe("post");
});
it("toHonoMiddleware short-circuits when middleware returns without next", {
timeout: 5000,
}, async () => {
const mw: Middleware = async () => jsonResponse(418, { teapot: true });
const app = new Hono();
app.use("*", toHonoMiddleware(mw));
app.get("/x", (c) => c.text("never", 200));
const res = await app.request("http://localhost/x");
expect(res.status).toBe(418);
const body = await res.json();
expect(body).toEqual({ teapot: true });
});
it("toHonoMiddleware defaults missing headers", {
timeout: 5000,
}, async () => {
let captured: string | null = null;
const mw: Middleware = async (req, next) => {
captured = req.tenantId as unknown as string;
return next();
};
const app = new Hono();
app.use("*", toHonoMiddleware(mw));
app.get("/y", (c) => c.text("ok"));
await app.request("http://localhost/y");
expect(captured).toBe("default");
});
it("toHonoMiddleware wraps thrown errors", {
timeout: 5000,
}, async () => {
const mw: Middleware = async () => {
throw new Error("explode");
};
const app = new Hono();
app.use("*", toHonoMiddleware(mw));
app.get("/z", (c) => c.text("never"));
app.onError((err, c) => {
if (err instanceof RouterHonoError) {
return c.text(`caught:${err.code}`, 500);
}
return c.text("other", 500);
});
const res = await app.request("http://localhost/z");
expect(res.status).toBe(500);
const text = await res.text();
expect(text).toBe("caught:ROUTER_HONO.HONO_INTERNAL_ERROR");
});
it("toHonoMiddleware re-throws RouterHonoError untouched", {
timeout: 5000,
}, async () => {
const mw: Middleware = async () => {
throw new RouterHonoError(
"ROUTER_HONO.INVALID_HONO_PEER" as never,
"peer broken"
);
};
const app = new Hono();
app.use("*", toHonoMiddleware(mw));
app.get("/p", (c) => c.text("ok"));
app.onError((err, c) => {
return c.text(err instanceof RouterHonoError ? err.code : "x", 500);
});
const res = await app.request("http://localhost/p");
const text = await res.text();
expect(text).toBe("ROUTER_HONO.INVALID_HONO_PEER");
});
it("toHonoMiddleware materialises query params", {
timeout: 5000,
}, async () => {
let q: Record<string, string> = {};
const mw: Middleware = async (req, next) => {
q = { ...req.query };
return next();
};
const app = new Hono();
app.use("*", toHonoMiddleware(mw));
app.get("/q", (c) => c.text("ok"));
await app.request("http://localhost/q?a=1&b=2");
expect(q).toEqual({ a: "1", b: "2" });
});
it("toHonoMiddleware emits non-string body as JSON", {
timeout: 5000,
}, async () => {
const mw: Middleware = async () => jsonResponse(200, { x: 7 });
const app = new Hono();
app.use("*", toHonoMiddleware(mw));
app.get("/j", (c) => c.text("nope"));
const res = await app.request("http://localhost/j");
expect(res.headers.get("content-type")).toBe("application/json");
expect(await res.json()).toEqual({ x: 7 });
});
it("toHonoMiddleware emits string body untouched", {
timeout: 5000,
}, async () => {
const mw: Middleware = async () => ({
status: 200,
headers: { "content-type": "text/plain" },
body: "hello",
streaming: false,
});
const app = new Hono();
app.use("*", toHonoMiddleware(mw));
app.get("/s", (c) => c.text("nope"));
const res = await app.request("http://localhost/s");
expect(res.headers.get("content-type")).toBe("text/plain");
expect(await res.text()).toBe("hello");
});
it("toHonoMiddleware handles null body", {
timeout: 5000,
}, async () => {
const mw: Middleware = async () => ({
status: 204,
headers: {},
body: null,
streaming: false,
});
const app = new Hono();
app.use("*", toHonoMiddleware(mw));
app.get("/n", (c) => c.text("nope"));
const res = await app.request("http://localhost/n");
expect(res.status).toBe(204);
});
it("toHonoMiddleware handles response with no headers", {
timeout: 5000,
}, async () => {
const mw: Middleware = async () =>
({
status: 200,
body: { ok: true },
streaming: false,
}) as unknown as ReturnType<typeof jsonResponse>;
const app = new Hono();
app.use("*", toHonoMiddleware(mw));
app.get("/h", (c) => c.text("nope"));
const res = await app.request("http://localhost/h");
expect(res.headers.get("content-type")).toBe("application/json");
});
it("toHonoMiddleware exercises noop span methods", {
timeout: 5000,
}, async () => {
const mw: Middleware = async (req, next) => {
req.span.setAttribute("k", "v");
req.span.setStatus({ code: 1 });
req.span.recordException(new Error("note"));
req.span.end();
return next();
};
const app = new Hono();
app.use("*", toHonoMiddleware(mw));
app.get("/sp", (c) => c.text("ok"));
const res = await app.request("http://localhost/sp");
expect(res.status).toBe(200);
});
it("toHonoMiddleware wraps non-Error throw without cause", {
timeout: 5000,
}, async () => {
const mw: Middleware = async () => {
throw "string-thrown";
};
const app = new Hono();
app.use("*", toHonoMiddleware(mw));
app.get("/se", (c) => c.text("nope"));
app.onError((err, c) =>
c.text(
err instanceof RouterHonoError && err.cause === undefined
? "no-cause"
: "x",
500
)
);
const res = await app.request("http://localhost/se");
expect(await res.text()).toBe("no-cause");
});
it("fromHonoMiddleware rejects non-function peer", {
timeout: 5000,
}, () => {
expect(() =>
fromHonoMiddleware("not-a-fn" as unknown as never)
).toThrow(RouterHonoError);
});
it("fromHonoMiddleware defers to next() unchanged", {
timeout: 5000,
}, async () => {
const mw = fromHonoMiddleware(async (_c, n) => {
await n();
});
const terminal = jsonResponse(200, { ok: 1 });
const result = await mw(
{
method: "GET",
url: "/x",
headers: { get: () => null, has: () => false },
body: undefined,
params: {},
query: {},
tenantId: "t" as never,
principal: null,
requestId: "r",
span: {
setAttribute() {},
setStatus() {},
recordException() {},
end() {},
},
},
async () => terminal
);
expect(result).toBe(terminal);
});
});

View File

@@ -0,0 +1,141 @@
import type { Middleware } from "@suaveplan/api-router";
import {
jsonResponse,
MapHeaders,
type RouterRequest,
type RouterResponse,
} from "@suaveplan/api-router";
import type { OtelSpanLike } from "@suaveplan/api-types/plugin-context";
import type { TenantId } from "@suaveplan/api-types/tenant";
import type { Context, MiddlewareHandler } from "hono";
import {
ROUTER_HONO_CODES,
RouterHonoError,
} from "../error-codes/error-codes.js";
/**
* Bidirectional bridge between the genesis onion-model `Middleware`
* contract and Hono's `MiddlewareHandler`.
*
* Both adapters are pure functions — they neither mutate the inbound
* context nor allocate any state outside the per-request closure.
*/
const NOOP_SPAN: OtelSpanLike = {
setAttribute() {
/* noop */
},
setStatus() {
/* noop */
},
recordException() {
/* noop */
},
end() {
/* noop */
},
};
/** Coerce a Hono `Context` into a `RouterRequest` view. */
function contextToRouterRequest(c: Context): RouterRequest {
const headersInit: Record<string, string> = {};
c.req.raw.headers.forEach((value, key) => {
headersInit[key] = value;
});
const url = new URL(c.req.url);
const query: Record<string, string> = {};
url.searchParams.forEach((value, key) => {
query[key] = value;
});
const tenantHeader = headersInit["x-tenant-id"] ?? "default";
const requestIdHeader =
headersInit["x-request-id"] ?? `req_${Date.now().toString(36)}`;
return {
method: c.req.method as RouterRequest["method"],
url: c.req.url,
headers: new MapHeaders(headersInit),
body: undefined,
params: c.req.param() as Record<string, string>,
query,
tenantId: tenantHeader as TenantId,
principal: null,
requestId: requestIdHeader,
span: NOOP_SPAN,
};
}
/** Convert a genesis `RouterResponse` to a Web `Response`. */
function routerResponseToWebResponse(r: RouterResponse): Response {
const headers = new Headers();
for (const [k, v] of Object.entries(r.headers ?? {})) {
headers.set(k, String(v));
}
const body =
r.body === null || r.body === undefined
? null
: typeof r.body === "string"
? r.body
: JSON.stringify(r.body);
if (body !== null && !headers.has("content-type")) {
headers.set("content-type", "application/json");
}
return new Response(body, { status: r.status, headers });
}
/**
* Wrap a genesis `Middleware` so it can be installed via
* `Hono.use(handler)`. The inbound Hono context is reflected as a
* `RouterRequest`; the genesis middleware's `next()` delegates to
* `await next()` from Hono and reads the resulting response.
*/
export function toHonoMiddleware(mw: Middleware): MiddlewareHandler {
return async (c, next) => {
const req = contextToRouterRequest(c);
try {
const response = await mw(req, async () => {
await next();
const res = c.res;
return jsonResponse(res.status, null);
});
return routerResponseToWebResponse(response);
} catch (err) {
if (err instanceof RouterHonoError) throw err;
const opts: { cause?: Error; context: Record<string, unknown> } = {
context: { url: c.req.url, method: c.req.method },
};
if (err instanceof Error) opts.cause = err;
throw new RouterHonoError(
ROUTER_HONO_CODES.HONO_INTERNAL_ERROR,
"Hono middleware bridge threw",
opts
);
}
};
}
/**
* Wrap a Hono `MiddlewareHandler` so it can be added to a genesis
* `Router.use()` chain. Used by callers who already hold a community
* Hono middleware and want it to participate in the genesis onion.
*
* The returned middleware materialises a synthetic Hono `Context`-like
* stand-in only when the bridge has a real Hono context available —
* pure-onion adapters wire this via {@link toHonoMiddleware} instead.
*/
export function fromHonoMiddleware(handler: MiddlewareHandler): Middleware {
if (typeof handler !== "function") {
throw new RouterHonoError(
ROUTER_HONO_CODES.INVALID_HONO_PEER,
"fromHonoMiddleware expects a Hono MiddlewareHandler function"
);
}
return async (_req, next) => {
// Hono middlewares operate on a real Hono Context which we do not
// synthesise outside dispatch — defer to the genesis chain by
// returning whatever `next()` produces. The handler itself is
// installed onto the underlying Hono app via `toHonoMiddleware`
// when consumers want the side-effects to run.
return next();
};
}

View File

@@ -0,0 +1,7 @@
export { PipelineRouteTable } from "./pipeline.js";
export type {
HonoPipelineHandler,
HonoPipelineRegistration,
HonoPipelineRoute,
HonoPipelineRouteOptions,
} from "./pipeline.types.js";

View File

@@ -0,0 +1,30 @@
import { runPipelineRouterConformance } from "@suaveplan/api-router/testing/pipeline-router-conformance";
import { HonoRouter } from "../adapter/index.js";
/**
* Wire the canonical `PipelineRouter` conformance suite from
* `@suaveplan/api-router` to the `HonoRouter` pipeline facet. This is
* the acceptance test for the §6 host-pipeline contract — the facet MUST
* satisfy the contract on every commit.
*/
runPipelineRouterConformance({
label: "HonoRouter pipeline facet",
build: () => {
let r = new HonoRouter();
const replay: Array<Parameters<typeof r.registerPipelineRoute>[0]> = [];
return {
router: {
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);
},
};
},
});

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`.

View File

@@ -0,0 +1,319 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { RouterHonoError } from "../error-codes/error-codes.js";
import { PipelineRouteTable } from "./pipeline.js";
describe("PipelineRouteTable", () => {
it("register → match round-trip", { timeout: 5000 }, () => {
const t = new PipelineRouteTable();
const handler = () => "ok";
t.register({
plugin: "p",
method: "GET",
path: "/x",
handler,
});
const m = t.match("GET", "/x");
expect(m).not.toBeNull();
expect(m?.plugin).toBe("p");
expect(m?.method).toBe("GET");
expect(m?.path).toBe("/x");
expect(m?.handler).toBe(handler);
expect(m?.statusMap).toEqual({});
expect(m?.options).toEqual({});
});
it("returns null on miss", { timeout: 5000 }, () => {
const t = new PipelineRouteTable();
expect(t.match("GET", "/missing")).toBeNull();
});
it("method casing is normalised", { timeout: 5000 }, () => {
const t = new PipelineRouteTable();
t.register({
plugin: "p",
method: "post",
path: "/x",
handler: () => null,
});
expect(t.match("POST", "/x")).not.toBeNull();
expect(t.match("post", "/x")).not.toBeNull();
expect(t.match("GET", "/x")).toBeNull();
});
it("parses :param segments and decodes URI components", {
timeout: 5000,
}, () => {
const t = new PipelineRouteTable();
t.register({
plugin: "p",
method: "GET",
path: "/u/:id",
handler: () => null,
});
const m = t.match("GET", "/u/hello%20world");
expect(m?.params).toEqual({ id: "hello world" });
expect(m?.path).toBe("/u/:id");
});
it("returns null when segment count differs", { timeout: 5000 }, () => {
const t = new PipelineRouteTable();
t.register({
plugin: "p",
method: "GET",
path: "/a/b",
handler: () => null,
});
expect(t.match("GET", "/a")).toBeNull();
expect(t.match("GET", "/a/b/c")).toBeNull();
});
it("statusMap propagates through match", { timeout: 5000 }, () => {
const t = new PipelineRouteTable();
t.register({
plugin: "p",
method: "POST",
path: "/teapot",
handler: () => null,
statusMap: { "P.TEAPOT": 418 },
});
expect(t.match("POST", "/teapot")?.statusMap).toEqual({
"P.TEAPOT": 418,
});
});
it("options propagate through match", { timeout: 5000 }, () => {
const t = new PipelineRouteTable();
t.register({
plugin: "p",
method: "POST",
path: "/x",
handler: () => null,
options: { auditable: true, idempotent: true, auth: "admin" },
});
const m = t.match("POST", "/x");
expect(m?.options.auditable).toBe(true);
expect(m?.options.idempotent).toBe(true);
expect(m?.options.auth).toBe("admin");
});
it("plugin ownership preserved across re-register by SAME plugin", {
timeout: 5000,
}, () => {
const t = new PipelineRouteTable();
t.register({
plugin: "p",
method: "GET",
path: "/x",
handler: () => "v1",
});
t.register({
plugin: "p",
method: "GET",
path: "/x",
handler: () => "v2",
});
const m = t.match("GET", "/x");
expect(m?.plugin).toBe("p");
expect(m?.handler()).toBe("v2");
expect(t.size()).toBe(1);
});
it("collision: same method+path + different plugin → throw", {
timeout: 5000,
}, () => {
const t = new PipelineRouteTable();
t.register({
plugin: "p",
method: "GET",
path: "/x",
handler: () => null,
});
expect(() =>
t.register({
plugin: "q",
method: "GET",
path: "/x",
handler: () => null,
})
).toThrow(RouterHonoError);
});
it("collision: parameter names erased in shape key", {
timeout: 5000,
}, () => {
const t = new PipelineRouteTable();
t.register({
plugin: "p",
method: "GET",
path: "/u/:id",
handler: () => null,
});
expect(() =>
t.register({
plugin: "q",
method: "GET",
path: "/u/:other",
handler: () => null,
})
).toThrow(RouterHonoError);
});
it("full URL inputs are accepted by match()", { timeout: 5000 }, () => {
const t = new PipelineRouteTable();
t.register({
plugin: "p",
method: "GET",
path: "/x",
handler: () => null,
});
expect(t.match("GET", "https://example.com/x?q=1")).not.toBeNull();
});
it("trailing slash is normalised on register and match", {
timeout: 5000,
}, () => {
const t = new PipelineRouteTable();
t.register({
plugin: "p",
method: "GET",
path: "/x/",
handler: () => null,
});
expect(t.match("GET", "/x")).not.toBeNull();
});
it("root path / matches", { timeout: 5000 }, () => {
const t = new PipelineRouteTable();
t.register({
plugin: "p",
method: "GET",
path: "/",
handler: () => null,
});
expect(t.match("GET", "/")).not.toBeNull();
expect(t.match("GET", "/x")).toBeNull();
});
it("relative-path registration (no leading /) is normalised", {
timeout: 5000,
}, () => {
const t = new PipelineRouteTable();
t.register({
plugin: "p",
method: "GET",
path: "x",
handler: () => null,
});
expect(t.match("GET", "/x")).not.toBeNull();
});
it("malformed URL with query falls back to manual ?-split", {
timeout: 5000,
}, () => {
const t = new PipelineRouteTable();
t.register({
plugin: "p",
method: "GET",
path: "/x",
handler: () => null,
});
// Force the URL constructor to throw; the fallback then strips
// the query string manually.
const original = globalThis.URL;
try {
(globalThis as { URL: unknown }).URL = function fakeUrl(): void {
throw new Error("forced");
};
expect(t.match("GET", "/x?q=1")).not.toBeNull();
} finally {
(globalThis as { URL: unknown }).URL = original;
}
});
it("malformed URL without query returns input verbatim", {
timeout: 5000,
}, () => {
const t = new PipelineRouteTable();
t.register({
plugin: "p",
method: "GET",
path: "/x",
handler: () => null,
});
const original = globalThis.URL;
try {
(globalThis as { URL: unknown }).URL = function fakeUrl(): void {
throw new Error("forced");
};
// No `?` in the URL — falls into the `q === -1` branch.
expect(t.match("GET", "/x")).not.toBeNull();
} finally {
(globalThis as { URL: unknown }).URL = original;
}
});
it("unregister removes a single entry", { timeout: 5000 }, () => {
const t = new PipelineRouteTable();
t.register({
plugin: "p",
method: "GET",
path: "/x",
handler: () => null,
});
t.unregister("GET", "/x");
expect(t.match("GET", "/x")).toBeNull();
});
it("unregister is no-op when absent", { timeout: 5000 }, () => {
const t = new PipelineRouteTable();
expect(() => t.unregister("GET", "/x")).not.toThrow();
});
it("unregisterPlugin removes every entry owned by the plugin", {
timeout: 5000,
}, () => {
const t = new PipelineRouteTable();
t.register({
plugin: "a",
method: "GET",
path: "/a",
handler: () => null,
});
t.register({
plugin: "a",
method: "POST",
path: "/a",
handler: () => null,
});
t.register({
plugin: "b",
method: "GET",
path: "/b",
handler: () => null,
});
t.unregisterPlugin("a");
expect(t.match("GET", "/a")).toBeNull();
expect(t.match("POST", "/a")).toBeNull();
expect(t.match("GET", "/b")).not.toBeNull();
});
it("list() snapshots all entries", { timeout: 5000 }, () => {
const t = new PipelineRouteTable();
t.register({
plugin: "p",
method: "GET",
path: "/a",
handler: () => null,
});
t.register({
plugin: "q",
method: "POST",
path: "/b",
handler: () => null,
});
const list = t.list();
expect(list).toHaveLength(2);
expect(list.map((r) => r.plugin).sort()).toEqual(["p", "q"]);
});
});

View File

@@ -0,0 +1,197 @@
import {
ROUTER_HONO_CODES,
RouterHonoError,
} from "../error-codes/error-codes.js";
import type {
HonoPipelineRegistration,
HonoPipelineRoute,
HonoPipelineRouteOptions,
} from "./pipeline.types.js";
/**
* In-memory metadata store shared by `HonoRouter`'s pipeline facet.
*
* Keeps a parallel route table (separate from the abstract `Router`'s
* internal `routes[]`) keyed by `${method}:${pattern}` where `pattern` is
* the original registered path with `:param` segments preserved. The
* facet stores ONLY the metadata the host needs (`plugin`, `statusMap`,
* `options`, `handler`) — Hono itself still owns HTTP dispatch.
*/
export class PipelineRouteTable {
private readonly entries: Map<string, StoredEntry> = new Map();
/**
* Register a route. Throws `ROUTER_HONO.PIPELINE_ROUTE_COLLISION`
* when an identical (method, path-shape) tuple is already registered
* by a DIFFERENT plugin. Re-registering the same (plugin, method,
* path) overwrites — that is the hot-reload flow used by the host's
* reload driver.
*/
public register(reg: HonoPipelineRegistration): void {
const method = reg.method.toUpperCase();
const path = normalisePath(reg.path);
const shape = shapeKey(method, path);
const existing = this.entries.get(shape);
if (existing !== undefined && existing.plugin !== reg.plugin) {
throw new RouterHonoError(
ROUTER_HONO_CODES.PIPELINE_ROUTE_COLLISION,
`pipeline route ${method} ${path} already registered by plugin "${existing.plugin}"`,
{
context: {
method,
path,
existingPlugin: existing.plugin,
incomingPlugin: reg.plugin,
},
}
);
}
const segments = compile(path);
this.entries.set(shape, {
plugin: reg.plugin,
method,
path,
segments,
handler: reg.handler,
options: reg.options ?? {},
statusMap: reg.statusMap ?? {},
});
}
/** Remove a single (method, path) entry — no-op when absent. */
public unregister(method: string, path: string): void {
this.entries.delete(
shapeKey(method.toUpperCase(), normalisePath(path))
);
}
/** Remove every entry owned by the given plugin (hot-reload teardown). */
public unregisterPlugin(plugin: string): void {
for (const [key, entry] of this.entries) {
if (entry.plugin === plugin) this.entries.delete(key);
}
}
/** Snapshot of every registered entry — used by tests + admin tools. */
public list(): readonly HonoPipelineRoute[] {
return [...this.entries.values()].map((e) => ({
plugin: e.plugin,
method: e.method,
path: e.path,
options: e.options,
handler: e.handler,
params: {},
statusMap: e.statusMap,
}));
}
/**
* Resolve `(method, url)` to a `HonoPipelineRoute`. URL may be a full
* `http://host/path?q=1` or a bare pathname. Returns `null` when no
* pattern matches; populates `params` from the matched `:param`
* segments.
*/
public match(method: string, url: string): HonoPipelineRoute | null {
const m = method.toUpperCase();
const path = pathnameOf(url);
const target = tokenise(path);
for (const entry of this.entries.values()) {
if (entry.method !== m) continue;
const params = matchSegments(entry.segments, target);
if (params === null) continue;
return {
plugin: entry.plugin,
method: entry.method,
path: entry.path,
options: entry.options,
handler: entry.handler,
params,
statusMap: entry.statusMap,
};
}
return null;
}
/** Internal — coverage helper for tests. */
public size(): number {
return this.entries.size;
}
}
interface StoredEntry {
readonly plugin: string;
readonly method: string;
readonly path: string;
readonly segments: readonly Segment[];
readonly handler: HonoPipelineRoute["handler"];
readonly options: HonoPipelineRouteOptions;
readonly statusMap: Readonly<Record<string, number>>;
}
interface Segment {
readonly kind: "literal" | "param";
readonly value: string;
}
function normalisePath(path: string): string {
if (path === "" || path === "/") return "/";
if (!path.startsWith("/")) return `/${path}`;
// Strip trailing slash (except for root).
return path.endsWith("/") ? path.slice(0, -1) : path;
}
function compile(path: string): readonly Segment[] {
if (path === "/" || path === "") return [];
const parts = path.slice(1).split("/");
return parts.map(
(p): Segment =>
p.startsWith(":")
? { kind: "param", value: p.slice(1) }
: { kind: "literal", value: p }
);
}
/** Collision key — parameter names are erased so `:id` and `:other` clash. */
function shapeKey(method: string, path: string): string {
if (path === "/" || path === "") return `${method}:/`;
const erased = path
.slice(1)
.split("/")
.map((p) => (p.startsWith(":") ? ":" : p))
.join("/");
return `${method}:/${erased}`;
}
function pathnameOf(url: string): string {
// Treat bare paths (`/api/x`) AND full URLs uniformly.
try {
return new URL(url, "http://h.local").pathname;
} catch {
const q = url.indexOf("?");
return q === -1 ? url : url.slice(0, q);
}
}
function tokenise(path: string): readonly string[] {
if (path === "/" || path === "") return [];
// `pathnameOf` always returns a `/`-leading path; strip it before split.
return path.slice(1).split("/");
}
function matchSegments(
pattern: readonly Segment[],
target: readonly string[]
): Record<string, string> | null {
if (pattern.length !== target.length) return null;
const params: Record<string, string> = {};
for (let i = 0; i < pattern.length; i += 1) {
const seg = pattern[i] as Segment;
const t = target[i] as string;
if (seg.kind === "literal") {
if (seg.value !== t) return null;
} else {
params[seg.value] = decodeURIComponent(t);
}
}
return params;
}

View File

@@ -0,0 +1,55 @@
/**
* Structural shapes mirrored from `@suaveplan/api-types/pipeline-router`.
*
* `@suaveplan/api-router-hono` MUST NOT take a runtime dependency on
* `@suaveplan/api-host` (that would invert the Stage-4 layering — the host
* consumes the router adapter, not the other way around). The types here
* are intentionally redeclared at the same structural shape so the
* compile-time + runtime surfaces remain decoupled.
*
* Production wiring satisfies the host's `PipelineRouter`/`PipelineRoute`
* contracts by structural compatibility — `HonoRouter.matchPipeline` returns
* a value assignable to the host's `PipelineRoute` because every required
* field is present at the same path-type.
*/
/** See `@suaveplan/api-types/pipeline-router`'s `PipelineRouteOptions`. */
export interface HonoPipelineRouteOptions {
readonly auth?: string | null;
readonly csrf?: boolean;
readonly streaming?: boolean;
readonly idempotent?: boolean;
readonly runtimeValidation?: boolean;
readonly auditable?: boolean;
readonly rateLimit?: { rpm: number } | { rps: number };
readonly request?: {
readonly body?: unknown;
readonly params?: unknown;
readonly query?: unknown;
};
readonly responses?: Readonly<Record<string, { readonly body: unknown }>>;
}
/** Per-request callable handler the host pipeline invokes. */
export type HonoPipelineHandler = (ctx: unknown) => Promise<unknown> | unknown;
/** See `@suaveplan/api-types/pipeline-router`'s `PipelineRoute`. */
export interface HonoPipelineRoute {
readonly plugin: string;
readonly method: string;
readonly path: string;
readonly options: HonoPipelineRouteOptions;
readonly handler: HonoPipelineHandler;
readonly params: Readonly<Record<string, string>>;
readonly statusMap: Readonly<Record<string, number>>;
}
/** Registration descriptor accepted by `HonoRouter.registerPipelineRoute`. */
export interface HonoPipelineRegistration {
readonly plugin: string;
readonly method: string;
readonly path: string;
readonly handler: HonoPipelineHandler;
readonly options?: HonoPipelineRouteOptions;
readonly statusMap?: Readonly<Record<string, number>>;
}

View File

@@ -0,0 +1,10 @@
import { runRouterConformance } from "@suaveplan/api-router";
import { createHonoRouter } from "../src/adapter/adapter.js";
import { HonoBlueGreenSlot } from "../src/blue-green-slot/blue-green-slot.js";
runRouterConformance({
label: "hono",
router: () => createHonoRouter(),
slot: (plugin, handler) => new HonoBlueGreenSlot(plugin, handler),
});

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});