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