Let AI go to work

This commit is contained in:
2026-05-23 10:33:29 +02:00
parent 7890669eda
commit adc758dfa5
20 changed files with 2200 additions and 257 deletions

View File

@@ -27,7 +27,7 @@ interface SSEOptions {
/**
* Server-Sent Events broadcaster with event history support.
*
*
* Maintains a per-user event history buffer that allows clients to replay
* missed events when reconnecting. This makes reconnections robust against
* network interruptions.
@@ -45,17 +45,17 @@ export class SSEBroadcaster {
}
/** Map of Invitation IDs to their connected SSE response streams */
private clients: Map<(string), Set<FastifyReply>> = new Map();
private clients: Map<string, Set<FastifyReply>> = new Map();
/** Map of Invitation IDs to their event history buffers */
private eventHistory: Map<string, HistoricalEvent[]> = new Map();
/** Maximum age of events to keep in history (in milliseconds) */
private maxHistoryAge: number;
/** Maximum number of events to keep per user */
private maxHistorySize: number;
private debug: Debugger;
constructor(options?: SSEOptions) {
@@ -63,7 +63,7 @@ export class SSEBroadcaster {
this.eventHistory = new Map();
this.maxHistoryAge = options?.maxHistoryAge ?? 20 * 60 * 1000; // 20 minutes default
this.maxHistorySize = options?.maxHistorySize ?? 1000; // 1000 events default
this.debug = debug('xo:sse');
this.debug = debug("xo:sse");
}
/**
@@ -71,14 +71,17 @@ export class SSEBroadcaster {
* @returns The SSE instance for chaining
*/
start() {
this.debug('SSE broadcaster is running (maxHistoryAge: %dms, maxHistorySize: %d)',
this.maxHistoryAge, this.maxHistorySize);
this.debug(
"SSE broadcaster is running (maxHistoryAge: %dms, maxHistorySize: %d)",
this.maxHistoryAge,
this.maxHistorySize,
);
return this;
}
/**
* Sends an event to a client.
*
*
* @param client - The client to send the event to
* @param topic - The event topic/type
* @param data - The event payload data
@@ -92,7 +95,7 @@ export class SSEBroadcaster {
/**
* Sends an event to a client.
*
*
* @param client - The client to send the event to
* @param topic - The event topic/type
* @param data - The event payload data
@@ -101,50 +104,56 @@ export class SSEBroadcaster {
try {
SSEBroadcaster.sendEvent(client, topic, data);
} catch (error) {
this.debug('Error sending event to client', error);
this.debug("Error sending event to client", error);
}
}
/**
* Broadcasts an event to all connected clients for a user and stores it in history.
*
*
* @param clientId - The user ID to broadcast to
* @param topic - The event topic/type
* @param data - The event payload data
*/
async broadcast(clientId: string, topic: string, data: unknown) {
const timestamp = Date.now();
// Store the event in history for potential replay
this.storeEvent(clientId, topic, data, timestamp);
// Broadcast to all connected clients
this.clients.get(clientId)?.forEach((client: FastifyReply) => {
try {
this.sendEvent(client, topic, data);
} catch (error) {
this.debug('Error sending event to client', error);
this.debug("Error sending event to client", error);
}
});
this.debug('SSE broadcasted message', topic, data);
this.debug("SSE broadcasted message", topic, data);
}
/**
* Subscribes a client to receive SSE events.
*
*
* If lastEventTime is provided, all events that occurred after that timestamp
* will be replayed to the client before starting the live stream.
*
*
* @param req - The authenticated request containing the user ID
* @param res - The Express response object to use for SSE streaming
* @param lastEventTime - Optional timestamp to replay events from (in milliseconds)
*/
async subscribe(req: FastifyRequest, res: FastifyReply, lastEventTime?: number) {
async subscribe(
req: FastifyRequest,
res: FastifyReply,
lastEventTime?: number,
) {
// Get the invitation ID from the request
const { invitationIdentifier } = req.query as { invitationIdentifier?: string };
const { invitationIdentifier } = req.query as {
invitationIdentifier?: string;
};
if (!invitationIdentifier) {
throw new Error('Invitation Identifier is required');
throw new Error("Invitation Identifier is required");
}
// Initialize client set for this user if needed
@@ -174,19 +183,26 @@ export class SSEBroadcaster {
res.raw.flushHeaders();
// Set retry interval for automatic reconnection
res.raw.write('retry: 3000\n\n');
res.raw.write("retry: 3000\n\n");
// Replay missed events if a lastEventTime was provided
if (lastEventTime !== undefined) {
const missedEvents = this.getEventsAfter(invitationIdentifier, lastEventTime);
this.debug('SSE replaying %d missed events for invitation %s (since %d)',
missedEvents.length, invitationIdentifier, lastEventTime);
const missedEvents = this.getEventsAfter(
invitationIdentifier,
lastEventTime,
);
this.debug(
"SSE replaying %d missed events for invitation %s (since %d)",
missedEvents.length,
invitationIdentifier,
lastEventTime,
);
for (const event of missedEvents) {
try {
await this.sendEvent(res, event.topic, event.data);
} catch (error) {
this.debug('Error sending event to client', error);
this.debug("Error sending event to client", error);
}
}
}
@@ -194,33 +210,44 @@ export class SSEBroadcaster {
// Add client to the set for live updates
this.clients.get(invitationIdentifier)?.add(res);
this.debug('SSE subscribed to client (invitationId: %s, lastEventTime: %s)',
invitationIdentifier, lastEventTime ?? 'none');
this.debug(
"SSE subscribed to client (invitationId: %s, lastEventTime: %s)",
invitationIdentifier,
lastEventTime ?? "none",
);
// Clean up when client disconnects
res.raw.on('close', () => {
res.raw.on("close", () => {
this.clients.get(invitationIdentifier)?.delete(res);
this.debug('SSE client disconnected (invitationIdentifier: %s)', invitationIdentifier);
this.debug(
"SSE client disconnected (invitationIdentifier: %s)",
invitationIdentifier,
);
});
}
/**
* Stores an event in the user's history buffer.
* Automatically prunes old events based on age and size limits.
*
*
* @param invitationId - The invitation ID to store the event for
* @param topic - The event topic/type
* @param data - The event payload data
* @param timestamp - The event timestamp
*/
private storeEvent(invitationId: string, topic: string, data: unknown, timestamp: number) {
private storeEvent(
invitationId: string,
topic: string,
data: unknown,
timestamp: number,
) {
// Initialize history array for this user if needed
if (!this.eventHistory.has(invitationId)) {
this.eventHistory.set(invitationId, []);
}
const history = this.eventHistory.get(invitationId)!;
// Add the new event
history.push({ topic, data, timestamp });
@@ -230,7 +257,7 @@ export class SSEBroadcaster {
/**
* Removes old events from a invitation's history based on age and size limits.
*
*
* @param invitationId - The invitation ID whose history to prune
* @param currentTime - The current timestamp for age calculations
*/
@@ -239,32 +266,36 @@ export class SSEBroadcaster {
if (!history) return;
const cutoffTime = currentTime - this.maxHistoryAge;
// Remove events older than maxHistoryAge
const prunedByAge = history.filter(event => event.timestamp > cutoffTime);
const prunedByAge = history.filter((event) => event.timestamp > cutoffTime);
// If still over size limit, remove oldest events
const prunedBySize = prunedByAge.length > this.maxHistorySize
? prunedByAge.slice(-this.maxHistorySize)
: prunedByAge;
const prunedBySize =
prunedByAge.length > this.maxHistorySize
? prunedByAge.slice(-this.maxHistorySize)
: prunedByAge;
this.eventHistory.set(invitationId, prunedBySize);
}
/**
* Retrieves all events for a user that occurred after a given timestamp.
*
*
* @param invitationId - The invitation ID to get events for
* @param afterTimestamp - The timestamp to get events after (exclusive)
* @returns Array of events that occurred after the timestamp
*/
private getEventsAfter(invitationId: string, afterTimestamp: number): HistoricalEvent[] {
private getEventsAfter(
invitationId: string,
afterTimestamp: number,
): HistoricalEvent[] {
const history = this.eventHistory.get(invitationId);
if (!history) return [];
// First prune old events to ensure we don't return stale data
this.pruneHistory(invitationId, Date.now());
return history.filter(event => event.timestamp > afterTimestamp);
return history.filter((event) => event.timestamp > afterTimestamp);
}
}
}