opinionated-machine
Version:
Very opinionated DI framework for fastify, built on top of awilix
298 lines • 12.1 kB
JavaScript
import { randomUUID } from 'node:crypto';
export class SSESubscriptionManager {
config;
connectionStates = new Map();
userConnections = new Map();
deps;
constructor(config, deps) {
this.config = { ...config, defaultPolicy: config.defaultPolicy ?? 'deny' };
this.deps = deps;
// Register as the broadcaster's pre-delivery filter. The broadcaster
// tracks delivered/filtered counts per call, so this filter only needs
// to return the verdict — no shared mutable counters.
deps.sseRoomBroadcaster.setPreDeliveryFilter((connectionId, message, metadata) => {
return this.shouldDeliver(connectionId, message, metadata);
});
}
async handleConnect(session) {
// 1. Resolve initial user context
let userContext = await this.config.resolveUserContext(session.request);
// 2. Build initial context (empty rooms)
const resolverRooms = new Map();
// 3. Run resolver onConnect chain in order
for (const resolver of this.config.resolvers) {
if (!resolver.onConnect)
continue;
const ctx = {
connectionId: session.id,
request: session.request,
userContext,
rooms: new Set(),
};
const result = await resolver.onConnect(ctx);
userContext = result.userContext;
resolverRooms.set(resolver.name, new Set(result.rooms ?? []));
}
// 4. Compute union of all resolver rooms
const unionRooms = new Set();
for (const rooms of resolverRooms.values()) {
for (const room of rooms) {
unionRooms.add(room);
}
}
// 5. Join all rooms in a single batch
if (unionRooms.size > 0) {
this.deps.sseRoomManager.join(session.id, [...unionRooms]);
}
// 6. Build final immutable context
const finalContext = {
connectionId: session.id,
request: session.request,
userContext,
rooms: unionRooms,
};
// 7. Store connection state
this.connectionStates.set(session.id, {
context: finalContext,
resolverRooms,
});
// 8. Index by userId if configured
if (this.config.resolveUserId) {
const userId = this.config.resolveUserId(userContext);
let connSet = this.userConnections.get(userId);
if (!connSet) {
connSet = new Set();
this.userConnections.set(userId, connSet);
}
connSet.add(session.id);
}
}
handleDisconnect(session) {
const state = this.connectionStates.get(session.id);
if (!state)
return;
// Remove from userId index
if (this.config.resolveUserId) {
const userId = this.config.resolveUserId(state.context.userContext);
const connSet = this.userConnections.get(userId);
if (connSet) {
connSet.delete(session.id);
if (connSet.size === 0) {
this.userConnections.delete(userId);
}
}
}
this.connectionStates.delete(session.id);
}
publish(event) {
const rooms = event.targetRooms;
// Order: cheapest/most specific check first, then broadest fallback.
// Caller omitted targetRooms → fan out to all managed connections.
if (rooms === undefined) {
return this.publishToAllConnections(event);
}
// Caller passed an empty array → explicit "no rooms", no work to do.
if (rooms.length === 0) {
return Promise.resolve({ delivered: 0, filtered: 0 });
}
const message = {
event: event.eventName,
data: event.data,
id: randomUUID(),
};
return this.deps.sseRoomBroadcaster.broadcastMessage(rooms, message, {
metadata: event.metadata,
});
}
/**
* Pre-delivery filter registered with the broadcaster.
* Evaluates the resolver pipeline for a connection and returns whether
* the message should be delivered.
*
* Returns `true` for connections not managed by this subscription manager,
* so non-subscription SSE streams are unaffected.
*
* @param connectionId - The connection to evaluate
* @param message - The SSE message being delivered
* @param metadata - Optional metadata (cast to TMetadata — callers must ensure shape matches)
* @returns Whether the message should be delivered to this connection
*/
shouldDeliver(connectionId, message, metadata) {
const state = this.connectionStates.get(connectionId);
if (!state) {
// Connection not managed by this subscription manager — don't interfere
return true;
}
// Reconstruct IncomingEvent from message + metadata
const event = {
eventName: message.event ?? '',
data: message.data,
metadata: (metadata ?? {}),
};
return this.evaluate(state.context, event);
}
async refreshConnection(connectionId) {
const state = this.connectionStates.get(connectionId);
if (!state) {
// No-op for unknown ids. Connections can vanish between scheduling a
// refresh and running it (client disconnects mid-flight, or the caller
// races refreshConnection with handleDisconnect), and handleDisconnect
// is already idempotent for the same case — keep the API symmetric.
return;
}
const previousUserId = this.config.resolveUserId?.(state.context.userContext);
const { userContext, resolverRooms } = await this.runRefreshChain(connectionId, state);
const newUnion = this.computeRoomUnion(resolverRooms);
this.applyRoomDiff(connectionId, state.context.rooms, newUnion);
this.connectionStates.set(connectionId, {
context: { connectionId, request: state.context.request, userContext, rooms: newUnion },
resolverRooms,
});
// Refresh may rotate the resolved userId (impersonation, user merge, etc.).
// Re-bucket the connection so refreshUser/handleDisconnect target the right Set.
if (this.config.resolveUserId) {
const nextUserId = this.config.resolveUserId(userContext);
if (previousUserId !== nextUserId) {
this.rekeyUserConnection(connectionId, previousUserId, nextUserId);
}
}
}
rekeyUserConnection(connectionId, previousUserId, nextUserId) {
if (previousUserId !== undefined) {
const previousSet = this.userConnections.get(previousUserId);
if (previousSet) {
previousSet.delete(connectionId);
if (previousSet.size === 0) {
this.userConnections.delete(previousUserId);
}
}
}
let nextSet = this.userConnections.get(nextUserId);
if (!nextSet) {
nextSet = new Set();
this.userConnections.set(nextUserId, nextSet);
}
nextSet.add(connectionId);
}
async refreshUser(userId) {
if (!this.config.resolveUserId) {
throw new Error('resolveUserId not configured');
}
const connSet = this.userConnections.get(userId);
if (!connSet || connSet.size === 0)
return;
// Snapshot the connection IDs — refreshConnection may mutate `connSet`
// (re-keying when the resolved userId changes), and iterating a Set while
// it is being modified produces undefined behaviour.
for (const connId of [...connSet]) {
await this.refreshConnection(connId);
}
}
getConnectionContext(connectionId) {
return this.connectionStates.get(connectionId)?.context;
}
async runRefreshChain(connectionId, state) {
let userContext = state.context.userContext;
const resolverRooms = new Map(state.resolverRooms);
for (const resolver of this.config.resolvers) {
if (!resolver.refresh)
continue;
const ctx = {
connectionId,
request: state.context.request,
userContext,
rooms: state.context.rooms,
};
try {
const result = await resolver.refresh(ctx);
userContext = result.userContext;
resolverRooms.set(resolver.name, new Set(result.rooms ?? []));
}
catch (err) {
this.config.logger?.error({ err, resolver: resolver.name, connectionId }, 'Resolver refresh error, keeping previous state for this resolver');
}
}
return { userContext, resolverRooms };
}
computeRoomUnion(resolverRooms) {
const union = new Set();
for (const rooms of resolverRooms.values()) {
for (const room of rooms) {
union.add(room);
}
}
return union;
}
applyRoomDiff(connectionId, currentRooms, newRooms) {
const toJoin = [];
const toLeave = [];
for (const room of newRooms) {
if (!currentRooms.has(room))
toJoin.push(room);
}
for (const room of currentRooms) {
if (!newRooms.has(room))
toLeave.push(room);
}
if (toJoin.length > 0)
this.deps.sseRoomManager.join(connectionId, toJoin);
if (toLeave.length > 0)
this.deps.sseRoomManager.leave(connectionId, toLeave);
}
async evaluatePipeline(ctx, event) {
let hasAllow = false;
for (const resolver of this.config.resolvers) {
let verdict;
try {
verdict = await resolver.evaluate(ctx, event);
}
catch (err) {
this.config.logger?.error({ err, resolver: resolver.name, connectionId: ctx.connectionId }, 'Resolver evaluate error, treating as deny');
return { action: 'deny', reason: 'resolver error' };
}
if (verdict.action === 'deny') {
return verdict; // short-circuit
}
if (verdict.action === 'allow') {
hasAllow = true;
}
// 'defer' → continue
}
if (hasAllow) {
return { action: 'allow' };
}
return { action: this.config.defaultPolicy };
}
async evaluate(ctx, event) {
const verdict = await this.evaluatePipeline(ctx, event);
return verdict.action !== 'deny';
}
publishToAllConnections(event) {
if (this.connectionStates.size === 0) {
return Promise.resolve({ delivered: 0, filtered: 0 });
}
// Collect all rooms across all managed connections
const allRooms = new Set();
for (const state of this.connectionStates.values()) {
for (const room of state.context.rooms) {
allRooms.add(room);
}
}
if (allRooms.size === 0) {
// Connections with no rooms cannot be reached via room-based broadcast.
// Resolvers should declare rooms in onConnect() to ensure reachability.
return Promise.resolve({ delivered: 0, filtered: 0 });
}
const message = {
event: event.eventName,
data: event.data,
id: randomUUID(),
};
// Broadcast to all rooms — the pre-delivery filter evaluates each
// connection, and the broadcaster returns per-call delivered/filtered counts.
return this.deps.sseRoomBroadcaster.broadcastMessage([...allRooms], message, {
metadata: event.metadata,
});
}
}
//# sourceMappingURL=SSESubscriptionManager.js.map