/** * SSE Client - Server-Sent Events for real-time session events * * Replaces WebSocket for receiving session events from the server. * Commands are sent via REST API instead. */ class SSEClient { constructor() { this.eventSource = null; this.currentSessionId = null; this.eventHandlers = new Map(); this.reconnectAttempts = 0; this.maxReconnectAttempts = 10; this.reconnectDelay = 1000; this.reconnectBackoff = 1.5; } /** * Connect to SSE endpoint for a session * @param {string} sessionId - Session ID to connect to */ connect(sessionId) { if (this.eventSource) { this.disconnect(); } this.currentSessionId = sessionId; // Use /claude/api prefix for production nginx routing const url = `/claude/api/session/${encodeURIComponent(sessionId)}/events`; console.log(`[SSEClient] Connecting to: ${url}`); if (window.traceExecution) { window.traceExecution('sse-client', 'connect START', { sessionId, url }); } this.eventSource = new EventSource(url); // Connection opened this.eventSource.onopen = () => { console.log(`[SSEClient] Connected to session ${sessionId}`); this.reconnectAttempts = 0; this.reconnectDelay = 1000; this.emit('connected', { sessionId }); if (window.traceExecution) { window.traceExecution('sse-client', 'connected SUCCESS', { sessionId, readyState: this.eventSource.readyState }); } // Auto-report successful connection if (window.AutoFixLogger) { window.AutoFixLogger.success('SSE connected', { sessionId }); } }; // Connection error this.eventSource.onerror = (error) => { console.error(`[SSEClient] Connection error for session ${sessionId}:`, error); if (window.traceExecution) { window.traceExecution('sse-client', 'connection ERROR', { sessionId, readyState: this.eventSource.readyState, error: error?.toString() }); } // Auto-report connection error if (window.AutoFixLogger) { window.AutoFixLogger.error('SSE connection failed', { sessionId, readyState: this.eventSource.readyState, reconnectAttempts: this.reconnectAttempts }); } if (this.eventSource.readyState === EventSource.CLOSED) { this.emit('error', { sessionId, error: 'Connection closed' }); this.handleReconnect(); } }; // Generic message handler (fallback) - only for unnamed events this.eventSource.onmessage = (event) => { try { const data = JSON.parse(event.data); console.log('[SSEClient] Message:', data.type, data); this.routeEvent(data); } catch (err) { console.error('[SSEClient] Error parsing message:', err, event.data); if (window.traceExecution) { window.traceExecution('sse-client', 'message parse ERROR', { error: err?.toString(), rawData: event.data?.substring(0, 200) }); } } }; // Register specific event listeners this.registerEventListeners(); } /** * Register specific event type listeners */ registerEventListeners() { const eventTypes = [ 'connected', 'session-output', 'session-error', 'session-status', 'operations-detected', 'operations-executed', 'operations-error', 'approval-request', 'approval-confirmed', 'approval-expired', 'command-sent', 'terminal-created', 'terminal-closed' ]; eventTypes.forEach(type => { this.eventSource.addEventListener(type, (event) => { try { const data = JSON.parse(event.data); // Use _eventType for routing to preserve the server's event type // The 'type' in data is the event subtype (e.g., 'stdout', 'stderr') this.routeEvent({ _eventType: type, ...data }); } catch (err) { console.error(`[SSEClient] Error parsing ${type} event:`, err); } }); }); } /** * Route event to appropriate handler * @param {Object} event - Event object with type and data */ routeEvent(event) { // Use _eventType for routing (set by server), fall back to type const eventType = event._eventType || event.type; if (window.traceExecution) { window.traceExecution('sse-client', 'routeEvent', { eventType, hasContent: !!event.content, contentLength: event.content?.length || 0 }); } // Call registered handlers for this event type const handlers = this.eventHandlers.get(eventType) || []; handlers.forEach(handler => { try { handler(event); } catch (err) { console.error(`[SSEClient] Error in handler for ${eventType}:`, err); if (window.traceExecution) { window.traceExecution('sse-client', 'handler ERROR', { eventType, error: err?.toString() }); } } }); // Call wildcard handlers const wildcardHandlers = this.eventHandlers.get('*') || []; wildcardHandlers.forEach(handler => { try { handler(event); } catch (err) { console.error('[SSEClient] Error in wildcard handler:', err); } }); } /** * Handle automatic reconnection with exponential backoff */ handleReconnect() { if (this.reconnectAttempts >= this.maxReconnectAttempts) { console.error('[SSEClient] Max reconnection attempts reached'); this.emit('disconnected', { sessionId: this.currentSessionId }); return; } this.reconnectAttempts++; const delay = this.reconnectDelay * Math.pow(this.reconnectBackoff, this.reconnectAttempts - 1); console.log(`[SSEClient] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`); setTimeout(() => { if (this.currentSessionId) { this.connect(this.currentSessionId); } }, delay); } /** * Register event handler * @param {string} eventType - Event type or '*' for wildcard * @param {Function} handler - Event handler function * @returns {Function} Unsubscribe function */ on(eventType, handler) { if (!this.eventHandlers.has(eventType)) { this.eventHandlers.set(eventType, []); } const handlers = this.eventHandlers.get(eventType); // Prevent duplicate handler registration if (handlers.includes(handler)) { console.log(`[SSEClient] Handler already registered for: ${eventType}, skipping`); return () => {}; // Return no-op unsubscribe function } handlers.push(handler); console.log(`[SSEClient] Registered handler for: ${eventType} (total: ${handlers.length})`); // Return unsubscribe function return () => { const currentHandlers = this.eventHandlers.get(eventType); if (currentHandlers) { const index = currentHandlers.indexOf(handler); if (index > -1) { currentHandlers.splice(index, 1); console.log(`[SSEClient] Unregistered handler for: ${eventType}`); } } }; } /** * Emit event to handlers * @param {string} eventType - Event type * @param {Object} data - Event data */ emit(eventType, data) { if (window.traceExecution) { window.traceExecution('sse-client', 'emit', { eventType, dataKeys: Object.keys(data || {}) }); } this.routeEvent({ _eventType: eventType, ...data }); } /** * Disconnect from SSE endpoint */ disconnect() { if (this.eventSource) { console.log(`[SSEClient] Disconnecting from session ${this.currentSessionId}`); this.eventSource.close(); this.eventSource = null; } const sessionId = this.currentSessionId; this.currentSessionId = null; this.reconnectAttempts = 0; this.emit('disconnected', { sessionId }); } /** * Get connection state * @returns {string} Connection state */ getReadyState() { if (!this.eventSource) return 'DISCONNECTED'; switch (this.eventSource.readyState) { case EventSource.CONNECTING: return 'CONNECTING'; case EventSource.OPEN: return 'OPEN'; case EventSource.CLOSED: return 'CLOSED'; default: return 'UNKNOWN'; } } /** * Check if connected * @returns {boolean} True if connected */ isConnected() { return this.eventSource && this.eventSource.readyState === EventSource.OPEN; } } // Singleton instance const sseClient = new SSEClient(); // Auto-initialize: connect to session from URL path document.addEventListener('DOMContentLoaded', () => { // Extract sessionId from URL path: /claude/ide/session/{sessionId} const pathMatch = window.location.pathname.match(/\/claude\/ide\/session\/([^/]+)$/); if (pathMatch && pathMatch[1]) { const sessionId = decodeURIComponent(pathMatch[1]); console.log('[SSEClient] Auto-connecting to session from URL:', sessionId); sseClient.connect(sessionId); } }); // Make globally accessible window.sseClient = sseClient;