opinionated-machine
Version:
Very opinionated DI framework for fastify, built on top of awilix
448 lines • 16.7 kB
JavaScript
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