# Capability Spec: api-router-hono **Package**: `@suaveplan/api-router-hono` **Status**: SHIPPED (0.1.0, 2026-05-26) > Every requirement carries a `**Verified by:**` line. ## Requirement: Direct surface implements the abstract Router contract The package SHALL export `createHonoRouter(opts?)` returning an instance of the abstract `Router` (from `@suaveplan/api-router`) backed by a private Hono instance. Every verb registration (`get`, `post`, `put`, `patch`, `delete`, `head`, `options`, `all`) MUST mirror onto the underlying Hono app. The returned router MUST pass `runRouterConformance` from `@suaveplan/api-router`. ### Scenario: factory returns a Router-conformant adapter - **WHEN** `createHonoRouter()` is called - **THEN** the result is an instance of `HonoRouter` AND `runRouterConformance({ label: "hono", router: () => createHonoRouter(), slot: (p, h) => new HonoBlueGreenSlot(p, h) })` reports zero failures ### Scenario: every verb method registers a mirrored Hono route - **WHEN** each of `r.get / post / put / patch / delete / head / options / all` is called - **THEN** `r.match` returns a non-null `RouteMatch` for the registered method AND the same path resolves on the Hono app exposed via `r.getHonoApp()` **Verified by:** `tests/conformance.test.ts` AND `src/adapter/adapter.test.ts::"registers every verb on both abstract router and Hono"` AND `src/adapter/adapter.test.ts::"Hono mirror stub returns 501 when dispatched"` --- ## Requirement: HonoBlueGreenSlot implements BlueGreenSlot The package SHALL export `HonoBlueGreenSlot` implementing the `BlueGreenSlot` interface with atomic flip, in-flight counter, and AbortSignal-aware `dispose(timeoutMs)`. `flip()` with no shadow MUST throw `ROUTER.SLOT_FLIP_FAILED`. `dispose()` timing out MUST throw `ROUTER.SLOT_DISPOSE_TIMEOUT` AND abort the slot's in-flight requests. ### Scenario: flip + dispose drains in-flight work - **WHEN** an acquired live slot is flipped, then released after the timer starts - **THEN** `dispose` resolves successfully without firing the timeout ### Scenario: dispose times out and aborts - **WHEN** `dispose(slotId, { timeoutMs: 20 })` is called against a slot with pending work that is never released - **THEN** the call rejects with `ROUTER.SLOT_DISPOSE_TIMEOUT` AND `signalFor(slotId).aborted === true` **Verified by:** `src/blue-green-slot/blue-green-slot.test.ts::"dispose waits for release"` AND `src/blue-green-slot/blue-green-slot.test.ts::"dispose times out and aborts pending"` --- ## Requirement: Middleware ⇄ Hono MiddlewareHandler bridge The package SHALL export `toHonoMiddleware(mw)` adapting a genesis `Middleware` onto Hono, and `fromHonoMiddleware(handler)` for the reverse direction. `toHonoMiddleware` MUST reflect the inbound Hono context as a `RouterRequest` (headers, query, params) and serialise the outbound `RouterResponse` to a Web `Response`. Errors thrown inside the middleware MUST be wrapped in `RouterHonoError` with code `ROUTER_HONO.HONO_INTERNAL_ERROR`, preserving any `Error` cause. ### Scenario: middleware participates in Hono dispatch - **WHEN** `app.use("*", toHonoMiddleware(mw))` is installed and a request flows through - **THEN** `mw` observes the canonical `RouterRequest` shape AND the response status / headers / body land on the wire ### Scenario: thrown errors get wrapped - **WHEN** the genesis middleware throws a plain `Error` - **THEN** Hono `onError` observes a `RouterHonoError` with `code === "ROUTER_HONO.HONO_INTERNAL_ERROR"` AND `cause === thrown` ### Scenario: invalid peer rejected - **WHEN** `fromHonoMiddleware(value)` is called with a non-function - **THEN** it throws `RouterHonoError` with `code === "ROUTER_HONO.INVALID_HONO_PEER"` **Verified by:** `src/middleware-adapter/middleware-adapter.test.ts::"toHonoMiddleware adapts genesis Middleware onto Hono"` AND `src/middleware-adapter/middleware-adapter.test.ts::"toHonoMiddleware wraps thrown errors"` AND `src/middleware-adapter/middleware-adapter.test.ts::"fromHonoMiddleware rejects non-function peer"` --- ## Requirement: ROUTER_HONO error-code registry The package SHALL expose `ROUTER_HONO_CODES` (`defineCodes("ROUTER_HONO", [...])`) and a `RouterHonoError` extending `SuaveplanError`. The registry MUST contain `HONO_INTERNAL_ERROR`, `INVALID_HONO_PEER`, AND `PIPELINE_ROUTE_COLLISION`. Every code MUST be thrown by at least one production path in this package. ### Scenario: registry shape - **WHEN** `Object.keys(ROUTER_HONO_CODES)` is inspected - **THEN** the keys are exactly `["HONO_INTERNAL_ERROR", "INVALID_HONO_PEER", "PIPELINE_ROUTE_COLLISION"]` AND each value is `ROUTER_HONO.` **Verified by:** `src/error-codes/error-codes.test.ts::"enumerates the three declared codes"` AND `src/error-codes/error-codes.test.ts::"namespaces each entry under ROUTER_HONO.*"` --- ## Requirement: HonoRouter pipeline facet satisfies the host's PipelineRouter contract The package SHALL extend `HonoRouter` with a second, additive facet that produces values structurally assignable to `@suaveplan/api-host`'s `PipelineRoute` interface. The facet SHALL expose: - `registerPipelineRoute(reg: HonoPipelineRegistration): void` — stores `(plugin, method, path, handler, options?, statusMap?)`. - `unregisterPipelineRoute(method: string, path: string): void` — no-op when absent. - `unregisterPipelinePlugin(plugin: string): void` — removes every entry owned by `plugin` (hot-reload teardown). - `listPipelineRoutes(): readonly HonoPipelineRoute[]` — snapshot for admin tooling. - `matchPipeline(method: string, url: string): HonoPipelineRoute | null` — resolves `(method, url)` to a `HonoPipelineRoute` carrying `plugin`, `path`, `options`, `handler`, `params`, `statusMap`. The facet SHALL preserve the existing abstract `Router` surface verbatim. Registering the same `(method, path-shape)` under a DIFFERENT plugin SHALL throw `ROUTER_HONO.PIPELINE_ROUTE_COLLISION`. Re-registering the same `(plugin, method, path)` SHALL overwrite. Parameter names SHALL be erased in the collision key so `:id` and `:other` clash. ### Scenario: facet satisfies the host's PipelineRouter conformance suite - **WHEN** `runPipelineRouterConformance` (from `@suaveplan/api-host/testing`) is invoked with a factory that returns `{ router: { match: (m, u) => r.matchPipeline(m, u) }, register: (reg) => r.registerPipelineRoute(reg) }` - **THEN** every harness case passes (register → match round-trip, 404, statusMap propagation, plugin ownership across reload, param parsing, collision detection) ### Scenario: collision detection throws ROUTER_HONO.PIPELINE_ROUTE_COLLISION - **WHEN** two plugins register the same `(method, path-shape)` on one `HonoRouter` - **THEN** the second call throws `RouterHonoError` with `code === "ROUTER_HONO.PIPELINE_ROUTE_COLLISION"` ### Scenario: hot-reload teardown removes plugin-owned entries - **WHEN** `unregisterPipelinePlugin("billing")` is called after `billing` registered two pipeline routes - **THEN** subsequent `matchPipeline(...)` calls against either route return `null` AND `listPipelineRoutes()` no longer contains a `"billing"`-owned entry **Verified by:** `src/pipeline/pipeline.test.ts` AND `src/pipeline/pipeline.conformance.test.ts` AND `src/adapter/adapter.test.ts::"pipeline facet — register/match/list/unregister/unregisterPlugin"`