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

117 lines
6.1 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-router-hono (api-router-hono)
**Package**: `@suaveplan/api-router-hono`
**Status**: PROPOSED
> Every requirement carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
## ADDED Requirements
### Requirement: Direct surface implements the Router contract
The package SHALL export `createHonoRouter(opts)` returning a `Router` (from `@suaveplan/api-router`) backed by a Hono instance. The returned router MUST pass `runRouterConformance` from `@suaveplan/api-router/testing`.
#### Scenario: factory returns a conformant Router
- **WHEN** `createHonoRouter({ basePath: "/" })` is called
- **THEN** the result satisfies the `Router` type AND `runRouterConformance("hono-direct", () => createHonoRouter(...))` reports zero failures
#### Scenario: route registration is order-independent for static paths
- **WHEN** `/a/b` is registered after `/a/:p`
- **THEN** a request to `/a/b` matches the static route AND a request to `/a/c` matches the param route
**Verified by:** `src/factory/create-hono-router.spec-conformance.test.ts::"hono-direct conforms to Router contract"` AND `src/factory/create-hono-router.test.ts::"static routes win over param routes when registered in either order"`
---
### Requirement: Worker-proxy surface implements the Router contract
The package SHALL export `createHonoRouterWorkerProxy(port, opts)` returning a `Router` whose calls are marshalled over a `MessagePort` via `@suaveplan/api-worker-rpc`. The proxy MUST pass the same `runRouterConformance` suite as the direct surface — no invariant may regress under marshalling.
#### Scenario: proxy passes every conformance invariant
- **WHEN** `runRouterConformance("hono-worker-proxy", () => createHonoRouterWorkerProxy(channel.port2, ...))` runs against a real `MessageChannel` bridged to a direct router on `channel.port1`
- **THEN** every invariant passes AND no invariant is skipped
#### Scenario: AbortSignal propagates across the proxy
- **WHEN** a route handler is invoked through the proxy AND the caller aborts via `AbortSignal`
- **THEN** the in-flight handler on the host side observes `signal.aborted === true` within one event-loop tick
**Verified by:** `src/proxy/create-hono-router-worker-proxy.spec-conformance.test.ts::"hono-worker-proxy conforms to Router contract"` AND `src/proxy/create-hono-router-worker-proxy.test.ts::"AbortSignal aborts the host-side handler"`
---
### Requirement: Dual-surface conformance gate
The adapter SHALL pass its capability's conformance suite under BOTH the direct surface AND the worker-proxy surface in the same test run. Failing either surface MUST fail the package's pre-publish gate.
#### Scenario: both surfaces run side by side
- **WHEN** the conformance suite executes
- **THEN** two `describe` blocks emit results — one labelled `hono-direct`, one labelled `hono-worker-proxy` — AND both report zero failures
**Verified by:** `src/factory/create-hono-router.spec-conformance.test.ts::"hono-direct conforms to Router contract"` AND `src/proxy/create-hono-router-worker-proxy.spec-conformance.test.ts::"hono-worker-proxy conforms to Router contract"`
---
### Requirement: Blue-green slot management
The package SHALL expose `HonoBlueGreenSlot` implementing the `BlueGreenSlot` contract from `api-router`. It maintains two Hono instances and an atomic active-slot pointer. New requests route to the active slot; in-flight requests on the inactive slot complete normally. A `swap()` call MUST be gated on a health-probe result when `strategy === "swap-on-health"`.
#### Scenario: in-flight requests complete on the old slot
- **WHEN** a request is in-flight on the blue slot AND `swap()` cuts over to green
- **THEN** the in-flight blue request completes its response AND any new request after `swap()` lands on green
#### Scenario: health-gated swap rolls back on failure
- **WHEN** the new slot's health probe throws OR returns false
- **THEN** the pointer remains on the old slot AND `ROUTER_HONO_BLUE_GREEN_SWAP_FAILED` is thrown to the caller
**Verified by:** `src/blue-green/hono-blue-green-slot.test.ts::"in-flight requests complete on the old slot after swap"` AND `src/blue-green/hono-blue-green-slot.test.ts::"swap rolls back when health probe fails"`
---
### Requirement: Streaming routes bypass buffering
Routes registered with `streaming: true` SHALL receive the raw `ReadableStream` body and SHALL write responses via a streaming bridge that emits chunks to the underlying socket without intermediate buffering.
#### Scenario: streaming handler writes chunks incrementally
- **WHEN** a streaming handler emits 3 chunks separated by event-loop ticks
- **THEN** the client observes 3 distinct read events AND no buffering across the whole response
**Verified by:** `src/streaming/streaming-bridge.test.ts::"streaming handler emits chunks incrementally without buffering"`
---
### Requirement: Options are Zod-validated
`HonoRouterOptions` SHALL be parsed through a Zod schema. Invalid options (negative `maxBodyBytes`, non-positive `streamingTimeoutMs`, unknown `strategy`) MUST throw at factory construction.
#### Scenario: invalid options throw at construction
- **WHEN** `createHonoRouter({ maxBodyBytes: -1 } as never)` is called
- **THEN** a `ZodError`-wrapped `SuaveplanError` with code `ROUTER_HONO_INVALID_OPTIONS` is thrown synchronously
**Verified by:** `src/options/hono-router-options.test.ts::"invalid options throw at factory construction"`
---
### Requirement: Telemetry instruments emitted
The adapter SHALL emit `api.router.match_duration_ms` (histogram, tagged with `route`, `method`, `outcome`) and `api.router.blue_green_swaps_total` (counter) on every match and every successful swap respectively.
#### Scenario: histogram records on every route match
- **WHEN** 5 requests are dispatched through `createHonoRouter`
- **THEN** the in-memory `MeterProvider` records exactly 5 observations on `api.router.match_duration_ms`
**Verified by:** `src/factory/create-hono-router.spec-conformance.test.ts::"emits api.router.match_duration_ms on every match"`
<!-- markdownlint-enable MD024 -->