# 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` — 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 /: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; 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: \nevent: \ndata: \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): AsyncIterableIterator` — client-side helper for integration tests. ### Errors + observability - `SseError extends SuaveplanError` 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`).