110 lines
7.2 KiB
Markdown
110 lines
7.2 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# 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"`
|