Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
# Add @suaveplan/api-plugin-sse — Server-Sent Events plugin on the @suaveplan/api foundation
**Package**: `@suaveplan/api-plugin-sse` (NEW, server-tier)
**Status**: PROPOSED
**Type**: new-package
- **Spec delta**: ./specs/api-plugin-sse/spec.md
## Why
Genesis ships `@suaveplan/api` (orchestrator), `@suaveplan/api-plugin` (`definePlugin` foundation), `@suaveplan/api-openapi` (RouteBuilder), and `@suaveplan/api-plugin-auth` (canonical 3-way plug-and-play exemplar). Many product surfaces — live notifications, log tailing, document collaboration cursors, build-status dashboards — need a low-latency push channel that survives plain HTTP/1.1 + 2 hops, proxies, and corporate firewalls without the operational overhead of WebSocket transports. Server-Sent Events (SSE) is the right primitive: text/event-stream framing over a long-lived `GET`, with built-in browser reconnect, ordered delivery, and `Last-Event-ID` resume.
This package adds an SSE plugin that integrates with the `@suaveplan/api` ServiceRegistry, exposes a typed `sseService` for handlers to broadcast / subscribe through, and ships a Hono `streamSSE` adapter that handles per-connection heartbeat, backpressure, abort-on-disconnect, and `Last-Event-ID` resume from a bounded ring buffer. It is a sibling to (not replacement for) `@suaveplan/api-plugin-{rate-limit,audit,webhook}`.
## What changes
Net-new package at `0.1.0`. ADDED:
### Plugin factory + descriptor surface
- `createSsePlugin(options: SsePluginOptions): PluginDescriptor<SsePluginConfig>` — Zod-validated factory returning a `PluginDescriptor` from `@suaveplan/api-plugin`. The descriptor's `onInit` executes the canonical 6-step lifecycle: (1) resolve `app` from ServiceRegistry, (2) construct `RingBuffer` and `InProcessFanout`, (3) construct `SseService` instance, (4) register `sseService` in ServiceRegistry, (5) register the default `GET <routePrefix>/:channel` SSE route (when `attachDefaultRoute !== false`), (6) install a no-op `onClose` that closes all active connections and clears the buffer.
- `SsePluginOptions` (Zod): `{ heartbeatIntervalMs?: number (default 30_000); routePrefix?: string (default "/sse"); attachDefaultRoute?: boolean (default true); retentionPerChannel?: number (default 100); maxConnectionsPerPrincipal?: number (default 16); maxTotalConnections?: number (default 10_000); queueHighWaterMark?: number (default 256); overflowPolicy?: "drop-oldest" | "drop-newest" (default "drop-oldest"); idGenerator?: () => string; clock?: Clock; logger?: LoggerLike }`. `heartbeatIntervalMs` is honored per-connection.
- `SsePluginConfig` — descriptor's `configSchema`-inferred shape (resolved options).
### Service registry slot
- `sseService` registered via `ctx.registerService("sseService", ...)`. Surface: `{ broadcast(channel: string, event: SseEvent): void; subscribe(args: SubscribeArgs): SubscriptionHandle; close(channel?: string): Promise<void>; stats(): SseStats }` — sufficient for handlers wired by other plugins to push events without owning the framing/heartbeat/buffer code paths.
- `ServiceRegistry` module augmentation: `declare module "@suaveplan/api-plugin/service-registry" { interface ServiceRegistry { sseService: SseService } }`.
### Hono SSE handler
- `createSseRoute(service: SseService, options): RouteHandler` — Hono handler factory that wraps `streamSSE` from `hono/streaming`. Reads `Last-Event-ID` header for resume, threads the request's `AbortSignal` into the subscription, writes a `: heartbeat` comment line on the configured interval, drains the per-connection write queue with backpressure honoring `queueHighWaterMark` + `overflowPolicy`, and emits SSE-framed bytes `id: <id>\nevent: <event>\ndata: <json>\n\n`.
### Event ring buffer + resume
- `createRingBuffer(retention: number): RingBuffer` — bounded per-channel ring buffer. `append(channel, event)` records, `since(channel, lastEventId)` returns the slice of events whose ids strictly follow `lastEventId` (oldest-first; empty when not found).
### Fanout abstraction
- `Fanout` interface — `publish(channel, event): void; subscribe(channel, listener): () => void`. `InProcessFanout` impl ships in-package. Cross-process fanout (Redis, NATS) is out of scope here; the interface exists so a follow-up plugin can swap it in.
### SSE event + framing
- `SseEvent` — `{ id: string; event?: string; data: unknown; retry?: number }`.
- `formatSseFrame(event: SseEvent): string` — serializes to wire format (`id:`, `event:`, `data:`, blank line). `data` is JSON-stringified unless already a string.
- `parseSseStream(reader: ReadableStreamDefaultReader<Uint8Array>): AsyncIterableIterator<SseEvent>` — client-side helper for integration tests.
### Errors + observability
- `SseError extends SuaveplanError<SseErrorCode>` via `defineCodes("SSE", [...])`. Codes: `SSE_UNAUTHENTICATED`, `SSE_TOO_MANY_CONNECTIONS`, `SSE_PER_PRINCIPAL_LIMIT_EXCEEDED`, `SSE_CHANNEL_REQUIRED`, `SSE_SERVICE_NOT_AVAILABLE`, `SSE_INVALID_INPUT`, `SSE_QUEUE_OVERFLOW`, `SSE_CLOSED`.
- OTel: counter `sse.active_connections` (UpDownCounter), counter `sse.broadcasts_total{channel}`, counter `sse.heartbeats_total`, counter `sse.message_bytes_total`, counter `sse.queue_overflow_total{policy}`, histogram `sse.connection_duration_ms`.
### Subpath exports
`.`, `./plugin/plugin`, `./services/service`, `./middleware/sse-handler`, `./formatter/format`, `./formatter/parse`, `./buffer/ring-buffer`, `./fanout/in-process`, `./schemas/options`, `./schemas/event`, `./error-codes/error-codes`, `./otel/instruments`, `./types/types`, `./registry/service-registry`.
## Out of scope
- **Cross-process / multi-node fanout** — `RedisPubSubFanout`, NATS, Kafka. The `Fanout` interface is in place; an `@suaveplan/api-plugin-sse-redis` follow-up will implement it.
- **AuthN-by-default** — `subscribe` accepts an optional `principalId`. Wiring through `@suaveplan/api-plugin-auth.requireAuth()` is the consumer's job in the route handler; the default route is anonymous unless the consumer composes auth middleware in front of it.
- **WebSocket bridge** — separate transport, separate plugin.
- **Frontend SDK / React hooks** — backend only; consumers can use native `EventSource` or any SSE client.
- **Compression (gzip/Brotli)** — bandwidth optimization; deferred until benchmarked need.
- **Per-channel ACLs / filter predicates** — composition with `@suaveplan/permissions` is the right home, not this plugin's concern.
## Source mapping
This package derives from `../suaveplan/enterprise/api-plugin-sse.md` (104 lines). The genesis rewrite consolidates the upstream "current-state" footguns (Parameters cast, discarded factory config, module-global counter, no built-in HTTP adapter, no resume, no backpressure) into a clean shipping surface. Cross-process Redis fanout, gzip compression, ACL predicates, and benchmarks remain out of scope here per the explicit deferrals above.
| Requirement | Source lines | Notes |
|---|---|---|
| Plugin via `definePlugin()` with onInit / onClose | L13, L21 | adopted; expanded to 4 lifecycle hooks per genesis api-plugin |
| `SSEManager`-equivalent service (connect, disconnect, broadcast, stats) | L13, L23-L24 | renamed `SseService`; HTTP framing moved into the bundled handler |
| `formatSSEMessage` wire-format helper | L23 | renamed `formatSseFrame`; co-located with `parseSseStream` for symmetry |
| `SSEConfig` (`maxConnectionsPerClient`, `maxTotalConnections`, `heartbeatIntervalMs`) | L22 | adopted; `maxConnectionsPerClient` renamed `maxConnectionsPerPrincipal` to match genesis terminology |
| Honor factory-time config (fixes upstream smell) | L33, L43 | merged into Zod-validated options at the factory boundary |
| Replace module-global `connectionCounter` with injectable id-generator | L34, L44 | `options.idGenerator` (defaulting to UUIDv7) — no module-global state |
| Built-in Hono HTTP adapter (fixes upstream smell) | L34, L45 | `createSseRoute` ships in `./middleware/sse-handler` and is auto-mounted unless `attachDefaultRoute: false` |
| `Fanout` interface (in-process default) | L46 | interface shipped; cross-process impls scoped out per "Out of scope" |
| Event log + `Last-Event-ID` resume | L36, L47 | bounded ring buffer per channel; `retentionPerChannel` config |
| Back-pressure + queue-overflow drop policy | L38, L48 | `queueHighWaterMark` + `overflowPolicy: "drop-oldest"\|"drop-newest"` |
| Typed errors via `@suaveplan/error` | L52 | `SseError` + 8 `SSE_*` codes |
| OTel spans + metrics | L51 | counters + histograms via real `MeterProvider`; codes asserted with in-memory exporter |
| Drop upstream event-bus map cast in favor of typed `ServiceRegistry` augmentation | L31, L42 | done via `declare module "@suaveplan/api-plugin/service-registry"` |
| Sibling-plugin pattern (3-way plug-and-play exemplar) | n/a (genesis principle) | matches `api-plugin-auth` shape — Zod schemas at boundary, service slot, optional default route |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| 100% coverage threshold | Genesis-wide policy (CLAUDE.md), not package-specific |
| Subpath exports per feature | Genesis-wide policy (AGENTS.md §6) |
| `@suaveplan/testing/runner` for test imports | Genesis-wide policy (AGENTS.md §7 / rule 11) |
| Spec-conformance test file | Genesis-wide policy (AGENTS.md §13) |
## Verification approach
- `src/plugin/plugin.test.ts` — factory validation, descriptor shape, lifecycle hooks
- `src/plugin/on-init.test.ts` — 6-step onInit + error paths (missing `app`, route collision)
- `src/services/service.test.ts` — subscribe/broadcast/close/stats; limit enforcement
- `src/middleware/sse-handler.test.ts` — Hono handler framing, heartbeat, Last-Event-ID resume, abort
- `src/buffer/ring-buffer.test.ts` — append, since, retention rollover
- `src/fanout/in-process.test.ts` — publish/subscribe, unsubscribe
- `src/formatter/format.test.ts` — SSE wire-format serialization edge cases
- `src/formatter/parse.test.ts` — streaming parser
- `src/schemas/options.test.ts` / `src/schemas/event.test.ts` — Zod boundary validation
- `src/error-codes/error-codes.test.ts` — registry + every code is thrown by ≥1 production path
- `src/otel/instruments.test.ts` — instruments emitted from production code, asserted via real `MeterProvider`
- `src/api-plugin-sse.spec-conformance.test.ts` — type-level assertions on `SseService`, `ServiceRegistry` augmentation, `SseEvent`
- `tests/integration/full-stream.test.ts` — real Hono app + real `fetch` against a started server; client subscribes, broadcast delivers 3 ordered events, reconnect with `Last-Event-ID` skips delivered, heartbeat ticks via FakeClock
- `tests/dep-graph.test.ts` — deps + subpaths + sibling .md coverage
## Migration impact
None — additive only. The package is net-new and no consumer in the monorepo references it today. The optional dependent `@suaveplan/api` is unchanged; the plugin registers into the existing ServiceRegistry slot.
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/api-plugin-sse` to enumerate (expected: empty — net-new).
No dependents (verified by `bunx turbo ls --filter=...@suaveplan/api-plugin-sse`).
### Catalog additions required
None. All externals reuse catalog keys already present: `hono` (`catalog:`), `zod` (`catalog:forms`), `tslib` (`catalog:build`), `@opentelemetry/api` (`catalog:observability`).

View File

@@ -0,0 +1,267 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-plugin-sse (add-api-plugin-sse)
**Package**: `@suaveplan/api-plugin-sse`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: createSsePlugin returns a Zod-validated PluginDescriptor
The package SHALL export `createSsePlugin(options): PluginDescriptor<SsePluginConfig>` that validates `options` against `SsePluginOptionsSchema` at the factory boundary. Invalid input SHALL raise `SseError(SSE_INVALID_INPUT)` carrying the `ZodError` as `cause`. The returned descriptor SHALL declare `name: "sse"`, the resolved `configSchema`, and the four lifecycle hooks (`onRegister`, `onInit`, `onReady`, `onClose`) of which at least `onInit` and `onClose` are populated.
#### Scenario: factory produces a valid descriptor
- **WHEN** `createSsePlugin({})` is called with defaults
- **THEN** the returned descriptor satisfies `PluginDescriptor<SsePluginConfig>` with `name === "sse"` AND `onInit` / `onClose` defined
#### Scenario: invalid options raise SSE_INVALID_INPUT
- **WHEN** `createSsePlugin({ heartbeatIntervalMs: -1 } as never)` is called
- **THEN** `SseError(SSE_INVALID_INPUT)` is thrown with `cause instanceof ZodError`
**Verified by:** `src/plugin/plugin.test.ts::"createSsePlugin returns a valid PluginDescriptor"` AND `src/plugin/plugin.test.ts::"invalid options raise SSE_INVALID_INPUT"`
---
### Requirement: onInit registers sseService in ServiceRegistry and mounts default route
The descriptor's `onInit(ctx)` hook SHALL: (1) resolve `app` from `ctx`'s ServiceRegistry — when absent throw `SseError(SSE_SERVICE_NOT_AVAILABLE)` listing `app` as missing; (2) construct a `RingBuffer`, an `InProcessFanout`, and an `SseService`; (3) call `ctx.registerService("sseService", service)` so downstream plugins / route handlers consume it through the canonical registry; (4) when `options.attachDefaultRoute !== false`, mount `GET <routePrefix>/:channel` (default `/sse/:channel`) wired to `createSseRoute(service, ...)`. `onClose` SHALL call `service.close()` to terminate every open connection.
#### Scenario: missing app raises SSE_SERVICE_NOT_AVAILABLE
- **WHEN** the plugin initializes against a context whose `getService("app")` returns `undefined`
- **THEN** `SseError(SSE_SERVICE_NOT_AVAILABLE)` is thrown with `context.missing === ["app"]`
#### Scenario: sseService is registered and the default route is mounted
- **WHEN** the plugin initializes against a context with a Hono `app` registered
- **THEN** `ctx.getService("sseService")` returns the `SseService` AND a `GET /sse/<channel>` request returns `Content-Type: text/event-stream`
#### Scenario: attachDefaultRoute: false skips mounting
- **WHEN** the plugin initializes with `attachDefaultRoute: false`
- **THEN** `ctx.getService("sseService")` returns the service AND no route is registered under `routePrefix`
#### Scenario: onClose terminates every open connection
- **WHEN** the plugin's `onClose` runs after a subscriber is active
- **THEN** `service.stats().activeConnections === 0` after the hook resolves
**Verified by:** `src/plugin/on-init.test.ts::"missing app raises SSE_SERVICE_NOT_AVAILABLE"` AND `src/plugin/on-init.test.ts::"registers sseService and mounts default route"` AND `src/plugin/on-init.test.ts::"attachDefaultRoute false skips route registration"` AND `src/plugin/on-init.test.ts::"onClose terminates active connections"`
---
### Requirement: SseService exposes broadcast / subscribe / close / stats
The package SHALL register `sseService` in the ServiceRegistry with the typed surface `{ broadcast(channel, event): void; subscribe(args): SubscriptionHandle; close(channel?): Promise<void>; stats(): SseStats }`. `broadcast` SHALL append the event to the channel's ring buffer with an id supplied by the configured `idGenerator` (when the event has no id) AND publish it through the `Fanout` so every active subscriber to that channel receives it. `subscribe` SHALL: (a) assign a connection id; (b) enforce `maxTotalConnections` (throw `SseError(SSE_TOO_MANY_CONNECTIONS)`) and `maxConnectionsPerPrincipal` (throw `SseError(SSE_PER_PRINCIPAL_LIMIT_EXCEEDED)`); (c) when `lastEventId` is supplied, replay the buffered events strictly after that id before forwarding live events; (d) return a `SubscriptionHandle` exposing `id`, `events` (an `AsyncIterableIterator<SseEvent>`), and `close()`. `close(channel?)` SHALL terminate every subscription (optionally restricted to a single channel) and resolve once their iterators have completed. `stats()` SHALL return `{ activeConnections: number; perChannel: Record<string, number> }`.
#### Scenario: broadcast appends to buffer and fans out to subscribers
- **WHEN** a subscriber is active on channel `notifications` and `service.broadcast("notifications", { data: { kind: "ping" } })` is called
- **THEN** the subscriber's iterator yields an `SseEvent` with `data.kind === "ping"` AND the buffer's `since("notifications", "")` includes that event
#### Scenario: subscribe with Last-Event-ID replays missed events
- **WHEN** broadcasts produce ids `1`, `2`, `3` on a channel and a new subscriber connects with `lastEventId: "1"`
- **THEN** the iterator yields events `2` and `3` in order before any live event
#### Scenario: maxConnectionsPerPrincipal enforces per-principal cap
- **WHEN** `principalId: "u1"` already holds the configured cap and another `subscribe` is attempted with the same principalId
- **THEN** `SseError(SSE_PER_PRINCIPAL_LIMIT_EXCEEDED)` is thrown with `context.principalId === "u1"`
#### Scenario: maxTotalConnections enforces global cap
- **WHEN** `activeConnections === maxTotalConnections` and another `subscribe` is attempted
- **THEN** `SseError(SSE_TOO_MANY_CONNECTIONS)` is thrown with `context.max` carrying the cap
#### Scenario: close terminates active connections
- **WHEN** `service.close("notifications")` is called with two subscribers on that channel
- **THEN** both iterators complete AND `service.stats().perChannel["notifications"]` is `undefined` or `0`
**Verified by:** `src/services/service.test.ts::"broadcast appends to buffer and fans out to subscribers"` AND `src/services/service.test.ts::"subscribe with Last-Event-ID replays missed events"` AND `src/services/service.test.ts::"maxConnectionsPerPrincipal enforces per-principal cap"` AND `src/services/service.test.ts::"maxTotalConnections enforces global cap"` AND `src/services/service.test.ts::"close terminates active connections"`
---
### Requirement: Hono SSE handler streams text/event-stream with heartbeats, abort, and resume
`createSseRoute(service, options)` SHALL return a Hono `Handler` that: (a) reads `:channel` from the path; missing → throw `SseError(SSE_CHANNEL_REQUIRED)`; (b) reads `Last-Event-ID` from request headers; (c) calls `service.subscribe({ channel, lastEventId, principalId: options.resolvePrincipalId?.(c), signal: c.req.raw.signal })`; (d) returns a Hono `streamSSE` response whose body writes one `id: <id>\nevent: <event>\ndata: <json>\n\n` frame per event AND a `: heartbeat\n\n` comment on the configured `heartbeatIntervalMs`; (e) on stream abort (client disconnect), unsubscribes by calling `handle.close()`; (f) on backpressure detection (`controller.desiredSize === 0` for longer than one heartbeat tick), drops or buffers per `overflowPolicy` and increments `sse.queue_overflow_total{policy}`. The response status SHALL be `200` with `Content-Type: text/event-stream; charset=utf-8`, `Cache-Control: no-cache, no-transform`, `X-Accel-Buffering: no`, and `Connection: keep-alive`.
#### Scenario: handler streams framed events to a real fetch client
- **WHEN** a real Hono app with the plugin mounted serves `GET /sse/notifications` and a `fetch` client subscribes, then `service.broadcast("notifications", { data: { n: 1 } })`, `{ data: { n: 2 } }`, `{ data: { n: 3 } }` run
- **THEN** the client receives three `SseEvent`s in order with ids `1`, `2`, `3` (or whatever the configured idGenerator emits) AND the response carries the SSE content-type headers above
#### Scenario: Last-Event-ID resumes from the ring buffer
- **WHEN** the client reconnects with `Last-Event-ID: 2` after the same broadcasts
- **THEN** the client receives only event `3` before live events begin
#### Scenario: heartbeats fire on the configured interval
- **WHEN** `heartbeatIntervalMs` elapses on an open connection with a FakeClock-driven timer
- **THEN** the client observes a `: heartbeat\n\n` comment frame AND `sse.heartbeats_total` increments by one
#### Scenario: abort signal closes the subscription
- **WHEN** the client `AbortController.abort()`s mid-stream
- **THEN** `service.stats().activeConnections` drops by one within one event-loop tick
**Verified by:** `src/middleware/sse-handler.test.ts::"streams framed events to a real fetch client"` AND `src/middleware/sse-handler.test.ts::"Last-Event-ID resumes from the ring buffer"` AND `src/middleware/sse-handler.test.ts::"heartbeats fire on the configured interval"` AND `src/middleware/sse-handler.test.ts::"abort signal closes the subscription"` AND `tests/integration/full-stream.test.ts::"end-to-end fetch subscriber receives three ordered events"` AND `tests/integration/full-stream.test.ts::"reconnect with Last-Event-ID skips delivered events"`
---
### Requirement: SSE wire-format serializer and streaming parser
The package SHALL export `formatSseFrame(event: SseEvent): string` that serialises an event into the SSE wire format (`id: <id>\n`, optional `event: <name>\n`, optional `retry: <ms>\n`, `data: <serialised>\n\n`). `data` SHALL be JSON-stringified when not already a string; multi-line string `data` SHALL be split into one `data:` line per source line. The package SHALL also export `parseSseStream(reader)` — an async iterator that decodes a `ReadableStreamDefaultReader<Uint8Array>` into `SseEvent`s, used by the integration test client and by any consumer that needs to parse SSE.
#### Scenario: formatSseFrame emits id, event, data with correct delimiters
- **WHEN** `formatSseFrame({ id: "42", event: "ping", data: { n: 1 } })` is called
- **THEN** the result equals `"id: 42\nevent: ping\ndata: {\"n\":1}\n\n"`
#### Scenario: multi-line string data is split into multiple data: lines
- **WHEN** `formatSseFrame({ id: "1", data: "a\nb" })` is called
- **THEN** the result contains `"data: a\ndata: b\n"`
#### Scenario: parseSseStream yields events for each frame
- **WHEN** a stream emits two frames separated by a blank line
- **THEN** the iterator yields two `SseEvent`s with the parsed `id`, `event`, and `data` fields
**Verified by:** `src/formatter/format.test.ts::"emits id, event, data with correct delimiters"` AND `src/formatter/format.test.ts::"multi-line string data splits into multiple data lines"` AND `src/formatter/parse.test.ts::"yields events for each frame in the stream"`
---
### Requirement: Bounded per-channel ring buffer powers replay
`createRingBuffer(retentionPerChannel)` SHALL return an object with `append(channel: string, event: SseEvent): void` and `since(channel: string, lastEventId: string): readonly SseEvent[]`. The buffer SHALL retain at most `retentionPerChannel` events per channel (oldest evicted on overflow). `since` SHALL return all events whose ids strictly follow `lastEventId` (oldest first) and an empty array when `lastEventId` is not found in the buffer (so a stale client starts fresh rather than receiving the entire buffer).
#### Scenario: append + since returns events after the cursor
- **WHEN** `append("c", { id: "1", data: {} })`, `append("c", { id: "2", data: {} })`, `append("c", { id: "3", data: {} })` and `since("c", "1")` is called
- **THEN** the returned array contains events with ids `2` and `3` in order
#### Scenario: retention rolls over
- **WHEN** `retention = 2` and three events are appended
- **THEN** `since("c", "")` returns the latest two events only
#### Scenario: unknown lastEventId returns empty
- **WHEN** `since("c", "non-existent")` is called against a non-empty buffer
- **THEN** the returned array is empty
**Verified by:** `src/buffer/ring-buffer.test.ts::"append + since returns events after the cursor"` AND `src/buffer/ring-buffer.test.ts::"retention rolls over"` AND `src/buffer/ring-buffer.test.ts::"unknown lastEventId returns empty"`
---
### Requirement: Fanout interface and in-process implementation
The package SHALL export a `Fanout` interface — `publish(channel: string, event: SseEvent): void` and `subscribe(channel: string, listener: (event: SseEvent) => void): () => void` — and a concrete `InProcessFanout` implementation. `subscribe` SHALL return an unsubscribe function; calling it removes the listener and prevents future deliveries.
#### Scenario: publish delivers to every subscribed listener for the channel
- **WHEN** two listeners are subscribed on `"a"` and one on `"b"`, then `publish("a", event)` fires
- **THEN** both `"a"` listeners receive the event AND the `"b"` listener does not
#### Scenario: unsubscribe stops further deliveries
- **WHEN** a listener subscribes, then its returned unsubscribe is called, then `publish` runs
- **THEN** the listener does not receive the event
**Verified by:** `src/fanout/in-process.test.ts::"publish delivers to every listener for the channel"` AND `src/fanout/in-process.test.ts::"unsubscribe stops further deliveries"`
---
### Requirement: Zod schemas validate options and event payloads at the boundary
The package SHALL export `SsePluginOptionsSchema` (Zod) validating every field of `SsePluginOptions` (positive integers for limits, positive `heartbeatIntervalMs`, `overflowPolicy` enum, optional functions). The package SHALL export `SseEventSchema` (Zod) validating the `SseEvent` shape used by callers of `service.broadcast`. Invalid input to either schema SHALL flow through `SseError(SSE_INVALID_INPUT)`.
#### Scenario: SsePluginOptionsSchema rejects non-positive heartbeat
- **WHEN** `SsePluginOptionsSchema.safeParse({ heartbeatIntervalMs: 0 })` is called
- **THEN** the result has `success === false` with an issue on `heartbeatIntervalMs`
#### Scenario: SseEventSchema accepts arbitrary JSON-serialisable data
- **WHEN** `SseEventSchema.safeParse({ id: "1", data: { nested: { arr: [1, 2] } } })` is called
- **THEN** `success === true`
**Verified by:** `src/schemas/options.test.ts::"rejects non-positive heartbeat"` AND `src/schemas/event.test.ts::"accepts arbitrary JSON data"`
---
### Requirement: SseError registry with eight stable codes thrown from production paths
The package SHALL declare `SSE_CODES = defineCodes("SSE", [...])` covering `SSE_UNAUTHENTICATED`, `SSE_TOO_MANY_CONNECTIONS`, `SSE_PER_PRINCIPAL_LIMIT_EXCEEDED`, `SSE_CHANNEL_REQUIRED`, `SSE_SERVICE_NOT_AVAILABLE`, `SSE_INVALID_INPUT`, `SSE_QUEUE_OVERFLOW`, `SSE_CLOSED`. Every code SHALL be reachable from at least one production call site (asserted by the spec-conformance test).
#### Scenario: every code is thrown by a production path
- **WHEN** the spec-conformance suite exercises the documented trigger for each code
- **THEN** an `SseError` instance is thrown carrying that code
**Verified by:** `src/error-codes/error-codes.test.ts::"every SSE_* code is reachable from a production path"` AND `src/api-plugin-sse.spec-conformance.test.ts::"every SSE_* code is thrown from src/"`
---
### Requirement: OpenTelemetry instruments are emitted from production paths
The package SHALL register OTel instruments via `getSseInstruments()` and emit each from production code: `sse.active_connections` (UpDownCounter — +1 on subscribe, −1 on close), `sse.broadcasts_total{channel}` (Counter on broadcast), `sse.heartbeats_total` (Counter on heartbeat tick), `sse.message_bytes_total` (Counter on every framed write), `sse.queue_overflow_total{policy}` (Counter on overflow), `sse.connection_duration_ms` (Histogram on close). The conformance test SHALL install a real in-memory `MeterProvider`, exercise each path, and assert the metric was recorded.
#### Scenario: subscribe + close moves active_connections up and back down
- **WHEN** a subscription is created and then closed against a real `MeterProvider`
- **THEN** the collected metrics show `sse.active_connections` increased by 1 then decreased by 1
#### Scenario: broadcast increments broadcasts_total{channel}
- **WHEN** `service.broadcast("notifications", event)` is called
- **THEN** the collected metrics show `sse.broadcasts_total` with attribute `channel === "notifications"` incremented by 1
**Verified by:** `src/otel/instruments.test.ts::"subscribe + close emits active_connections"` AND `src/otel/instruments.test.ts::"broadcast emits broadcasts_total"`
---
### Requirement: Backpressure honors queueHighWaterMark and overflowPolicy
The Hono SSE handler SHALL track per-connection write-queue depth. When the writable controller's `desiredSize` falls to zero or the in-memory backlog exceeds `queueHighWaterMark`, the handler SHALL apply `overflowPolicy`: `"drop-oldest"` removes the oldest queued event before enqueueing the new one; `"drop-newest"` discards the incoming event. In both cases, `sse.queue_overflow_total{policy}` SHALL increment.
#### Scenario: drop-oldest evicts the oldest queued event
- **WHEN** `queueHighWaterMark = 2`, `overflowPolicy = "drop-oldest"`, and three events arrive faster than the controller drains
- **THEN** the third event lands in the queue AND the first is dropped AND `sse.queue_overflow_total{policy: "drop-oldest"}` increments
**Verified by:** `src/middleware/sse-handler.test.ts::"drop-oldest evicts the oldest queued event under backpressure"`
---
### Requirement: ContextVariableMap untouched; subscriber identity flows via options.resolvePrincipalId
The package SHALL NOT augment Hono's global `ContextVariableMap`. Subscriber principal identity SHALL be resolved via `options.resolvePrincipalId?: (c: Context) => string | undefined` passed to `createSseRoute`, so consumers wiring `@suaveplan/api-plugin-auth` upstream can read `c.get("user")?.id` and feed it in without this plugin depending on the auth package.
#### Scenario: resolvePrincipalId is invoked once per subscription
- **WHEN** a request reaches the handler and `resolvePrincipalId` is configured
- **THEN** the resolver is called with the Hono `Context` and the returned value is passed to `service.subscribe` as `principalId`
**Verified by:** `src/middleware/sse-handler.test.ts::"resolvePrincipalId is invoked once per subscription"`
---
### Requirement: Subpath exports per feature module
The package SHALL declare subpath exports in `package.json` so each feature module is independently importable: `.`, `./plugin/plugin`, `./services/service`, `./middleware/sse-handler`, `./formatter/format`, `./formatter/parse`, `./buffer/ring-buffer`, `./fanout/in-process`, `./schemas/options`, `./schemas/event`, `./error-codes/error-codes`, `./otel/instruments`, `./types/types`, `./registry/service-registry`.
#### Scenario: dep-graph test enumerates expected subpaths
- **WHEN** `tests/dep-graph.test.ts` reads `package.json`'s `exports` map
- **THEN** the list of `./*` entries matches the documented set exactly
**Verified by:** `tests/dep-graph.test.ts::"exports map advertises every feature subpath"`

View File

@@ -0,0 +1,67 @@
# Tasks — add-api-plugin-sse
## 1. Spec authoring
- [x] 1.1 Read `../suaveplan/enterprise/api-plugin-sse.md` end-to-end
- [x] 1.2 Read genesis api-plugin-auth reference impl
- [x] 1.3 Read `openspec/AGENTS.md` §13/§14 + enterprise-package-migration skill
- [x] 1.4 Draft `proposal.md` from template with Source mapping
- [x] 1.5 Draft `specs/api-plugin-sse/spec.md` with `**Verified by:**` lines
## 2. Implementation
- [x] 2.1 Scaffold `packages/api/api-plugin-sse/` with src/{plugin,services,middleware,formatter,buffer,fanout,schemas,otel,error-codes,types,registry}/ + tests/{setup,integration}/
- [x] 2.2 `package.json` v0.1.0 with 14 subpath exports + tsconfig + vite + vitest configs
- [x] 2.3 `src/error-codes/error-codes.{ts,test,md}` — 8 SSE.* codes + `SseError`
- [x] 2.4 `src/types/types.ts` — `SseEvent`, `SubscriptionHandle`, `SseStats`, `SsePluginOptions`, `SsePluginConfig`
- [x] 2.5 `src/schemas/options.{ts,test,md}` + `src/schemas/event.{ts,test,md}` — Zod boundary schemas
- [x] 2.6 `src/buffer/ring-buffer.{ts,test,md}` — bounded per-channel ring buffer
- [x] 2.7 `src/fanout/in-process.{ts,test,md}` — listener-set fanout
- [x] 2.8 `src/formatter/format.{ts,test,md}` + `src/formatter/parse.{ts,test,md}` — wire format + streaming parser
- [x] 2.9 `src/otel/instruments.{ts,test,md}` — UpDownCounter + Counters + Histogram
- [x] 2.10 `src/services/service.{ts,test,md}` — `SseService` impl (broadcast/subscribe/close/stats)
- [x] 2.11 `src/registry/service-registry.{ts,md}` — module augmentation
- [x] 2.12 `src/middleware/sse-handler.{ts,test,md}` — Hono `streamSSE` handler with heartbeat/abort/resume/backpressure
- [x] 2.13 `src/plugin/plugin.{ts,test,md}` + `src/plugin/on-init.test.ts` — `createSsePlugin` + 6-step onInit
- [x] 2.14 `src/index.ts` — named re-exports
- [x] 2.15 `src/api-plugin-sse.spec-conformance.test.ts` — type-level + every-code-thrown assertions
## 3. Tests
- [x] 3.1 Co-located `*.test.ts` per scenario
- [x] 3.2 Spec-conformance test pinning types + error registry reachability
- [x] 3.3 `tests/integration/full-stream.test.ts` — real Hono app + real fetch; 3 ordered events, Last-Event-ID resume, heartbeat via FakeClock
- [x] 3.4 `tests/dep-graph.test.ts` — exports map matches documented subpaths
- [x] 3.5 All tests imported from `@suaveplan/testing/runner`; `{ timeout: 5000 }` (60000 for integration)
## 4. Documentation
- [x] 4.1 Sibling `.md` for every impl (≥200 words, basic + advanced examples, full API reference)
- [x] 4.2 README with quick-start, modules, 3-way contract notes
## 5. Pre-archive 5-gate
- [x] 5.1 `bun run check` — Biome 0/0/0 across the monorepo
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-plugin-sse --force`
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-plugin-sse` 100/100/100/100
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/api-plugin-sse` exit 0
- [x] 5.5 `bunx turbo build --filter=@suaveplan/api-plugin-sse --force`
- [x] 5.6 `bun scripts/lint/check-test-runner-imports.ts` exit 0
## 6. Pre-archive 9-row checklist
- [x] 6.1 Generics threaded; `expectTypeOf` on `SseService` / `ServiceRegistry` augmentation / `SseEvent`
- [x] 6.2 Zod schemas wired at every declared boundary
- [x] 6.3 Every error code in `error-codes.ts` thrown by ≥1 production path
- [x] 6.4 Strict-mode behaviors tested both on and off (overflowPolicy variants)
- [x] 6.5 OTel instruments emitted + asserted via real in-memory `MeterProvider`
- [x] 6.6 Lifecycle dedup tested (idempotent close)
- [x] 6.7 No retroactive tick — every box reflects work in this change
- [x] 6.8 No `export *` in feature barrels (grep clean)
- [x] 6.9 No direct vitest/bun:test imports in tests (grep clean)
## 7. Archive
- [x] 7.1 Fold spec → `openspec/specs/api-plugin-sse/spec.md`
- [x] 7.2 `mv openspec/changes/add-api-plugin-sse → openspec/archive/2026-05-18-add-api-plugin-sse/`
- [x] 7.3 DO NOT refresh MANIFEST / STATUS per orchestrator directive