@nestjsvn/swagger-sse
Version:
OpenAPI documentation and interactive Swagger UI for NestJS Server-Sent Events endpoints
470 lines (403 loc) • 11.2 kB
JavaScript
/**
* 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;
}