UNPKG

@nestjsvn/swagger-sse

Version:

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

1,018 lines (870 loc) 32.9 kB
/** * Enhanced Swagger UI SSE Plugin - Client-side JavaScript * * This file provides: * - EventSource connection management with performance optimizations * - Real-time event display with virtual scrolling * - Connection status indicators with theme support * - Event filtering and formatting * - Integration with vanilla JS components */ (function () { 'use strict'; // Global state for SSE connections and components window.sseConnections = new Map(); window.sseComponents = new Map(); // Performance monitoring const performanceMonitor = { eventCount: 0, renderTime: 0, lastRenderTime: Date.now(), averageRenderTime: 0, }; /** * Initialize SSE plugin when Swagger UI loads */ function initSsePlugin() { console.log('Enhanced SSE Plugin initialized'); // Initialize theme manager first initializeThemeManager(); // Wait for Swagger UI to be ready if (typeof window.ui !== 'undefined') { console.log('Swagger UI detected, setting up enhanced SSE plugin'); setupSsePlugin(); } else { // Retry after a short delay setTimeout(initSsePlugin, 500); } } /** * Initialize theme manager */ function initializeThemeManager() { if (window.ThemeManager && !window._sseThemeManager) { window._sseThemeManager = new window.ThemeManager({ defaultTheme: 'light', storageKey: 'swagger-sse-theme', autoDetectSystem: true, }); console.log('SSE Plugin: Theme manager initialized'); } } /** * Setup SSE plugin integration with Swagger UI */ function setupSsePlugin() { // Override the default execute function for SSE endpoints if (window.ui?.getSystem) { const system = window.ui.getSystem(); console.log('Enhanced SSE Plugin: Swagger UI system available'); // Monitor for SSE endpoint executions monitorSseExecutions(); // Setup performance monitoring setupPerformanceMonitoring(); // Setup accessibility features setupAccessibilityFeatures(); } } /** * Setup performance monitoring */ function setupPerformanceMonitoring() { // Monitor render performance const observer = new PerformanceObserver(list => { for (const entry of list.getEntries()) { if (entry.name.includes('sse-render')) { performanceMonitor.renderTime += entry.duration; performanceMonitor.averageRenderTime = performanceMonitor.renderTime / ++performanceMonitor.eventCount; } } }); if (typeof PerformanceObserver !== 'undefined') { observer.observe({ entryTypes: ['measure'] }); } } /** * Setup accessibility features */ function setupAccessibilityFeatures() { // Add keyboard navigation support document.addEventListener('keydown', handleKeyboardNavigation); // Add focus management document.addEventListener('focusin', handleFocusManagement); // Add screen reader announcements setupScreenReaderSupport(); } /** * Handle keyboard navigation */ function handleKeyboardNavigation(event) { if (event.target.closest('.sse-interface')) { switch (event.key) { case 'Escape': // Close any open modals or reset focus const activeElement = document.activeElement; if (activeElement && activeElement.blur) { activeElement.blur(); } break; case 'Tab': // Ensure proper tab order document.body.classList.add('sse-keyboard-navigation'); break; } } } /** * Handle focus management */ function handleFocusManagement(event) { // Remove keyboard navigation class on mouse interaction document.addEventListener( 'mousedown', () => { document.body.classList.remove('sse-keyboard-navigation'); }, { once: true }, ); } /** * Setup screen reader support */ function setupScreenReaderSupport() { // Create live region for announcements if (!document.getElementById('sse-live-region')) { const liveRegion = document.createElement('div'); liveRegion.id = 'sse-live-region'; liveRegion.setAttribute('aria-live', 'polite'); liveRegion.setAttribute('aria-atomic', 'true'); liveRegion.style.position = 'absolute'; liveRegion.style.left = '-10000px'; liveRegion.style.width = '1px'; liveRegion.style.height = '1px'; liveRegion.style.overflow = 'hidden'; document.body.appendChild(liveRegion); } } /** * Announce to screen readers */ function announceToScreenReader(message) { const liveRegion = document.getElementById('sse-live-region'); if (liveRegion) { liveRegion.textContent = message; setTimeout(() => { liveRegion.textContent = ''; }, 1000); } } /** * Extract event types from OpenAPI schema */ function extractEventTypesFromSchema(url) { try { if (!window.ui || !window.ui.getSystem) { return []; } const system = window.ui.getSystem(); const state = system.getState(); if (!state) { return []; } // Get the spec from Swagger UI state let spec = null; if (state.get) { spec = state.get('spec'); } else if (state.spec) { spec = state.spec; } if (!spec) { return []; } // Access the parsed JSON spec using Immutable.js Map methods const specJson = spec.get('json'); if (!specJson) { return []; } // Convert Immutable.js structure to regular JavaScript object if needed let paths = null; if (specJson.get) { paths = specJson.get('paths'); } else if (specJson.paths) { paths = specJson.paths; } if (!paths) { // Try to get paths from resolvedSubtrees as fallback const resolvedSubtrees = spec.get('resolvedSubtrees'); if (resolvedSubtrees && resolvedSubtrees.get) { paths = resolvedSubtrees.get('paths'); } else { return []; } } const eventTypes = new Set(); // Extract path from URL const urlPath = new URL(url).pathname; // Handle both Immutable.js Map and regular JavaScript object const pathKeys = paths.keySeq ? paths.keySeq().toArray() : Object.keys(paths); // Look for matching path in OpenAPI spec pathKeys.forEach(pathKey => { if (pathKey === urlPath || urlPath.includes(pathKey)) { const pathItem = paths.get ? paths.get(pathKey) : paths[pathKey]; const methodKeys = pathItem.keySeq ? pathItem.keySeq().toArray() : Object.keys(pathItem); methodKeys.forEach(method => { const operation = pathItem.get ? pathItem.get(method) : pathItem[method]; // Look for SSE-specific extensions or schemas const responses = operation.get ? operation.get('responses') : operation.responses; if (responses) { const response200 = responses.get ? responses.get('200') : responses['200']; if (response200) { const content = response200.get ? response200.get('content') : response200.content; if (content) { const eventStreamContent = content.get ? content.get('text/event-stream') : content['text/event-stream']; if (eventStreamContent) { const schema = eventStreamContent.get ? eventStreamContent.get('schema') : eventStreamContent.schema; // Extract event types from oneOf discriminator pattern const oneOf = schema.get ? schema.get('oneOf') : schema.oneOf; if (oneOf) { const oneOfArray = oneOf.toArray ? oneOf.toArray() : oneOf; oneOfArray.forEach(eventSchema => { const properties = eventSchema.get ? eventSchema.get('properties') : eventSchema.properties; if (properties) { const typeProperty = properties.get ? properties.get('type') : properties.type; if (typeProperty) { const typeValue = (typeProperty.get ? typeProperty.get('const') : typeProperty.const) || (typeProperty.get ? typeProperty.get('enum') : typeProperty.enum); const finalValue = Array.isArray(typeValue) ? typeValue[0] : typeValue; if (finalValue) { eventTypes.add(finalValue); } } } }); } // Check for x-sse-events extension const xSseEvents = schema.get ? schema.get('x-sse-events') : schema['x-sse-events']; if (xSseEvents) { const eventsArray = xSseEvents.toArray ? xSseEvents.toArray() : xSseEvents; eventsArray.forEach(eventType => { eventTypes.add(eventType); }); } } } } } }); } }); const result = Array.from(eventTypes); console.log('Enhanced SSE Plugin: Extracted event types from schema:', result); return result; } catch (error) { console.warn('Enhanced SSE Plugin: Error extracting event types from schema:', error); return []; } } /** * Monitor for SSE endpoint executions */ function monitorSseExecutions() { console.log('Enhanced SSE Plugin: Setting up execution monitoring'); // Store original fetch to intercept requests if needed const originalFetch = window.fetch; window.fetch = function (url) { // Check if this is an SSE endpoint request if (typeof url === 'string' && isSseEndpoint(url)) { console.log('Enhanced SSE Plugin: Intercepting fetch request to SSE endpoint:', url); // Return a rejected promise to prevent the request return Promise.reject(new Error('SSE endpoint request intercepted by plugin')); } return originalFetch.apply(this, arguments); }; // Listen for execute button clicks on SSE endpoints document.addEventListener( 'click', function (event) { const executeBtn = event.target.closest('.execute-wrapper .btn.execute'); if (!executeBtn) { return; } console.log('Enhanced SSE Plugin: Execute button clicked:', executeBtn); // Check if this is an SSE endpoint const operationContainer = executeBtn.closest('.opblock'); if (!operationContainer) { return; } const pathElement = operationContainer.querySelector('.opblock-summary-path'); if (!pathElement) { return; } const path = pathElement.textContent.trim(); console.log('Enhanced SSE Plugin: Endpoint path:', path); // Check if this path corresponds to an SSE endpoint if (isSseEndpoint(path)) { console.log('Enhanced SSE Plugin: Confirmed SSE endpoint, intercepting execution'); // Prevent the default Swagger UI execution event.preventDefault(); event.stopPropagation(); // Handle SSE execution with custom UI handleSseExecution(operationContainer, path); return false; } }, true, ); } /** * Check if a path is an SSE endpoint by examining OpenAPI schema */ function isSseEndpoint(path) { try { // Get the current state from Swagger UI const state = window.ui?.getState ? window.ui.getState() : null; if (!state) { return false; } // Get the spec from Swagger UI state let spec = null; if (state.get) { spec = state.get('spec'); } else if (state.spec) { spec = state.spec; } if (!spec) { return false; } // Access the parsed JSON spec using Immutable.js Map methods const specJson = spec.get('json'); if (!specJson) { return false; } // Convert Immutable.js structure to regular JavaScript object if needed let paths = null; if (specJson.get) { paths = specJson.get('paths'); } else if (specJson.paths) { paths = specJson.paths; } if (!paths) { // Try to get paths from resolvedSubtrees as fallback const resolvedSubtrees = spec.get('resolvedSubtrees'); if (resolvedSubtrees && resolvedSubtrees.get) { paths = resolvedSubtrees.get('paths'); } else { return false; } } // Parse the URL to get the path const urlPath = path.startsWith('http') ? new URL(path).pathname : path; // Handle both Immutable.js Map and regular JavaScript object const pathKeys = paths.keySeq ? paths.keySeq().toArray() : Object.keys(paths); // Look for matching path in OpenAPI spec for (const pathKey of pathKeys) { if (pathKey === urlPath || urlPath.includes(pathKey)) { const pathItem = paths.get ? paths.get(pathKey) : paths[pathKey]; if (pathItem) { const methodKeys = pathItem.keySeq ? pathItem.keySeq().toArray() : Object.keys(pathItem); // Check each HTTP method for SSE indicators for (const method of methodKeys) { const operation = pathItem.get ? pathItem.get(method) : pathItem[method]; // Check for x-sse-endpoint extension (primary indicator) const xSseEndpoint = operation.get ? operation.get('x-sse-endpoint') : operation['x-sse-endpoint']; if (xSseEndpoint) { return true; } // Check for text/event-stream content-type in responses const responses = operation.get ? operation.get('responses') : operation.responses; if (responses) { const response200 = responses.get ? responses.get('200') : responses['200']; if (response200) { const content = response200.get ? response200.get('content') : response200.content; if (content) { const eventStreamContent = content.get ? content.get('text/event-stream') : content['text/event-stream']; if (eventStreamContent) { return true; } } } } } } } } return false; } catch (error) { console.warn('Enhanced SSE Plugin: Error checking SSE endpoint:', error); return false; } } /** * Extract parameters from Swagger UI form */ function extractParametersFromForm(container) { const parameters = {}; // Find all parameter inputs const parameterInputs = container.querySelectorAll( '.parameters-container input, .parameters-container select, .parameters-container textarea', ); parameterInputs.forEach(input => { const paramName = input.getAttribute('data-param-name') || input.name; if (paramName && input.value) { parameters[paramName] = input.value; } }); return parameters; } /** * Build URL with parameters */ function buildUrlWithParameters(baseUrl, parameters) { if (!parameters || Object.keys(parameters).length === 0) { return baseUrl; } const url = new URL(baseUrl); Object.entries(parameters).forEach(([key, value]) => { url.searchParams.append(key, value); }); return url.toString(); } /** * Handle SSE endpoint execution with enhanced components */ function handleSseExecution(container, path) { console.log('Enhanced SSE Plugin: Handling SSE execution for:', path); // Extract parameters from the form const parameters = extractParametersFromForm(container); // Build the full URL with parameters const baseUrl = window.location.origin; const fullUrl = buildUrlWithParameters(baseUrl + path, parameters); // Create enhanced SSE interface createEnhancedSseInterface(container, fullUrl, parameters); } /** * Create enhanced SSE interface with vanilla JS components */ function createEnhancedSseInterface(container, url, parameters = {}) { console.log('Enhanced SSE Plugin: Creating enhanced SSE interface for:', url); // Find the responses wrapper const responsesWrapper = container.querySelector('.responses-wrapper'); if (!responsesWrapper) { console.error('Enhanced SSE Plugin: Could not find responses wrapper'); return; } // Remove any existing SSE interface const existingInterface = responsesWrapper.querySelector('.sse-interface'); if (existingInterface) { // Cleanup existing components cleanupSseComponents(existingInterface); existingInterface.remove(); } // Create the enhanced SSE interface HTML const sseInterface = document.createElement('div'); sseInterface.className = 'sse-interface'; sseInterface.setAttribute('role', 'region'); sseInterface.setAttribute('aria-label', 'Server-Sent Events Interface'); // Build parameters display const parametersHtml = Object.keys(parameters).length > 0 ? ` <div class="sse-parameters" style="margin-bottom: 1rem; padding: 1rem; background-color: #f8f9fa; border-radius: 4px; border: 1px solid #e9ecef;"> <h4 style="margin: 0 0 0.5rem 0; font-size: 1rem; color: #495057;">Parameters</h4> <div class="sse-parameters-list"> ${Object.entries(parameters) .map( ([key, value]) => ` <div class="sse-parameter-item" style="margin-bottom: 0.25rem;"> <span class="sse-parameter-name" style="font-weight: bold; color: #007bff;">${key}:</span> <span class="sse-parameter-value" style="margin-left: 0.5rem; color: #28a745;">${value}</span> </div> `, ) .join('')} </div> </div> ` : ''; sseInterface.innerHTML = ` <div class="sse-interface-header"> <h3>Server-Sent Events</h3> <div class="sse-theme-controls"> <div id="sse-theme-selector"></div> </div> </div> ${parametersHtml} <div class="sse-connection-container" id="sse-connection-${Date.now()}"> <!-- Connection status component will be inserted here --> </div> <div class="sse-filter-container" id="sse-filter-${Date.now()}"> <!-- Event filter component will be inserted here --> </div> <div class="sse-events-container" id="sse-events-${Date.now()}"> <!-- Event list component will be inserted here --> </div> <div class="sse-performance-stats" style="display: none;"> <small> Events: <span id="sse-event-count">0</span> | Avg Render: <span id="sse-avg-render">0ms</span> </small> </div> `; // Insert the interface responsesWrapper.appendChild(sseInterface); // Initialize enhanced components initializeEnhancedComponents(sseInterface, url, parameters); // Setup theme selector setupThemeSelector(sseInterface); // Hide loading elements hideLoadingElements(container); // Announce interface creation announceToScreenReader('Server-Sent Events interface created'); console.log('Enhanced SSE Plugin: Enhanced interface creation completed'); } /** * Initialize enhanced vanilla JS components */ function initializeEnhancedComponents(interfaceElement, url, parameters = {}) { createBasicSseInterface(interfaceElement, url, parameters); } /** * Setup theme selector */ function setupThemeSelector(interfaceElement) { const themeContainer = interfaceElement.querySelector('#sse-theme-selector'); if (window._sseThemeManager && themeContainer) { window._sseThemeManager.createThemeToggle(themeContainer, { showLabel: false, className: 'sse-theme-toggle-btn', }); console.log('Enhanced SSE Plugin: Theme selector initialized'); } } /** * Update performance statistics */ function updatePerformanceStats() { performanceMonitor.eventCount++; const eventCountElement = document.getElementById('sse-event-count'); const avgRenderElement = document.getElementById('sse-avg-render'); if (eventCountElement) { eventCountElement.textContent = performanceMonitor.eventCount; } if (avgRenderElement) { avgRenderElement.textContent = `${performanceMonitor.averageRenderTime.toFixed(1)}ms`; } // Show performance stats if event count is high if (performanceMonitor.eventCount > 50) { const statsElement = document.querySelector('.sse-performance-stats'); if (statsElement) { statsElement.style.display = 'block'; } } } /** * Create basic SSE interface as fallback */ function createBasicSseInterface(interfaceElement, url, parameters = {}) { console.log('Enhanced SSE Plugin: Creating basic fallback interface'); // Build parameters display const parametersHtml = Object.keys(parameters).length > 0 ? ` <div class="sse-parameters" style="margin-bottom: 1rem; padding: 1rem; background-color: #f8f9fa; border-radius: 4px; border: 1px solid #e9ecef;"> <h4 style="margin: 0 0 0.5rem 0; font-size: 1rem; color: #495057;">Parameters</h4> <div class="sse-parameters-list"> ${Object.entries(parameters) .map( ([key, value]) => ` <div class="sse-parameter-item" style="margin-bottom: 0.25rem;"> <span class="sse-parameter-name" style="font-weight: bold; color: #007bff;">${key}:</span> <span class="sse-parameter-value" style="margin-left: 0.5rem; color: #28a745;">${value}</span> </div> `, ) .join('')} </div> </div> ` : ''; interfaceElement.innerHTML = ` ${parametersHtml} <div class="sse-controls"> <button class="sse-btn sse-btn-primary sse-connect-btn" data-testid="sse-connect-btn"> Connect </button> <button class="sse-btn sse-btn-danger sse-disconnect-btn" data-testid="sse-disconnect-btn" disabled> Disconnect </button> <span class="sse-status disconnected" data-testid="sse-status" data-status="disconnected"> DISCONNECTED </span> </div> <div class="sse-event-log" data-testid="sse-event-log"> <h4>Event Log</h4> <div class="sse-events"></div> <div class="sse-log-controls"> <button class="sse-btn sse-btn-secondary sse-btn-sm sse-clear-btn" data-testid="sse-clear-btn"> Clear Log </button> <button class="sse-btn sse-btn-secondary sse-btn-sm sse-export-btn"> Export JSON </button> </div> </div> `; // Setup basic event listeners setupBasicSseInterfaceListeners(interfaceElement, url); } /** * Setup event listeners for basic SSE interface */ function setupBasicSseInterfaceListeners(interfaceElement, url) { const connectBtn = interfaceElement.querySelector('.sse-connect-btn'); const disconnectBtn = interfaceElement.querySelector('.sse-disconnect-btn'); const clearBtn = interfaceElement.querySelector('.sse-clear-btn'); const exportBtn = interfaceElement.querySelector('.sse-export-btn'); const statusElement = interfaceElement.querySelector('.sse-status'); const eventsContainer = interfaceElement.querySelector('.sse-events'); if (!eventsContainer) { console.error('Enhanced SSE Plugin: Events container not found in interface!'); return; } let eventSource = null; let events = []; // Connect button connectBtn.addEventListener('click', () => { console.log('Enhanced SSE Plugin: Basic connect button clicked'); try { if (typeof EventSource === 'undefined') { console.error('Enhanced SSE Plugin: EventSource not supported'); updateStatus('error'); announceToScreenReader('EventSource not supported in this browser'); return; } if (eventSource) { eventSource.close(); eventSource = null; } // Extract event types from the OpenAPI schema if available const eventTypes = extractEventTypesFromSchema(url); console.log('Enhanced SSE Plugin: Extracted event types from schema:', eventTypes); // Create simple EventSource connection eventSource = new EventSource(url); // Store connection for cleanup window.sseConnections.set(url, eventSource); // Standard EventSource event handlers eventSource.onopen = event => { console.log('Enhanced SSE Plugin: Connection opened', event); updateStatus('connected'); connectBtn.disabled = true; disconnectBtn.disabled = false; announceToScreenReader('Connected to server'); }; eventSource.onerror = error => { console.error('Enhanced SSE Plugin: Connection error:', error); updateStatus('error'); connectBtn.disabled = false; disconnectBtn.disabled = true; announceToScreenReader('Connection error occurred'); }; eventSource.onmessage = event => { console.log('Enhanced SSE Plugin: Default message received:', event); // Try to extract event type from message data as fallback try { const data = JSON.parse(event.data); if (data.type && typeof data.type === 'string') { const eventType = data.type; console.log('Enhanced SSE Plugin: Detected event type from message data:', eventType); // Register listener for this event type if not already registered if (!eventTypes.includes(eventType)) { eventTypes.push(eventType); console.log( 'Enhanced SSE Plugin: Auto-registering handler for discovered event type:', eventType, ); eventSource.addEventListener(eventType, typedEvent => { console.log( `Enhanced SSE Plugin: Typed event handler for ${eventType}:`, typedEvent, ); addEvent(eventType, typedEvent.data, typedEvent); }); } addEvent(eventType, event.data, event); return; } } catch (e) { // Not JSON or no type field, treat as generic message } addEvent('message', event.data, event); }; // Register listeners for schema-defined event types if (eventTypes && eventTypes.length > 0) { eventTypes.forEach(eventType => { console.log(`Enhanced SSE Plugin: Registering listener for event type: ${eventType}`); eventSource.addEventListener(eventType, event => { console.log(`Enhanced SSE Plugin: Received ${eventType} event:`, event); addEvent(eventType, event.data, event); }); }); } console.log( 'Enhanced SSE Plugin: Simple EventSource initialized with schema-based event listeners', ); updateStatus('connecting'); announceToScreenReader('Connecting to server'); } catch (error) { console.error('Enhanced SSE Plugin: Basic connection exception:', error); updateStatus('error'); connectBtn.disabled = false; disconnectBtn.disabled = true; announceToScreenReader('Failed to connect to server'); } }); // Disconnect button disconnectBtn.addEventListener('click', () => { console.log('Enhanced SSE Plugin: Disconnecting'); if (eventSource) { eventSource.close(); window.sseConnections.delete(url); eventSource = null; } updateStatus('disconnected'); connectBtn.disabled = false; disconnectBtn.disabled = true; announceToScreenReader('Disconnected from server'); }); // Clear button clearBtn.addEventListener('click', () => { events = []; eventsContainer.innerHTML = ''; announceToScreenReader('Event log cleared'); }); // Export button exportBtn.addEventListener('click', () => { const data = JSON.stringify(events, null, 2); const blob = new Blob([data], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `sse-events-${new Date().toISOString()}.json`; a.click(); URL.revokeObjectURL(url); announceToScreenReader('Events exported to JSON file'); }); // Helper functions function updateStatus(status) { statusElement.className = `sse-status ${status}`; statusElement.textContent = status.toUpperCase(); statusElement.setAttribute('data-status', status); } function addEvent(type, data, originalEvent) { const timestamp = new Date(); const event = { type, data: tryParseJson(data), timestamp: timestamp.toISOString(), id: originalEvent.lastEventId || null, }; events.push(event); // Add to UI with performance optimization const eventElement = document.createElement('div'); eventElement.className = 'sse-event-item'; eventElement.setAttribute('data-testid', 'sse-event'); eventElement.setAttribute('data-event-type', type); eventElement.innerHTML = ` <div class="sse-event-header"> <span class="sse-event-type">${type}</span> <span class="sse-event-timestamp">${timestamp.toLocaleTimeString()}</span> </div> <div class="sse-event-data">${JSON.stringify(event.data, null, 2)}</div> `; eventsContainer.appendChild(eventElement); // Performance optimization: limit events if (events.length > 100) { events = events.slice(-100); const eventElements = eventsContainer.children; if (eventElements.length > 100) { eventsContainer.removeChild(eventElements[0]); } } // Smooth scroll to bottom eventsContainer.scrollTo({ top: eventsContainer.scrollHeight, behavior: 'smooth', }); // Update performance stats updatePerformanceStats(); } function tryParseJson(data) { try { return JSON.parse(data); } catch { return data; } } } /** * Cleanup SSE components */ function cleanupSseComponents(interfaceElement) { // Close any active EventSource connections const eventSource = interfaceElement.querySelector('.sse-connect-btn')?._eventSource; if (eventSource) { eventSource.close(); } console.log('Enhanced SSE Plugin: Components cleaned up'); } /** * Hide loading elements */ function hideLoadingElements(container) { const loadingElement = container.querySelector('.loading-container'); if (loadingElement) { loadingElement.style.display = 'none'; } const executeBtn = container.querySelector('.execute-wrapper .btn.execute'); if (executeBtn) { executeBtn.classList.remove('loading'); executeBtn.disabled = false; executeBtn.style.opacity = '1'; executeBtn.style.pointerEvents = 'auto'; } } // Cleanup on page unload window.addEventListener('beforeunload', () => { // Close all active EventSource connections window.sseConnections.forEach(connection => { if (connection && connection.close) { connection.close(); } }); window.sseConnections.clear(); }); // Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initSsePlugin); } else { initSsePlugin(); } })();