Files
suaveplan/openspec/archive/2026-05-19-add-api-plugin-sse/proposal.md
2026-05-27 23:29:53 +01:00

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 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).