Initial Commit
This commit is contained in:
62
packages/api-stack/api/api-router-hono/README.md
Normal file
62
packages/api-stack/api/api-router-hono/README.md
Normal 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.
|
||||
|
||||
96
packages/api-stack/api/api-router-hono/package.json
Normal file
96
packages/api-stack/api/api-router-hono/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
216
packages/api-stack/api/api-router-hono/src/adapter/adapter.ts
Normal file
216
packages/api-stack/api/api-router-hono/src/adapter/adapter.ts
Normal 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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createHonoRouter, HonoRouter } from "./adapter.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { HonoBlueGreenSlot } from "./blue-green-slot.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { RouterHonoCode } from "./error-codes.js";
|
||||
export { ROUTER_HONO_CODES, RouterHonoError } from "./error-codes.js";
|
||||
23
packages/api-stack/api/api-router-hono/src/index.ts
Normal file
23
packages/api-stack/api/api-router-hono/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
fromHonoMiddleware,
|
||||
toHonoMiddleware,
|
||||
} from "./middleware-adapter.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { PipelineRouteTable } from "./pipeline.js";
|
||||
export type {
|
||||
HonoPipelineHandler,
|
||||
HonoPipelineRegistration,
|
||||
HonoPipelineRoute,
|
||||
HonoPipelineRouteOptions,
|
||||
} from "./pipeline.types.js";
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
130
packages/api-stack/api/api-router-hono/src/pipeline/pipeline.md
Normal file
130
packages/api-stack/api/api-router-hono/src/pipeline/pipeline.md
Normal 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`.
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
197
packages/api-stack/api/api-router-hono/src/pipeline/pipeline.ts
Normal file
197
packages/api-stack/api/api-router-hono/src/pipeline/pipeline.ts
Normal 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;
|
||||
}
|
||||
@@ -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>>;
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
12
packages/api-stack/api/api-router-hono/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-router-hono/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
54
packages/api-stack/api/api-router-hono/vite.config.ts
Normal file
54
packages/api-stack/api/api-router-hono/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
25
packages/api-stack/api/api-router-hono/vitest.config.ts
Normal file
25
packages/api-stack/api/api-router-hono/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user