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