/** * Fastify plugin that registers an SSE endpoint. * Product backends configure the path and optional auth check. */ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { SSEHub } from './hub.js'; export interface SSEClient { id: string; userId?: string; } export interface SSEPluginOptions { /** Route path for the SSE endpoint (default: /events/stream) */ path?: string; /** Extract userId from request (optional, enables per-user targeting) */ getUserId?: (req: FastifyRequest) => string | undefined; /** Heartbeat interval in ms (default: 30000, set 0 to disable) */ heartbeatIntervalMs?: number; /** The SSE hub instance to use (creates one if not provided) */ hub?: SSEHub; } export async function ssePlugin( app: FastifyInstance, options: SSEPluginOptions = {} ): Promise { const path = options.path ?? '/events/stream'; const heartbeatMs = options.heartbeatIntervalMs ?? 30_000; const hub = options.hub ?? new SSEHub(); // Decorate app with the hub so routes can push events if (!app.hasDecorator('sseHub')) { app.decorate('sseHub', hub); } // Register SSE endpoint app.get(path, async (req: FastifyRequest, reply: FastifyReply) => { const userId = options.getUserId?.(req); // Hijack the raw response for SSE streaming const raw = reply.raw; hub.addClient(raw, userId); // Prevent Fastify from sending its own response reply.hijack(); }); // Heartbeat timer let heartbeatTimer: ReturnType | undefined; if (heartbeatMs > 0) { heartbeatTimer = setInterval(() => hub.heartbeat(), heartbeatMs); } // Cleanup on close app.addHook('onClose', async () => { if (heartbeatTimer) clearInterval(heartbeatTimer); hub.disconnectAll(); }); }