import type { RingBuffer } from "../buffer/ring-buffer.js"; import { SSE_CODES, SseError } from "../error-codes/error-codes.js"; import type { Fanout } from "../fanout/in-process.js"; import { getSseInstruments } from "../otel/instruments.js"; import { SseEventSchema } from "../schemas/event.js"; import type { SseEvent, SseEventInput, SseStats, SubscribeArgs, SubscriptionHandle, } from "../types/types.js"; /** * Constructor options for `createSseService`. The plugin lifecycle * supplies all four — callers using the service outside the plugin * (rare) can build them by hand. */ export interface CreateSseServiceOptions { readonly buffer: RingBuffer; readonly fanout: Fanout; readonly idGenerator: () => string; readonly maxConnectionsPerPrincipal: number; readonly maxTotalConnections: number; } /** * Public surface registered into the host `ServiceRegistry` under the * `sseService` slot. Route handlers and downstream plugins consume * this — never the underlying `Fanout` or `RingBuffer` directly — so * the plugin can swap implementations without breaking consumers. */ export interface SseService { broadcast(channel: string, event: SseEventInput): SseEvent; subscribe(args: SubscribeArgs): SubscriptionHandle; close(channel?: string): Promise; stats(): SseStats; } type Pending = { resolve: (v: IteratorResult) => void }; interface Subscription { readonly id: string; readonly channel: string; readonly principalId: string | undefined; readonly queue: SseEvent[]; closed: boolean; pending: Pending | undefined; unsubscribeFanout: (() => void) | undefined; signal: AbortSignal | undefined; abortHandler: (() => void) | undefined; } /** * Construct an `SseService`. The plugin's `onInit` is the only * caller in production; tests may call it directly for unit * coverage of the service in isolation. */ export function createSseService(options: CreateSseServiceOptions): SseService { const { buffer, fanout, idGenerator, maxConnectionsPerPrincipal, maxTotalConnections, } = options; const subscriptions = new Map(); const perPrincipal = new Map(); let closedAll = false; function takeStats(): SseStats { const perChannel: Record = {}; for (const sub of subscriptions.values()) { perChannel[sub.channel] = (perChannel[sub.channel] ?? 0) + 1; } return { activeConnections: subscriptions.size, perChannel }; } function detach(sub: Subscription): void { if (sub.closed) return; sub.closed = true; sub.unsubscribeFanout?.(); if (sub.signal !== undefined && sub.abortHandler !== undefined) { sub.signal.removeEventListener("abort", sub.abortHandler); } subscriptions.delete(sub.id); if (sub.principalId !== undefined) { // subscribe() always sets perPrincipal[principalId] >= 1 before // creating the subscription, so the map entry is guaranteed. const next = (perPrincipal.get(sub.principalId) as number) - 1; if (next <= 0) perPrincipal.delete(sub.principalId); else perPrincipal.set(sub.principalId, next); } getSseInstruments().activeConnections.add(-1, { channel: sub.channel }); if (sub.pending !== undefined) { const p = sub.pending; sub.pending = undefined; p.resolve({ value: undefined, done: true }); } } return { broadcast(channel, eventInput) { if (closedAll) { throw new SseError( SSE_CODES.CLOSED, "broadcast called after service.close()", { context: { channel } } ); } const parsed = SseEventSchema.safeParse(eventInput); if (!parsed.success) { throw new SseError( SSE_CODES.INVALID_INPUT, "broadcast received invalid SseEvent", { cause: parsed.error, context: { channel } } ); } const event: SseEvent = { id: parsed.data.id ?? idGenerator(), ...(parsed.data.event !== undefined ? { event: parsed.data.event } : {}), ...(parsed.data.retry !== undefined ? { retry: parsed.data.retry } : {}), data: parsed.data.data, }; buffer.append(channel, event); getSseInstruments().broadcastsTotal.add(1, { channel }); fanout.publish(channel, event); return event; }, subscribe(args) { if (closedAll) { throw new SseError( SSE_CODES.CLOSED, "subscribe called after service.close()", { context: { channel: args.channel } } ); } if (args.channel === "") { throw new SseError( SSE_CODES.CHANNEL_REQUIRED, "subscribe requires a non-empty channel" ); } if (subscriptions.size >= maxTotalConnections) { throw new SseError( SSE_CODES.TOO_MANY_CONNECTIONS, `Global active-connection cap reached (${maxTotalConnections})`, { context: { max: maxTotalConnections } } ); } if (args.principalId !== undefined) { const current = perPrincipal.get(args.principalId) ?? 0; if (current >= maxConnectionsPerPrincipal) { throw new SseError( SSE_CODES.PER_PRINCIPAL_LIMIT_EXCEEDED, `Principal '${args.principalId}' already holds ${current} connections`, { context: { principalId: args.principalId, max: maxConnectionsPerPrincipal, }, } ); } perPrincipal.set(args.principalId, current + 1); } const id = idGenerator(); const sub: Subscription = { id, channel: args.channel, principalId: args.principalId, queue: [], closed: false, pending: undefined, unsubscribeFanout: undefined, signal: args.signal, abortHandler: undefined, }; if (args.lastEventId !== undefined) { for (const ev of buffer.since(args.channel, args.lastEventId)) { sub.queue.push(ev); } } sub.unsubscribeFanout = fanout.subscribe(args.channel, (event) => { if (sub.pending !== undefined) { const p = sub.pending; sub.pending = undefined; p.resolve({ value: event, done: false }); } else { sub.queue.push(event); } }); if (args.signal !== undefined) { const handler = () => detach(sub); sub.abortHandler = handler; if (args.signal.aborted) { queueMicrotask(handler); } else { args.signal.addEventListener("abort", handler, { once: true, }); } } subscriptions.set(id, sub); getSseInstruments().activeConnections.add(1, { channel: args.channel, }); const handle: SubscriptionHandle = { id, channel: args.channel, events: createIterator(sub), close: async () => { detach(sub); }, }; return handle; }, async close(channel) { if (channel === undefined) closedAll = true; const victims: Subscription[] = []; for (const sub of subscriptions.values()) { if (channel === undefined || sub.channel === channel) { victims.push(sub); } } for (const v of victims) detach(v); }, stats: takeStats, }; } function createIterator(sub: Subscription): AsyncIterableIterator { const iterator: AsyncIterableIterator = { next() { if (sub.queue.length > 0) { const value = sub.queue.shift() as SseEvent; return Promise.resolve({ value, done: false }); } if (sub.closed) { return Promise.resolve({ value: undefined, done: true }); } return new Promise>((resolve) => { sub.pending = { resolve }; }); }, return() { if (!sub.closed) sub.closed = true; if (sub.pending !== undefined) { const p = sub.pending; sub.pending = undefined; p.resolve({ value: undefined, done: true }); } return Promise.resolve({ value: undefined, done: true }); }, [Symbol.asyncIterator]() { return iterator; }, }; return iterator; }