Files
2026-05-27 23:29:53 +01:00

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