opinionated-machine
Version:
Very opinionated DI framework for fastify, built on top of awilix
250 lines • 9.11 kB
JavaScript
import { randomUUID } from 'node:crypto';
import { isErrorLike } from '../../errorUtils.js';
import { InMemoryAdapter } from './adapters/InMemoryAdapter.js';
/**
* Manages room membership for SSE connections.
*
* Provides Socket.IO-style room support for SSE connections with optional
* cross-node propagation via adapters (e.g., Redis).
*
* **Data Structures (per node):**
* - `connectionRooms: Map<ConnectionId, Set<RoomId>>` - tracks which rooms each connection is in
* - `roomConnections: Map<RoomId, Set<ConnectionId>>` - tracks which connections are in each room
*
* @example Basic usage (single node)
* ```typescript
* const roomManager = new SSERoomManager()
*
* // Join rooms
* roomManager.join(connectionId, 'announcements')
* roomManager.join(connectionId, ['project:123', 'team:eng'])
*
* // Query rooms
* roomManager.getRooms(connectionId) // ['announcements', 'project:123', 'team:eng']
* roomManager.getConnectionsInRoom('team:eng') // [connectionId, ...]
*
* // Leave rooms
* roomManager.leave(connectionId, 'project:123')
* roomManager.leaveAll(connectionId) // Called automatically on disconnect
* ```
*
* @example With Redis adapter (multi-node)
* ```typescript
* import { RedisAdapter } from '@opinionated-machine/sse-rooms-redis'
*
* const roomManager = new SSERoomManager({
* adapter: new RedisAdapter({ pubClient, subClient })
* })
* ```
*/
export class SSERoomManager {
/** Map of connection ID to set of room names */
connectionRooms = new Map();
/** Map of room name to set of connection IDs */
roomConnections = new Map();
/** Adapter for cross-node communication */
adapter;
/** Unique identifier for this node */
nodeId;
/** Handler for remote messages from adapter */
messageHandler;
constructor(config = {}) {
this.adapter = config.adapter ?? new InMemoryAdapter();
this.nodeId = config.nodeId ?? randomUUID();
// Set up adapter message handler
this.adapter.onMessage((room, message, sourceNodeId, metadata) => {
// Skip messages from this node (we already handled them locally)
if (sourceNodeId === this.nodeId) {
return;
}
return this.messageHandler?.(room, message, sourceNodeId, metadata);
});
}
/**
* Connect the adapter (if applicable).
* Call this during server startup.
*/
async connect() {
await this.adapter.connect();
}
/**
* Disconnect the adapter (if applicable).
* Call this during graceful shutdown.
*/
async disconnect() {
await this.adapter.disconnect();
}
/**
* Register a handler for messages from other nodes.
* The controller uses this to forward messages to local connections.
*
* @param handler - Callback invoked when a remote message is received
*/
onRemoteMessage(handler) {
this.messageHandler = handler;
}
/**
* Join one or more rooms.
*
* @param connectionId - The connection to add to rooms
* @param room - Room name or array of room names
* @param logger - Used to report adapter subscription failures (optional)
*/
join(connectionId, room, logger) {
const rooms = Array.isArray(room) ? room : [room];
for (const r of rooms) {
// Get or create connection's room set
let connRooms = this.connectionRooms.get(connectionId);
if (!connRooms) {
connRooms = new Set();
this.connectionRooms.set(connectionId, connRooms);
}
// Skip if already in room
if (connRooms.has(r)) {
continue;
}
// Add to connection -> rooms mapping
connRooms.add(r);
// Get or create room's connection set
let roomConns = this.roomConnections.get(r);
const wasEmpty = !roomConns || roomConns.size === 0;
if (!roomConns) {
roomConns = new Set();
this.roomConnections.set(r, roomConns);
}
// Add to room -> connections mapping
roomConns.add(connectionId);
// Subscribe via adapter if this is the first connection in the room on this node
if (wasEmpty) {
this.adapter.subscribe(r).catch((err) => {
// Log error but don't throw - subscription failure shouldn't break join
logger?.error({
connectionId,
room: r,
error: isErrorLike(err) ? err.message : 'Internal server error',
}, 'Adapter subscription failed');
});
}
}
}
/**
* Leave one or more rooms.
*
* @param connectionId - The connection to remove from rooms
* @param room - Room name or array of room names
* @param logger - Used to report adapter unsubscription failures (optional)
*/
leave(connectionId, room, logger) {
const rooms = Array.isArray(room) ? room : [room];
for (const r of rooms) {
// Remove from connection -> rooms mapping
const connRooms = this.connectionRooms.get(connectionId);
if (connRooms) {
connRooms.delete(r);
if (connRooms.size === 0) {
this.connectionRooms.delete(connectionId);
}
}
// Remove from room -> connections mapping
const roomConns = this.roomConnections.get(r);
if (roomConns) {
roomConns.delete(connectionId);
if (roomConns.size === 0) {
this.roomConnections.delete(r);
// Unsubscribe via adapter - no more local connections in this room
this.adapter.unsubscribe(r).catch((err) => {
// Log error but don't throw
logger?.error({
connectionId,
room: r,
error: isErrorLike(err) ? err.message : 'Internal server error',
}, 'Adapter unsubscription failed');
});
}
}
}
}
/**
* Leave all rooms for a connection.
* Called automatically when a connection disconnects.
*
* @param connectionId - The connection to remove from all rooms
* @returns Array of room names the connection was in
*/
leaveAll(connectionId) {
const connRooms = this.connectionRooms.get(connectionId);
if (!connRooms) {
return [];
}
const rooms = Array.from(connRooms);
this.leave(connectionId, rooms);
return rooms;
}
/**
* Get all rooms a connection is in.
*
* @param connectionId - The connection to query
* @returns Array of room names
*/
getRooms(connectionId) {
const rooms = this.connectionRooms.get(connectionId);
return rooms ? Array.from(rooms) : [];
}
/**
* Get all connection IDs in a room.
*
* @param room - The room to query
* @returns Array of connection IDs
*/
getConnectionsInRoom(room) {
const connections = this.roomConnections.get(room);
return connections ? Array.from(connections) : [];
}
/**
* Get the number of connections in a room.
*
* @param room - The room to query
* @returns Number of connections
*/
getConnectionCountInRoom(room) {
const connections = this.roomConnections.get(room);
return connections?.size ?? 0;
}
/**
* Check if a connection is in a specific room.
*
* @param connectionId - The connection to check
* @param room - The room to check
* @returns true if the connection is in the room
*/
isInRoom(connectionId, room) {
const rooms = this.connectionRooms.get(connectionId);
return rooms?.has(room) ?? false;
}
/**
* Get all room names that have at least one connection.
*
* @returns Array of room names
*/
getAllRooms() {
return Array.from(this.roomConnections.keys());
}
/**
* Publish a message to a room via the adapter.
* This propagates the message to other nodes.
*
* @param room - The room to publish to
* @param message - The SSE message to broadcast
* @param options - Broadcast options (e.g. `local: true` to skip adapter)
* @param metadata - Optional opaque metadata propagated to other nodes
* alongside the message. Used by `SSESubscriptionManager` for cross-node
* resolver evaluation; not delivered to clients.
*/
async publish(room, message, options, metadata) {
if (options?.local) {
return; // Skip adapter for local-only broadcasts
}
await this.adapter.publish(room, message, metadata);
}
}
//# sourceMappingURL=SSERoomManager.js.map