19 KiB
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>withname === "sse"ANDonInit/onClosedefined
Scenario: invalid options raise SSE_INVALID_INPUT
- WHEN
createSsePlugin({ heartbeatIntervalMs: -1 } as never)is called - THEN
SseError(SSE_INVALID_INPUT)is thrown withcause 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")returnsundefined - THEN
SseError(SSE_SERVICE_NOT_AVAILABLE)is thrown withcontext.missing === ["app"]
Scenario: sseService is registered and the default route is mounted
- WHEN the plugin initializes against a context with a Hono
appregistered - THEN
ctx.getService("sseService")returns theSseServiceAND aGET /sse/<channel>request returnsContent-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 underroutePrefix
Scenario: onClose terminates every open connection
- WHEN the plugin's
onCloseruns after a subscriber is active - THEN
service.stats().activeConnections === 0after 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
notificationsandservice.broadcast("notifications", { data: { kind: "ping" } })is called - THEN the subscriber's iterator yields an
SseEventwithdata.kind === "ping"AND the buffer'ssince("notifications", "")includes that event
Scenario: subscribe with Last-Event-ID replays missed events
- WHEN broadcasts produce ids
1,2,3on a channel and a new subscriber connects withlastEventId: "1" - THEN the iterator yields events
2and3in order before any live event
Scenario: maxConnectionsPerPrincipal enforces per-principal cap
- WHEN
principalId: "u1"already holds the configured cap and anothersubscribeis attempted with the same principalId - THEN
SseError(SSE_PER_PRINCIPAL_LIMIT_EXCEEDED)is thrown withcontext.principalId === "u1"
Scenario: maxTotalConnections enforces global cap
- WHEN
activeConnections === maxTotalConnectionsand anothersubscribeis attempted - THEN
SseError(SSE_TOO_MANY_CONNECTIONS)is thrown withcontext.maxcarrying 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"]isundefinedor0
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/notificationsand afetchclient subscribes, thenservice.broadcast("notifications", { data: { n: 1 } }),{ data: { n: 2 } },{ data: { n: 3 } }run - THEN the client receives three
SseEvents in order with ids1,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: 2after the same broadcasts - THEN the client receives only event
3before live events begin
Scenario: heartbeats fire on the configured interval
- WHEN
heartbeatIntervalMselapses on an open connection with a FakeClock-driven timer - THEN the client observes a
: heartbeat\n\ncomment frame ANDsse.heartbeats_totalincrements by one
Scenario: abort signal closes the subscription
- WHEN the client
AbortController.abort()s mid-stream - THEN
service.stats().activeConnectionsdrops 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 SseEvents, 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
SseEvents with the parsedid,event, anddatafields
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: {} })andsince("c", "1")is called - THEN the returned array contains events with ids
2and3in order
Scenario: retention rolls over
- WHEN
retention = 2and 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", thenpublish("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
publishruns - 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 === falsewith an issue onheartbeatIntervalMs
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
SseErrorinstance 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_connectionsincreased 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_totalwith attributechannel === "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
resolvePrincipalIdis configured - THEN the resolver is called with the Hono
Contextand the returned value is passed toservice.subscribeasprincipalId
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.tsreadspackage.json'sexportsmap - THEN the list of
./*entries matches the documented set exactly
Verified by: tests/dep-graph.test.ts::"exports map advertises every feature subpath"