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