UNPKG

@nestjsvn/swagger-sse

Version:

OpenAPI documentation and interactive Swagger UI for NestJS Server-Sent Events endpoints

470 lines (403 loc) 11.2 kB
/** * Enhanced EventSource Manager * Provides robust SSE connection management with automatic reconnection, * exponential backoff, and comprehensive error handling. */ class EventSourceManager { constructor(url, options = {}) { this.url = url; this.options = { maxReconnectAttempts: 10, initialReconnectDelay: 1000, maxReconnectDelay: 30000, reconnectDecay: 1.5, eventTypes: [], headers: {}, withCredentials: false, ...options, }; // Connection state this.eventSource = null; this.status = 'disconnected'; this.reconnectAttempts = 0; this.reconnectTimeoutId = null; this.lastEventId = null; // Statistics this.stats = { connectionStartTime: null, totalEvents: 0, eventsByType: new Map(), reconnectCount: 0, lastError: null, uptime: 0, }; // Event handlers this.eventHandlers = new Map(); this.statusHandlers = new Set(); this.errorHandlers = new Set(); // Performance monitoring this.performanceMonitor = { eventBuffer: [], batchSize: 100, batchTimeout: 100, lastBatchTime: Date.now(), }; // Bind methods this.connect = this.connect.bind(this); this.disconnect = this.disconnect.bind(this); this.reconnect = this.reconnect.bind(this); this.handleOpen = this.handleOpen.bind(this); this.handleMessage = this.handleMessage.bind(this); this.handleError = this.handleError.bind(this); } /** * Connect to the SSE endpoint */ async connect() { if (this.status === 'connected' || this.status === 'connecting') { console.warn('EventSourceManager: Already connected or connecting'); return; } this.setStatus('connecting'); this.stats.connectionStartTime = Date.now(); try { // Build URL with last event ID if available const url = this.buildUrl(); console.log('EventSourceManager: Connecting to', url); // Create EventSource with proper configuration this.eventSource = new EventSource(url, { withCredentials: this.options.withCredentials, }); // Set up event listeners this.setupEventListeners(); } catch (error) { console.error('EventSourceManager: Failed to create EventSource', error); this.handleError(error); } } /** * Disconnect from the SSE endpoint */ disconnect() { console.log('EventSourceManager: Disconnecting'); // Clear reconnect timeout if (this.reconnectTimeoutId) { clearTimeout(this.reconnectTimeoutId); this.reconnectTimeoutId = null; } // Close EventSource if (this.eventSource) { this.eventSource.close(); this.eventSource = null; } // Reset state this.reconnectAttempts = 0; this.setStatus('disconnected'); this.updateUptime(); } /** * Manually trigger reconnection */ reconnect() { console.log('EventSourceManager: Manual reconnect triggered'); this.disconnect(); setTimeout(() => this.connect(), 100); } /** * Build URL with query parameters */ buildUrl() { const url = new URL(this.url, window.location.origin); // Add last event ID for resuming if (this.lastEventId) { url.searchParams.set('lastEventId', this.lastEventId); } // Add any additional parameters from options if (this.options.queryParams) { Object.entries(this.options.queryParams).forEach(([key, value]) => { url.searchParams.set(key, value); }); } return url.toString(); } /** * Setup EventSource event listeners */ setupEventListeners() { if (!this.eventSource) return; this.eventSource.onopen = this.handleOpen; this.eventSource.onmessage = this.handleMessage; this.eventSource.onerror = this.handleError; // Register custom event type listeners this.options.eventTypes.forEach(eventType => { this.eventSource.addEventListener(eventType, event => { this.handleCustomEvent(eventType, event); }); }); } /** * Handle connection open */ handleOpen(event) { console.log('EventSourceManager: Connection opened', event); this.setStatus('connected'); this.reconnectAttempts = 0; this.stats.connectionStartTime = Date.now(); // Clear any pending reconnect if (this.reconnectTimeoutId) { clearTimeout(this.reconnectTimeoutId); this.reconnectTimeoutId = null; } this.emit('open', event); } /** * Handle incoming message */ handleMessage(event) { this.processEvent('message', event); } /** * Handle custom event types */ handleCustomEvent(eventType, event) { this.processEvent(eventType, event); } /** * Process incoming event with performance monitoring */ processEvent(eventType, event) { // Update statistics this.stats.totalEvents++; this.stats.eventsByType.set(eventType, (this.stats.eventsByType.get(eventType) || 0) + 1); // Store last event ID for reconnection if (event.lastEventId) { this.lastEventId = event.lastEventId; } // Parse event data let parsedData; try { parsedData = JSON.parse(event.data); } catch (error) { parsedData = event.data; } const processedEvent = { type: eventType, data: parsedData, timestamp: new Date(), id: event.lastEventId, raw: event, }; // Add to performance buffer for batching this.performanceMonitor.eventBuffer.push(processedEvent); // Process batch if needed this.processBatchIfNeeded(); // Emit individual event this.emit(eventType, processedEvent); this.emit('event', processedEvent); } /** * Process event batch for performance optimization */ processBatchIfNeeded() { const now = Date.now(); const timeSinceLastBatch = now - this.performanceMonitor.lastBatchTime; const bufferSize = this.performanceMonitor.eventBuffer.length; if ( bufferSize >= this.performanceMonitor.batchSize || timeSinceLastBatch >= this.performanceMonitor.batchTimeout ) { if (bufferSize > 0) { const batch = [...this.performanceMonitor.eventBuffer]; this.performanceMonitor.eventBuffer = []; this.performanceMonitor.lastBatchTime = now; this.emit('batch', batch); } } } /** * Handle connection errors */ handleError(error) { console.error('EventSourceManager: Connection error', error); this.stats.lastError = { message: error.message || 'Connection error', timestamp: new Date(), readyState: this.eventSource?.readyState, }; // Determine if we should attempt reconnection if (this.shouldReconnect()) { this.setStatus('error'); this.scheduleReconnect(); } else { this.setStatus('error'); console.error('EventSourceManager: Max reconnection attempts reached'); } this.emit('error', error); } /** * Determine if reconnection should be attempted */ shouldReconnect() { return ( this.reconnectAttempts < this.options.maxReconnectAttempts && this.status !== 'disconnected' ); } /** * Schedule reconnection with exponential backoff */ scheduleReconnect() { if (this.reconnectTimeoutId) { clearTimeout(this.reconnectTimeoutId); } const delay = Math.min( this.options.initialReconnectDelay * Math.pow(this.options.reconnectDecay, this.reconnectAttempts), this.options.maxReconnectDelay, ); console.log( `EventSourceManager: Scheduling reconnect in ${delay}ms (attempt ${this.reconnectAttempts + 1})`, ); this.reconnectTimeoutId = setTimeout(() => { this.reconnectAttempts++; this.stats.reconnectCount++; this.connect(); }, delay); } /** * Set connection status and notify handlers */ setStatus(newStatus) { if (this.status !== newStatus) { const oldStatus = this.status; this.status = newStatus; console.log(`EventSourceManager: Status changed from ${oldStatus} to ${newStatus}`); this.statusHandlers.forEach(handler => { try { handler(newStatus, oldStatus); } catch (error) { console.error('EventSourceManager: Error in status handler', error); } }); this.emit('statusChange', { status: newStatus, previousStatus: oldStatus }); } } /** * Update uptime statistics */ updateUptime() { if (this.stats.connectionStartTime) { this.stats.uptime += Date.now() - this.stats.connectionStartTime; this.stats.connectionStartTime = null; } } /** * Add event listener */ addEventListener(eventType, handler) { if (!this.eventHandlers.has(eventType)) { this.eventHandlers.set(eventType, new Set()); } this.eventHandlers.get(eventType).add(handler); } /** * Remove event listener */ removeEventListener(eventType, handler) { const handlers = this.eventHandlers.get(eventType); if (handlers) { handlers.delete(handler); if (handlers.size === 0) { this.eventHandlers.delete(eventType); } } } /** * Add status change listener */ onStatusChange(handler) { this.statusHandlers.add(handler); return () => this.statusHandlers.delete(handler); } /** * Add error listener */ onError(handler) { this.errorHandlers.add(handler); return () => this.errorHandlers.delete(handler); } /** * Emit event to registered handlers */ emit(eventType, data) { const handlers = this.eventHandlers.get(eventType); if (handlers) { handlers.forEach(handler => { try { handler(data); } catch (error) { console.error(`EventSourceManager: Error in ${eventType} handler`, error); } }); } } /** * Get current connection statistics */ getStats() { this.updateUptime(); return { ...this.stats, status: this.status, reconnectAttempts: this.reconnectAttempts, currentUptime: this.stats.connectionStartTime ? Date.now() - this.stats.connectionStartTime : 0, }; } /** * Get current connection status */ getStatus() { return this.status; } /** * Check if currently connected */ isConnected() { return this.status === 'connected'; } /** * Check if currently connecting */ isConnecting() { return this.status === 'connecting'; } /** * Reset statistics */ resetStats() { this.stats = { connectionStartTime: this.stats.connectionStartTime, totalEvents: 0, eventsByType: new Map(), reconnectCount: 0, lastError: null, uptime: 0, }; } /** * Cleanup resources */ destroy() { this.disconnect(); this.eventHandlers.clear(); this.statusHandlers.clear(); this.errorHandlers.clear(); } } // Export for use in other modules if (typeof module !== 'undefined' && module.exports) { module.exports = EventSourceManager; } else if (typeof window !== 'undefined') { window.EventSourceManager = EventSourceManager; }