11 KiB
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 aPluginDescriptorfrom@suaveplan/api-plugin. The descriptor'sonInitexecutes the canonical 6-step lifecycle: (1) resolveappfrom ServiceRegistry, (2) constructRingBufferandInProcessFanout, (3) constructSseServiceinstance, (4) registersseServicein ServiceRegistry, (5) register the defaultGET <routePrefix>/:channelSSE route (whenattachDefaultRoute !== false), (6) install a no-oponClosethat 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 }.heartbeatIntervalMsis honored per-connection.SsePluginConfig— descriptor'sconfigSchema-inferred shape (resolved options).
Service registry slot
sseServiceregistered viactx.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.ServiceRegistrymodule augmentation:declare module "@suaveplan/api-plugin/service-registry" { interface ServiceRegistry { sseService: SseService } }.
Hono SSE handler
createSseRoute(service: SseService, options): RouteHandler— Hono handler factory that wrapsstreamSSEfromhono/streaming. ReadsLast-Event-IDheader for resume, threads the request'sAbortSignalinto the subscription, writes a: heartbeatcomment line on the configured interval, drains the per-connection write queue with backpressure honoringqueueHighWaterMark+overflowPolicy, and emits SSE-framed bytesid: <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 followlastEventId(oldest-first; empty when not found).
Fanout abstraction
Fanoutinterface —publish(channel, event): void; subscribe(channel, listener): () => void.InProcessFanoutimpl 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).datais JSON-stringified unless already a string.parseSseStream(reader: ReadableStreamDefaultReader<Uint8Array>): AsyncIterableIterator<SseEvent>— client-side helper for integration tests.
Errors + observability
SseError extends SuaveplanError<SseErrorCode>viadefineCodes("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), countersse.broadcasts_total{channel}, countersse.heartbeats_total, countersse.message_bytes_total, countersse.queue_overflow_total{policy}, histogramsse.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. TheFanoutinterface is in place; an@suaveplan/api-plugin-sse-redisfollow-up will implement it. - AuthN-by-default —
subscribeaccepts an optionalprincipalId. 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
EventSourceor any SSE client. - Compression (gzip/Brotli) — bandwidth optimization; deferred until benchmarked need.
- Per-channel ACLs / filter predicates — composition with
@suaveplan/permissionsis 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 hookssrc/plugin/on-init.test.ts— 6-step onInit + error paths (missingapp, route collision)src/services/service.test.ts— subscribe/broadcast/close/stats; limit enforcementsrc/middleware/sse-handler.test.ts— Hono handler framing, heartbeat, Last-Event-ID resume, abortsrc/buffer/ring-buffer.test.ts— append, since, retention rolloversrc/fanout/in-process.test.ts— publish/subscribe, unsubscribesrc/formatter/format.test.ts— SSE wire-format serialization edge casessrc/formatter/parse.test.ts— streaming parsersrc/schemas/options.test.ts/src/schemas/event.test.ts— Zod boundary validationsrc/error-codes/error-codes.test.ts— registry + every code is thrown by ≥1 production pathsrc/otel/instruments.test.ts— instruments emitted from production code, asserted via realMeterProvidersrc/api-plugin-sse.spec-conformance.test.ts— type-level assertions onSseService,ServiceRegistryaugmentation,SseEventtests/integration/full-stream.test.ts— real Hono app + realfetchagainst a started server; client subscribes, broadcast delivers 3 ordered events, reconnect withLast-Event-IDskips delivered, heartbeat ticks via FakeClocktests/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).