4.7 KiB
4.7 KiB
service
(no submodules)
Purpose
Core SSE service registered under the sseService slot in the host ServiceRegistry. Owns subscription lifecycle (id assignment, per-principal and global caps, abort-signal threading), ring-buffer append on broadcast, fanout delivery to live subscribers, and clean termination of every active iterator on close. Route handlers consume this service via ctx.getService("sseService") and never touch the underlying buffer/fanout directly.
Features
- Per-subscription
AsyncIterableIterator<SseEvent>that resolves the next event from a backlog queue or a pending promise — so callers canfor awaitwithout polling. Last-Event-IDreplay: on subscribe withlastEventId, the service pre-fills the backlog from the ring buffer before forwarding live events.- Global
maxTotalConnectionsand per-principalmaxConnectionsPerPrincipalcaps with typed errors. AbortSignalthreading: client disconnect closes the subscription deterministically; pre-aborted signals are handled viaqueueMicrotask.close(channel?)terminates every iterator (optionally restricted to a single channel) and rejects furthersubscribe/broadcastcalls withSseError(SSE_CLOSED).stats()returns a live snapshot of active counts globally and per channel.- Every public failure path raises a typed
SseError;causechains preserve the underlyingZodErrorwhen relevant.
Usage: Basic
import { createSseService } from "@suaveplan/api-plugin-sse/services/service";
import { createRingBuffer } from "@suaveplan/api-plugin-sse/buffer/ring-buffer";
import { createInProcessFanout } from "@suaveplan/api-plugin-sse/fanout/in-process";
const service = createSseService({
buffer: createRingBuffer(100),
fanout: createInProcessFanout(),
idGenerator: () => crypto.randomUUID(),
maxConnectionsPerPrincipal: 16,
maxTotalConnections: 10_000,
});
const handle = service.subscribe({ channel: "notifications" });
service.broadcast("notifications", { data: { kind: "ping" } });
for await (const event of handle.events) {
console.log(event);
break;
}
await handle.close();
Usage: Advanced
import { createSseService } from "@suaveplan/api-plugin-sse/services/service";
const service = createSseService({ /* ... */ });
// Subscribe with Last-Event-ID resume and an abort signal.
const controller = new AbortController();
const handle = service.subscribe({
channel: "notifications",
lastEventId: "evt-42",
principalId: "user-123",
signal: controller.signal,
});
// Disconnect on user navigation.
window.addEventListener("beforeunload", () => controller.abort());
try {
for await (const event of handle.events) {
renderEvent(event);
}
} catch (err) {
// Abort signal will resolve the iterator with done:true, not throw.
// Errors here come from custom listener implementations.
}
console.log(service.stats());
// { activeConnections: 1, perChannel: { notifications: 1 } }
API Reference
interface CreateSseServiceOptions—{ buffer, fanout, idGenerator, maxConnectionsPerPrincipal, maxTotalConnections }.createSseService(options: CreateSseServiceOptions): SseService— constructs the service.interface SseService:broadcast(channel: string, event: SseEventInput): SseEvent— validates the event, appends to buffer, publishes via fanout. Returns the resolved event (with generated id when not supplied).subscribe(args: SubscribeArgs): SubscriptionHandle— enforces caps, replays fromlastEventIdif supplied, threadssignal. Returns a handle exposing the iterator and aclose().close(channel?: string): Promise<void>— terminates every active subscription (optionally restricted to a single channel). Subsequentbroadcast/subscribecalls raiseSSE_CLOSEDwhen called without a channel.stats(): SseStats— snapshot{ activeConnections, perChannel }.
Implementation Notes
- The iterator's
return()method is honored sofor await (const ev of handle.events) { break }cleans up the pending promise rather than leaking it. - Per-principal counts are tracked in a separate
Map<string, number>for O(1) cap enforcement. The map is pruned when a principal's count returns to zero. - The fanout listener captures
sub.closedinside the closure so deliveries afterdetachare silent no-ops — no race between unsubscribe and the nextpublish. close()without a channel sets aclosedAlllatch so subsequent operations fail fast rather than racing the pending teardown.- The
idGeneratoris intentionally caller-provided so tests can use deterministic ids; the plugin layer suppliescrypto.randomUUID()as the default.