UNPKG

@nestjsvn/swagger-sse

Version:

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

614 lines (613 loc) 23.3 kB
"use strict"; /** * Swagger UI plugin for Server-Sent Events support * * This plugin will be implemented in Phase 4 to provide interactive * SSE streaming capabilities within the Swagger UI interface. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.SwaggerSsePlugin = void 0; exports.createSseConnection = createSseConnection; exports.createEventSourceConnection = createEventSourceConnection; /** * Swagger UI SSE Plugin * * This plugin extends Swagger UI to handle SSE endpoints by: * - Detecting x-sse-endpoint extension in OpenAPI spec * - Providing connect/disconnect controls * - Displaying real-time event log * - Handling EventSource connections */ const SwaggerSsePlugin = function () { return { name: 'swagger-sse-plugin', // Plugin initialization init(system) { // Register custom components and actions system.register({ components: { SseExecuteButton: createSseExecuteButton(system), SseEventLog: createSseEventLog(system), SseConnectionStatus: createSseConnectionStatus(system), }, actions: { startSseConnection: (url, eventTypes) => startSseConnection(url, eventTypes, system), stopSseConnection: () => stopSseConnection(system), addSseEvent: (event) => addSseEvent(event, system), }, }); }, // Wrap existing actions statePlugins: { spec: { wrapActions: { execute: (ori, system) => (req) => { const { pathName, method, spec } = req; // Handle both Immutable.js Map and regular JavaScript object for spec let paths = null; if (spec?.get) { // spec is an Immutable.js Map paths = spec.get('paths'); } else if (spec?.paths) { // spec is a regular JavaScript object paths = spec.paths; } let operation = null; if (paths) { if (paths.get) { // paths is an Immutable.js Map const pathItem = paths.get(pathName); if (pathItem?.get) { operation = pathItem.get(method); } } else if (paths[pathName]) { // paths is a regular JavaScript object operation = paths[pathName][method]; } } // Check if this is an SSE endpoint if (operation?.['x-sse-endpoint']) { return handleSseRequest(req, system); } return ori(req); }, }, }, }, }; }; exports.SwaggerSsePlugin = SwaggerSsePlugin; /** * Handle SSE request execution * * @param req Request object * @param system Swagger UI system * @returns SSE connection result */ function handleSseRequest(req, system) { const url = buildRequestUrl(req, system); const eventTypes = extractEventTypes(req); const connection = createEventSourceConnection(url, eventTypes); setTimeout(() => { injectSseInterface(req, connection, system); }, 100); return { ...req, type: 'sse-stream', connection, url, eventTypes, }; } /** * Create EventSource connection for SSE streaming * * @param url SSE endpoint URL * @param options Connection options * @returns SSE connection object */ function createSseConnection(_url, _options) { const connection = { eventSource: null, status: 'disconnected', events: [], }; return connection; } /** * Create EventSource connection with event handling */ function createEventSourceConnection(url, eventTypes) { const connection = { eventSource: null, status: 'connecting', events: [], }; try { const eventSource = new EventSource(url); connection.eventSource = eventSource; eventSource.onopen = () => { connection.status = 'connected'; updateConnectionStatus('connected'); }; eventSource.onerror = error => { connection.status = 'error'; updateConnectionStatus('error'); console.error('SSE Error:', error); }; eventSource.onmessage = event => { const sseEvent = { type: 'message', timestamp: new Date(), // eslint-disable-next-line @typescript-eslint/no-unsafe-argument data: tryParseJson(event.data), }; connection.events.push(sseEvent); updateEventLog(connection.events); }; // Register handlers for each event type eventTypes.forEach(eventType => { eventSource.addEventListener(eventType, (event) => { const sseEvent = { type: eventType, timestamp: new Date(), // eslint-disable-next-line @typescript-eslint/no-unsafe-argument data: tryParseJson(event.data), }; connection.events.push(sseEvent); updateEventLog(connection.events); }); }); } catch (error) { connection.status = 'error'; console.error('Failed to create EventSource:', error); } return connection; } /** * Build request URL from Swagger UI request object */ function buildRequestUrl(req, system) { const { pathName, method: _method, parameters = {} } = req; const baseUrl = system.getState().getIn(['spec', 'url']) || window.location.origin; let url = baseUrl.replace(/\/swagger.*$/, '') + pathName; // Add query parameters const queryParams = new URLSearchParams(); // eslint-disable-next-line @typescript-eslint/no-unsafe-argument Object.entries(parameters).forEach(([key, value]) => { if (value !== undefined && value !== null) { queryParams.append(key, String(value)); } }); if (queryParams.toString()) { url += `?${queryParams.toString()}`; } return url; } /** * Extract event types from operation schema */ function extractEventTypes(req) { const { pathName, method, spec } = req; // Handle both Immutable.js Map and regular JavaScript object for spec let paths = null; if (spec?.get) { // spec is an Immutable.js Map paths = spec.get('paths'); } else if (spec?.paths) { // spec is a regular JavaScript object paths = spec.paths; } let operation = null; if (paths) { if (paths.get) { // paths is an Immutable.js Map const pathItem = paths.get(pathName); if (pathItem?.get) { operation = pathItem.get(method); } } else if (paths[pathName]) { // paths is a regular JavaScript object operation = paths[pathName][method]; } } if (!operation) return []; const schema = operation.responses?.['200']?.content?.['text/event-stream']?.schema; if (!schema) return []; // Extract event types from oneOf discriminator mapping if (schema.discriminator?.mapping) { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument return Object.keys(schema.discriminator.mapping); } // Extract from oneOf schemas if (schema.oneOf) { return schema.oneOf.map((s) => s.properties?.event?.const).filter(Boolean); } return []; } /** * Try to parse JSON, return original string if parsing fails */ function tryParseJson(data) { try { return JSON.parse(data); } catch { return data; } } /** * Update connection status in UI */ function updateConnectionStatus(status) { const statusElement = document.querySelector('.sse-status'); if (statusElement) { statusElement.className = `sse-status ${status}`; statusElement.textContent = status.toUpperCase(); } } /** * Update event log in UI */ function updateEventLog(events) { const eventsContainer = document.querySelector('.sse-events'); if (!eventsContainer) return; // Show only last 100 events for performance const recentEvents = events.slice(-100); eventsContainer.innerHTML = recentEvents .map(event => ` <div class="sse-event-item" data-testid="event-item"> <div class="sse-event-header"> <span class="sse-event-type">${event.type}</span> <span class="sse-event-timestamp">${event.timestamp.toLocaleTimeString()}</span> </div> <div class="sse-event-data">${JSON.stringify(event.data, null, 2)}</div> </div> `) .join(''); eventsContainer.scrollTop = eventsContainer.scrollHeight; } /** * Inject SSE interface into Swagger UI */ function injectSseInterface(req, connection, _system) { const operationContainer = document.querySelector(`[data-path="${req.pathName}"][data-method="${req.method}"] .responses-wrapper`); if (!operationContainer) return; const existingInterface = operationContainer.querySelector('.sse-interface'); if (existingInterface) { existingInterface.remove(); } const sseInterface = createSseInterface(connection); operationContainer.appendChild(sseInterface); } /** * Create SSE interface HTML element */ function createSseInterface(connection) { const container = document.createElement('div'); container.className = 'sse-interface'; container.innerHTML = ` <div class="sse-controls"> <button class="sse-connect-btn" data-testid="sse-connect-btn">Connect</button> <button class="sse-disconnect-btn" data-testid="sse-disconnect-btn" disabled>Disconnect</button> <span class="sse-status disconnected" data-testid="sse-status">DISCONNECTED</span> </div> <div class="sse-event-log" data-testid="event-log"> <h4>Event Log</h4> <div class="sse-events"></div> <div class="sse-log-controls"> <button class="sse-clear-btn">Clear Log</button> <button class="sse-export-btn">Export JSON</button> </div> </div> `; // Add event listeners const connectBtn = container.querySelector('.sse-connect-btn'); const disconnectBtn = container.querySelector('.sse-disconnect-btn'); const clearBtn = container.querySelector('.sse-clear-btn'); const exportBtn = container.querySelector('.sse-export-btn'); connectBtn.addEventListener('click', () => { if (connection.eventSource) { connection.status = 'connected'; connectBtn.disabled = true; disconnectBtn.disabled = false; updateConnectionStatus('connected'); } }); disconnectBtn.addEventListener('click', () => { if (connection.eventSource) { connection.eventSource.close(); connection.eventSource = null; connection.status = 'disconnected'; connectBtn.disabled = false; disconnectBtn.disabled = true; updateConnectionStatus('disconnected'); } }); clearBtn.addEventListener('click', () => { connection.events = []; updateEventLog(connection.events); }); exportBtn.addEventListener('click', () => { const data = JSON.stringify(connection.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); }); return container; } /** * Component factory functions for Swagger UI * These create vanilla JavaScript components that integrate with Swagger UI */ function createSseExecuteButton(system) { return (props) => { // Create a container element that will hold our vanilla JS component const container = document.createElement('div'); container.className = 'sse-execute-button-container'; // Initialize the EventFilter component setTimeout(() => { try { // Load the UI components script if not already loaded void loadUIAssets() .then(() => { if (window.SSEComponents) { const eventFilter = new window.SSEComponents.EventFilterComponent(container, { eventTypes: props.eventTypes || ['message', 'error', 'open', 'close'], onFilterChange: (filters) => { // Emit filter change to Swagger UI system system.fn.execute({ type: 'SSE_FILTER_CHANGE', payload: filters, }); }, }); // Store reference for cleanup container._sseComponent = eventFilter; } }) .catch(error => { console.error('Failed to load UI assets for EventFilter:', error); }); } catch (error) { console.error('Failed to initialize SSE Execute Button:', error); } }, 0); return container; }; } function createSseEventLog(system) { return (props) => { // Create a container element that will hold our vanilla JS component const container = document.createElement('div'); container.className = 'sse-event-log-container'; // Initialize the EventList component setTimeout(() => { try { void loadUIAssets() .then(() => { if (window.SSEComponents) { const eventList = new window.SSEComponents.EventListComponent(container, { maxEvents: props.maxEvents || 1000, virtualScrolling: true, autoScroll: true, onEventSelect: (event) => { // Emit event selection to Swagger UI system system.fn.execute({ type: 'SSE_EVENT_SELECT', payload: event, }); }, }); // Store reference for cleanup container._sseComponent = eventList; // Listen for new events from the system const unsubscribe = system.subscribe((state) => { const events = state.getIn(['sse', 'events'], []); if (events.length > 0) { eventList.addEvents(events); } }); container._unsubscribe = unsubscribe; } }) .catch(error => { console.error('Failed to load UI assets for EventList:', error); }); } catch (error) { console.error('Failed to initialize SSE Event Log:', error); } }, 0); return container; }; } function createSseConnectionStatus(system) { return (props) => { // Create a container element that will hold our vanilla JS component const container = document.createElement('div'); container.className = 'sse-connection-status-container'; // Initialize the ConnectionStatus component setTimeout(() => { try { void loadUIAssets() .then(() => { if (window.SSEComponents && window.EventSourceManager) { const connectionStatus = new window.SSEComponents.ConnectionStatusComponent(container, { showStats: true, showControls: true, onConnect: () => { // Start SSE connection const url = props.url || system.getState().getIn(['sse', 'url']); const eventTypes = props.eventTypes || system.getState().getIn(['sse', 'eventTypes'], []); if (url) { const manager = new window.EventSourceManager(url, { eventTypes, maxReconnectAttempts: 10, }); // Store manager reference container._sseManager = manager; // Setup event listeners manager.addEventListener('event', (event) => { system.fn.execute({ type: 'SSE_EVENT_RECEIVED', payload: event, }); }); manager.onStatusChange((status) => { connectionStatus.setStatus(status); system.fn.execute({ type: 'SSE_STATUS_CHANGE', payload: { status }, }); }); manager.connect(); } }, onDisconnect: () => { // Stop SSE connection if (container._sseManager) { container._sseManager.disconnect(); container._sseManager = null; } connectionStatus.setStatus('disconnected'); }, onReconnect: () => { // Reconnect SSE connection if (container._sseManager) { container._sseManager.reconnect(); } }, }); // Store reference for cleanup container._sseComponent = connectionStatus; } }) .catch(error => { console.error('Failed to load UI assets for ConnectionStatus:', error); }); } catch (error) { console.error('Failed to initialize SSE Connection Status:', error); } }, 0); return container; }; } /** * Action functions for Swagger UI state management */ function startSseConnection(url, eventTypes, _system) { return createEventSourceConnection(url, eventTypes); } function stopSseConnection(_system) { // Implementation for stopping SSE connection } function addSseEvent(_event, _system) { // Implementation for adding SSE event to state } /** * Load UI assets (CSS and JavaScript) for SSE components */ async function loadUIAssets() { // Check if assets are already loaded if (window.SSEComponents && window.EventSourceManager && window.ThemeManager) { return Promise.resolve(); } const assetsToLoad = [ // CSS files { type: 'css', url: '/swagger-sse-assets/styles/sse-plugin.css' }, { type: 'css', url: '/swagger-sse-assets/styles/components.css' }, { type: 'css', url: '/swagger-sse-assets/styles/themes/light.css' }, { type: 'css', url: '/swagger-sse-assets/styles/themes/dark.css' }, // JavaScript files { type: 'js', url: '/swagger-sse-assets/scripts/event-source-manager.js' }, { type: 'js', url: '/swagger-sse-assets/scripts/ui-components.js' }, { type: 'js', url: '/swagger-sse-assets/scripts/theme-manager.js' }, ]; const loadPromises = assetsToLoad.map(asset => { if (asset.type === 'css') { return loadCSS(asset.url); } else { return loadScript(asset.url); } }); try { await Promise.all(loadPromises); // Initialize theme manager if (window.ThemeManager && !window._sseThemeManager) { window._sseThemeManager = new window.ThemeManager({ defaultTheme: 'light', storageKey: 'swagger-sse-theme', }); } } catch (error) { console.error('Failed to load SSE UI assets:', error); throw error; } } /** * Load CSS file dynamically */ function loadCSS(url) { return new Promise((resolve, reject) => { // Check if CSS is already loaded const existingLink = document.querySelector(`link[href="${url}"]`); if (existingLink) { resolve(); return; } const link = document.createElement('link'); link.rel = 'stylesheet'; link.type = 'text/css'; link.href = url; link.onload = () => { resolve(); }; link.onerror = () => { console.error('Failed to load CSS:', url); reject(new Error(`Failed to load CSS: ${url}`)); }; document.head.appendChild(link); }); } /** * Load JavaScript file dynamically */ function loadScript(url) { return new Promise((resolve, reject) => { // Check if script is already loaded const existingScript = document.querySelector(`script[src="${url}"]`); if (existingScript) { resolve(); return; } const script = document.createElement('script'); script.type = 'text/javascript'; script.src = url; script.onload = () => { resolve(); }; script.onerror = () => { console.error('Failed to load script:', url); reject(new Error(`Failed to load script: ${url}`)); }; document.head.appendChild(script); }); }