UNPKG

opinionated-machine

Version:

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

220 lines 8.25 kB
import { randomUUID } from 'node:crypto'; /** Maximum number of message IDs to cache per connection for deduplication */ const MAX_DEDUP_CACHE_SIZE = 1000; /** * Shared, non-generic room broadcaster that can be registered once in DI * and used by multiple controllers and domain services. * * Controllers register their `sendEvent` callback via `registerSender()`. * Domain services receive the broadcaster directly from the DI container. * * Requires `sseRoomManager` to be registered in the DI container. * * @example * ```typescript * // In your DI module's resolveDependencies() * sseRoomManager: asValue(new SSERoomManager()), * sseRoomBroadcaster: asSingletonClass(SSERoomBroadcaster), * * // In a domain service * class MetricsService { * private readonly sseRoomBroadcaster: SSERoomBroadcaster * constructor(deps: { sseRoomBroadcaster: SSERoomBroadcaster }) { * this.sseRoomBroadcaster = deps.sseRoomBroadcaster * } * } * ``` */ export class SSERoomBroadcaster { _roomManager; senders = []; dedupCache = new Map(); preDeliveryFilter; constructor(deps) { this._roomManager = deps.sseRoomManager; // Wire up adapter message handler to forward remote broadcasts to local connections this._roomManager.onRemoteMessage((room, message, _sourceNodeId, metadata) => { return this.handleRemoteBroadcast(room, message, metadata); }); } /** * Set a pre-delivery filter that runs before sending to each connection. * Used by SSESubscriptionManager for resolver pipeline evaluation. * Only one filter can be active at a time — calling this method twice throws, * to prevent silently replacing an already-installed filter (which is almost * always a wiring bug, e.g. registering two SSESubscriptionManagers). */ setPreDeliveryFilter(filter) { if (this.preDeliveryFilter) { throw new Error('SSERoomBroadcaster pre-delivery filter is already set; only one filter can be active at a time'); } this.preDeliveryFilter = filter; } /** * Public getter for the underlying room manager. * Used by the route builder for `session.rooms`. */ get roomManager() { return this._roomManager; } /** * Register a sender callback (typically from a controller's sendEvent). * When broadcasting, each registered sender is tried — the first to return `true` wins. */ registerSender(sendFn) { this.senders.push(sendFn); } /** * Broadcast a type-safe event to all connections in one or more rooms. * * Domain services use this method with `defineEvent()`-based event definitions * for compile-time data validation. * * @param room - Room name or array of room names * @param event - Event definition created by `defineEvent()` * @param data - Event data (must match the schema from the event definition) * @param options - Broadcast options (local, id, retry) * @returns Number of local connections the message was successfully delivered to. * For separate `delivered`/`filtered` counts, call {@link broadcastMessage}. */ async broadcastToRoom(room, event, data, options) { const message = { event: event.event, data, id: options?.id ?? randomUUID(), retry: options?.retry, }; const { delivered } = await this.broadcastMessage(room, message, options); return delivered; } /** * Lower-level broadcast API — sends a raw SSEMessage to all connections in one or more rooms. * * The controller's typed `broadcastToRoom()` delegates here after constructing the message. * * @param room - Room name or array of room names * @param message - The SSE message to broadcast * @param options - Broadcast options (local) * @returns `delivered` is the number of local connections the message was sent to; * `filtered` is the number of local connections skipped by the pre-delivery * filter. Returning both per-call (rather than tracking on the broadcaster) * avoids races between concurrent broadcasts. */ async broadcastMessage(room, message, options) { const rooms = Array.isArray(room) ? room : [room]; const connectionIds = this.collectRoomConnections(rooms); let delivered = 0; let filtered = 0; for (const connId of connectionIds) { // Check pre-delivery filter if set if (this.preDeliveryFilter) { const allowed = await this.preDeliveryFilter(connId, message, options?.metadata); if (!allowed) { filtered++; continue; } } if (await this.sendToConnection(connId, message)) { delivered++; } } // Publish to adapter for cross-node propagation (unless local-only) if (!options?.local) { for (const r of rooms) { await this._roomManager.publish(r, message, options, options?.metadata); } } return { delivered, filtered }; } /** * Get all connection IDs in a room. * * @param room - The room to query * @returns Array of connection IDs */ getConnectionsInRoom(room) { return this._roomManager.getConnectionsInRoom(room); } /** * Get the number of connections in a room. * * @param room - The room to query * @returns Number of connections */ getConnectionCountInRoom(room) { return this._roomManager.getConnectionCountInRoom(room); } /** * Clean up dedup cache for a disconnected connection. * Called by the controller when a connection is unregistered. */ cleanupConnection(connectionId) { this.dedupCache.delete(connectionId); } /** * Try each registered sender until one succeeds (only the owning controller can send). */ async sendToConnection(connId, msg) { for (const sender of this.senders) { if (await sender(connId, msg)) { return true; } } return false; } /** * Handle broadcasts from other nodes (via adapter). * Deduplicates messages per-connection based on message ID. */ async handleRemoteBroadcast(room, message, metadata) { const connectionIds = this._roomManager.getConnectionsInRoom(room); for (const connId of connectionIds) { if (this.isDuplicateMessage(connId, message.id)) { continue; } if (this.preDeliveryFilter) { const allowed = await this.preDeliveryFilter(connId, message, metadata); if (!allowed) continue; } await this.sendToConnection(connId, message); } } /** * Check if a message has already been delivered to a connection (deduplication). * Returns true if the message is a duplicate and should be skipped. */ isDuplicateMessage(connectionId, messageId) { if (!messageId) { return false; } let cache = this.dedupCache.get(connectionId); if (!cache) { cache = new Set(); this.dedupCache.set(connectionId, cache); } if (cache.has(messageId)) { return true; } // FIFO eviction: remove oldest entry if at capacity if (cache.size >= MAX_DEDUP_CACHE_SIZE) { const oldest = cache.values().next().value; cache.delete(oldest); } cache.add(messageId); return false; } /** * Collect unique connection IDs from multiple rooms. */ collectRoomConnections(rooms) { const connectionIds = new Set(); for (const r of rooms) { for (const connId of this._roomManager.getConnectionsInRoom(r)) { connectionIds.add(connId); } } return connectionIds; } } //# sourceMappingURL=SSERoomBroadcaster.js.map