7.2 KiB
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
HonoRouterANDrunRouterConformance({ 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 / allis called - THEN
r.matchreturns a non-nullRouteMatchfor the registered method AND the same path resolves on the Hono app exposed viar.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
disposeresolves 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_TIMEOUTANDsignalFor(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
mwobserves the canonicalRouterRequestshape 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
onErrorobserves aRouterHonoErrorwithcode === "ROUTER_HONO.HONO_INTERNAL_ERROR"ANDcause === thrown
Scenario: invalid peer rejected
- WHEN
fromHonoMiddleware(value)is called with a non-function - THEN it throws
RouterHonoErrorwithcode === "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 isROUTER_HONO.<name>
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 byplugin(hot-reload teardown).listPipelineRoutes(): readonly HonoPipelineRoute[]— snapshot for admin tooling.matchPipeline(method: string, url: string): HonoPipelineRoute | null— resolves(method, url)to aHonoPipelineRoutecarryingplugin,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 oneHonoRouter - THEN the second call throws
RouterHonoErrorwithcode === "ROUTER_HONO.PIPELINE_ROUTE_COLLISION"
Scenario: hot-reload teardown removes plugin-owned entries
- WHEN
unregisterPipelinePlugin("billing")is called afterbillingregistered two pipeline routes - THEN subsequent
matchPipeline(...)calls against either route returnnullANDlistPipelineRoutes()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"