UNPKG

cui-server

Version:

Web UI Agent Platform based on Claude Code

239 lines 8.2 kB
import { EventEmitter } from 'events'; import { createLogger } from './logger.js'; /** * Manages streaming connections to multiple clients */ export class StreamManager extends EventEmitter { clients = new Map(); logger; heartbeatInterval; // Send heartbeat every 30 seconds to keep connections alive HEARTBEAT_INTERVAL_MS = 30000; constructor() { super(); this.logger = createLogger('StreamManager'); } /** * Add a client to receive stream updates */ addClient(streamingId, res) { this.logger.debug('Adding client to stream', { streamingId }); // Configure response for Server-Sent Events res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('X-Accel-Buffering', 'no'); res.setHeader('Access-Control-Allow-Origin', '*'); // Initialize client set if needed if (!this.clients.has(streamingId)) { this.clients.set(streamingId, new Set()); } // Add this client to the session this.clients.get(streamingId).add(res); this.logger.debug('Client added successfully', { streamingId, totalClients: this.clients.get(streamingId).size }); // Send initial connection confirmation const connectionMessage = { type: 'connected', streaming_id: streamingId, timestamp: new Date().toISOString() }; this.logger.debug('Sending initial SSE connection confirmation', { streamingId, clientCount: this.clients.get(streamingId).size }); this.sendSSEEvent(res, connectionMessage); // Start heartbeat if this is the first client this.startHeartbeat(); // Clean up when client disconnects res.on('close', () => { this.removeClient(streamingId, res); }); res.on('error', (error) => { this.logger.error('Stream error for session', error, { streamingId }); this.removeClient(streamingId, res); }); } /** * Remove a client connection */ removeClient(streamingId, res) { const clients = this.clients.get(streamingId); if (clients) { clients.delete(res); if (clients.size === 0) { this.clients.delete(streamingId); } } this.emit('client-disconnected', { streamingId }); // Stop heartbeat if no clients remain if (this.getTotalClientCount() === 0) { this.stopHeartbeat(); } } /** * Broadcast an event to all clients watching a session */ broadcast(streamingId, event) { this.logger.debug('Broadcasting event to clients', { streamingId, eventType: event?.type, eventSubtype: 'subtype' in event ? event.subtype : undefined }); const clients = this.clients.get(streamingId); if (!clients || clients.size === 0) { this.logger.debug('No clients found for streaming session, dropping message', { streamingId }); return; } this.logger.debug('Found clients for broadcast', { streamingId, clientCount: clients.size }); const deadClients = []; for (const client of clients) { try { this.sendSSEEvent(client, event); this.logger.debug('Successfully sent SSE event to client', { streamingId, eventType: event?.type, eventSubtype: 'subtype' in event ? event.subtype : undefined }); } catch (error) { this.logger.error('Failed to send SSE event to client', error, { streamingId }); deadClients.push(client); } } // Clean up dead clients deadClients.forEach(client => this.removeClient(streamingId, client)); } /** * Send an SSE event to a specific client */ sendSSEEvent(res, message, eventType) { if (res.writableEnded || res.destroyed) { throw new Error('Response is no longer writable'); } let sseData = ''; if (eventType) { sseData += `event: ${eventType}\n`; } sseData += `data: ${JSON.stringify(message)}\n\n`; // Log SSE event data this.logger.debug('Sending SSE event', { eventType, messageType: message?.type, messageSubtype: 'subtype' in message ? message.subtype : undefined, streamingId: 'streamingId' in message ? message.streamingId : 'streaming_id' in message ? message.streaming_id : undefined, sseDataLength: sseData.length }); res.write(sseData); } /** * Send SSE heartbeat (comment) to keep connection alive */ sendHeartbeat(res) { if (!res.writableEnded && !res.destroyed) { res.write(': heartbeat\n\n'); } } /** * Get number of clients connected to a session */ getClientCount(streamingId) { return this.clients.get(streamingId)?.size || 0; } /** * Get all active sessions */ getActiveSessions() { return Array.from(this.clients.keys()); } /** * Close all connections for a session */ closeSession(streamingId) { const clients = this.clients.get(streamingId); if (!clients) return; const closeEvent = { type: 'closed', streamingId: streamingId, timestamp: new Date().toISOString() }; // Create array to avoid modifying set while iterating const clientsArray = Array.from(clients); this.logger.debug('Closing SSE session, sending close events to all clients', { streamingId, clientCount: clientsArray.length }); for (const client of clientsArray) { try { this.sendSSEEvent(client, closeEvent); this.logger.debug('Sent SSE close event to client', { streamingId }); client.end(); } catch (error) { this.logger.error('Error closing SSE client connection', error, { streamingId }); } } // Remove the entire session this.clients.delete(streamingId); // Stop heartbeat if no clients remain if (this.getTotalClientCount() === 0) { this.stopHeartbeat(); } } /** * Get total number of clients across all sessions */ getTotalClientCount() { let total = 0; for (const clients of this.clients.values()) { total += clients.size; } return total; } /** * Disconnect all clients from all sessions */ disconnectAll() { for (const streamingId of this.clients.keys()) { this.closeSession(streamingId); } this.stopHeartbeat(); } /** * Start periodic heartbeat to keep SSE connections alive */ startHeartbeat() { if (this.heartbeatInterval) { return; // Already running } this.heartbeatInterval = setInterval(() => { this.logger.debug('Sending heartbeat to all clients'); for (const clients of this.clients.values()) { for (const client of clients) { try { this.sendHeartbeat(client); } catch (error) { this.logger.debug('Failed to send heartbeat to client', { error }); } } } }, this.HEARTBEAT_INTERVAL_MS); } /** * Stop periodic heartbeat */ stopHeartbeat() { if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = undefined; this.logger.debug('Stopped heartbeat'); } } } //# sourceMappingURL=stream-manager.js.map