UNPKG

opinionated-machine

Version:

Very opinionated DI framework for fastify, built on top of awilix

448 lines 16.7 kB
import { randomUUID } from 'node:crypto'; import { InternalError } from '@lokalise/node-core'; import { SSESessionSpy } from "./SSESessionSpy.js"; export { SSESessionSpy } from "./SSESessionSpy.js"; /** * Abstract base class for SSE controllers. * * Provides connection management, broadcasting, and lifecycle hooks. * Extend this class to create SSE controllers that handle real-time * streaming connections. * * @template APIContracts - Map of route names to SSE route definitions * * @example * ```typescript * class NotificationsSSEController extends AbstractSSEController<typeof contracts> { * public static contracts = { * notifications: buildSseContract({ ... }), * } as const * * public buildSSERoutes() { * return { * notifications: this.handleNotifications, * } * } * * private handleNotifications = buildHandler( * NotificationsSSEController.contracts.notifications, * { * sse: async (request, sse) => { * const session = sse.start('autoClose') * await session.send('notification', { message: 'Hello!' }) * // Connection closes automatically when handler returns * }, * }, * ) * } * ``` */ export class AbstractSSEController { /** Map of connection ID to connection object */ connections = new Map(); /** Private storage for connection spy */ _connectionSpy; /** Room manager for room-based broadcasting (optional) */ _roomManager; /** Room broadcaster for decoupled room broadcasting (optional) */ _roomBroadcaster; /** * SSE controllers must override this constructor and call super with their * dependencies object and the SSE config. * * @param _dependencies - The dependencies object (cradle proxy in awilix) * @param sseConfig - Optional SSE controller configuration * * @example * ```typescript * class MySSEController extends AbstractSSEController<MyContracts> { * private myService: MyService * * constructor(deps: { myService: MyService }, sseConfig?: SSEControllerConfig) { * super(deps, sseConfig) * this.myService = deps.myService * } * } * ``` */ constructor(_dependencies, sseConfig) { if (sseConfig?.enableConnectionSpy) { this._connectionSpy = new SSESessionSpy(); } if (sseConfig?.roomBroadcaster) { this._roomBroadcaster = sseConfig.roomBroadcaster; this._roomManager = sseConfig.roomBroadcaster.roomManager; sseConfig.roomBroadcaster.registerSender((id, msg) => this.sendEvent(id, msg)); } } /** * Get the connection spy for testing. * Throws an error if spies are not enabled. * Enable spies by passing `{ enableConnectionSpy: true }` to the constructor. * * @example * ```typescript * // In test, create controller with spy enabled * // Pass dependencies first, then config with enableConnectionSpy * const controller = new MySSEController({}, { enableConnectionSpy: true }) * * // Start connection (async) * connectSSE(baseUrl, '/api/stream') * * // Wait for connection - handles race condition * const connection = await controller.connectionSpy.waitForConnection() * ``` * * @throws Error if connection spy is not enabled */ get connectionSpy() { if (!this._connectionSpy) { throw new Error('Connection spy is not enabled. Pass { enableConnectionSpy: true } to the constructor. ' + 'This should only be used in test environments.'); } return this._connectionSpy; } /** * Get the shared room broadcaster instance. * * The broadcaster is the same singleton that's registered in DI as `sseRoomBroadcaster`. * Domain services should resolve it directly from DI rather than going through the controller. * * @throws Error if rooms are not enabled in the controller config * * **Required DI registrations** (must be registered before the controller): * - `sseRoomManager` — `asValue(new SSERoomManager())` or `asSingletonFunction((): SSERoomManager => new SSERoomManager(config))` * - `sseRoomBroadcaster` — `asSingletonClass(SSERoomBroadcaster)` * * @example * ```typescript * // In a domain service — resolve broadcaster directly from DI * class MetricsService { * private readonly broadcaster: SSERoomBroadcaster * constructor(deps: { sseRoomBroadcaster: SSERoomBroadcaster }) { * this.broadcaster = deps.sseRoomBroadcaster * } * } * ``` */ get roomBroadcaster() { if (!this._roomBroadcaster) { throw new Error('Rooms are not enabled. Pass { roomBroadcaster } to the controller config to enable rooms.'); } return this._roomBroadcaster; } /** * Send an event to a specific connection. * * This is a private method used internally by broadcast methods and sendEventInternal. * Handlers should use the type-safe `connection.send` method instead of calling * this method directly. * * Event data is validated against the Zod schema defined in the contract's `events` field * if the connection has event schemas attached (which happens automatically when routes * are built using buildFastifyRoute). * * @param connectionId - The connection to send to * @param message - The SSE message to send * @returns true if sent successfully, false if connection not found or closed * @throws Error if event data fails validation against the contract schema */ async sendEvent(connectionId, message) { const connection = this.connections.get(connectionId); if (!connection) { return false; } // Validate event data against schema if available if (message.event && connection.eventSchemas) { const schema = connection.eventSchemas[message.event]; if (schema) { const result = schema.safeParse(message.data); if (!result.success) { throw new InternalError({ message: `SSE event validation failed for event "${message.event}": ${result.error.message}`, errorCode: 'RESPONSE_VALIDATION_FAILED', }); } } } try { const reply = connection.reply; // @fastify/sse serializes data (JSON by default, customizable via plugin config) await reply.sse.send({ data: message.data, event: message.event, id: message.id, retry: message.retry, }); return true; } catch { // Send failed - connection is likely closed (client disconnected, network error, etc.) // Remove from tracking to prevent further send attempts to a dead connection // Use unregisterConnection to ensure hooks and spy are notified this.unregisterConnection(connectionId); return false; } } /** * Raw internal method for the route builder to send events. * This is used by the route builder to create the typed `send` function. * External code should use `sendEventInternal` instead for type safety. * @internal */ _sendEventRaw(connectionId, message) { return this.sendEvent(connectionId, message); } /** * Get the room manager for use by route utilities. * Returns undefined if rooms are not enabled. * @internal */ get _internalRoomManager() { return this._roomManager; } /** * Send an event to a connection with type-safe event names and data. * * This method provides autocomplete and type checking for event names and data * that match any event defined in the controller's contracts. Use this for * external event sources (subscriptions, timers, message queues) when you * don't have access to the handler's `send` function. * * For best type safety in handlers, use the `send` parameter instead. * For external sources, you can also store the `send` function for per-route typing. * * @example * ```typescript * // External event source (subscription callback) * this.messageQueue.onMessage((msg) => { * this.sendEventInternal(connectionId, { * event: 'notification', // autocomplete shows all valid events * data: { id: msg.id, message: msg.text } // typed based on event * }) * }) * ``` * * @param connectionId - The connection to send to * @param message - The event message with typed event name and data * @returns true if sent successfully, false if connection not found */ sendEventInternal(connectionId, message) { return this.sendEvent(connectionId, message); } /** * Broadcast an event to all connected clients. * * @param message - The SSE message to broadcast * @returns Number of clients the message was sent to */ async broadcast(message) { let sent = 0; const connectionIds = Array.from(this.connections.keys()); for (const id of connectionIds) { if (await this.sendEvent(id, message)) { sent++; } } return sent; } /** * Broadcast an event to connections matching a predicate. * * @param message - The SSE message to broadcast * @param predicate - Function to filter connections * @returns Number of clients the message was sent to */ async broadcastIf(message, predicate) { let sent = 0; for (const [id, connection] of this.connections) { if (predicate(connection) && (await this.sendEvent(id, message))) { sent++; } } return sent; } /** * Get all active connections. */ getConnections() { return Array.from(this.connections.values()); } /** * Get the number of active connections. */ getConnectionCount() { return this.connections.size; } /** * Close a specific connection. * * This gracefully ends the SSE stream by calling the underlying `reply.sse.close()`. * All previously sent data is flushed to the client before the connection terminates. * * Called automatically by the route builder when handler returns `success('disconnect')`. * Can also be called manually for scenarios like external triggers or timeouts. * * @param connectionId - The connection to close * @returns true if connection was found and closed */ closeConnection(connectionId) { const connection = this.connections.get(connectionId); if (!connection) { return false; } try { const reply = connection.reply; reply.sse.close(); } catch { // Connection may already be closed } // Use unregisterConnection to ensure hooks and spy are notified this.unregisterConnection(connectionId); return true; } /** * Close all active connections. * Called during graceful shutdown via asyncDispose. */ closeAllConnections() { const connectionIds = Array.from(this.connections.keys()); for (const id of connectionIds) { this.closeConnection(id); } } /** * Register a connection (called internally by route builder). * Triggers the onConnectionEstablished hook and spy if defined. * @internal */ registerConnection(connection) { this.connections.set(connection.id, connection); this.onConnectionEstablished?.(connection); // Notify spy after hook (so hook can set context before spy sees it) this._connectionSpy?.addConnection(connection); } /** * Unregister a connection (called internally by route builder). * Triggers the onConnectionClosed hook and spy if defined. * This method is idempotent - calling it multiple times for the same * connection ID has no effect after the first call. * @internal */ unregisterConnection(connectionId) { const connection = this.connections.get(connectionId); if (!connection) { // Already unregistered or never existed - do nothing (idempotent) return; } this.onConnectionClosed?.(connection); // Notify spy of disconnection this._connectionSpy?.addDisconnection(connectionId); // Auto-leave all rooms on disconnect this._roomManager?.leaveAll(connectionId); this._roomBroadcaster?.cleanupConnection(connectionId); this.connections.delete(connectionId); } // ============================================================================ // Room Operations // ============================================================================ /** * Get the room manager for this controller. * Throws an error if rooms are not enabled. * * @throws Error if rooms are not enabled in the controller config */ get roomManager() { if (!this._roomManager) { throw new Error('Rooms are not enabled. Pass { rooms: {} } to the controller config to enable rooms.'); } return this._roomManager; } /** * Check if rooms are enabled for this controller. */ get roomsEnabled() { return this._roomManager !== undefined; } /** * Broadcast a type-safe event to all connections in one or more rooms. * * Event names and data are validated against the controller's contract schemas * at compile time, ensuring only valid events can be broadcast. * * When broadcasting to multiple rooms, connections in multiple rooms * only receive the message once (de-duplicated). * * @param room - Room name or array of room names * @param eventName - Event name (must be defined in one of the controller's contracts) * @param data - Event data (must match the schema for the event) * @param options - Broadcast options (local, id, retry) * @returns Number of local connections the message was sent to * * @example * ```typescript * // Broadcast to a single room (type-safe) * await this.broadcastToRoom('dashboard:123', 'metricsUpdate', { * cpu: 45.2, memory: 72.1 * }) * * // Broadcast to multiple rooms (no duplicates) * await this.broadcastToRoom(['premium', 'beta-testers'], 'featureFlag', { * flag: 'new-ui', enabled: true * }) * ``` */ async broadcastToRoom(room, eventName, data, options) { if (!this._roomBroadcaster) { return 0; } const message = { event: eventName, data, id: options?.id ?? randomUUID(), retry: options?.retry, }; const { delivered } = await this._roomBroadcaster.broadcastMessage(room, message, options); return delivered; } /** * Join a connection to one or more rooms. * Prefer using `session.rooms.join()` in handlers instead. * * @param connectionId - The connection to add to rooms * @param room - Room name or array of room names */ joinRoom(connectionId, room) { this._roomManager?.join(connectionId, room); } /** * Remove a connection from one or more rooms. * Prefer using `session.rooms.leave()` in handlers instead. * * @param connectionId - The connection to remove from rooms * @param room - Room name or array of room names */ leaveRoom(connectionId, room) { this._roomManager?.leave(connectionId, room); } /** * Get all connection IDs in a room. * * @param room - The room to query * @returns Array of connection IDs */ getConnectionsInRoom(room) { return this._roomBroadcaster?.getConnectionsInRoom(room) ?? []; } /** * Get the number of connections in a room. * * @param room - The room to query * @returns Number of connections */ getConnectionCountInRoom(room) { return this._roomBroadcaster?.getConnectionCountInRoom(room) ?? 0; } } //# sourceMappingURL=AbstractSSEController.js.map