fix(ssr): resolve EventSource SSR violations and implement best practices
- Fix EventSource is not defined error in queue dashboard - Add browser guards for all EventSource usage - Replace static constants (EventSource.OPEN/CLOSED) with numeric values - Fix setInterval SSR violation in LLM health indicator - Replace $effect anti-pattern with onMount in share page - Add comprehensive SvelteKit SSR best practices documentation - Add SSR audit and testing verification All changes follow SvelteKit best practices and are verified against official documentation. Production build succeeds with no SSR errors. Closes: FixEventSourceSSR See: docs/outcomes/FixEventSourceSSR.md
This commit is contained in:
162
src/routes/api/queue/stream/+server.ts
Normal file
162
src/routes/api/queue/stream/+server.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Queue SSE Stream API Endpoint
|
||||
*
|
||||
* Provides Server-Sent Events stream for real-time queue updates:
|
||||
* - GET /api/queue/stream - Stream queue status updates
|
||||
*/
|
||||
|
||||
import { queueManager } from '$lib/server/queue/QueueManager';
|
||||
import type { RequestHandler } from './$types';
|
||||
import type { QueueStatusUpdate } from '$lib/server/queue/types';
|
||||
|
||||
/**
|
||||
* GET /api/queue/stream - Server-Sent Events stream for queue updates
|
||||
*
|
||||
* Returns a continuous stream of queue status updates in SSE format.
|
||||
* Supports optional query parameters:
|
||||
* - ?id={queue-item-id} - Stream updates only for specific item
|
||||
* - ?status={status} - Stream updates only for items with specific status
|
||||
*
|
||||
* SSE Event Format:
|
||||
* - event: queue-update
|
||||
* - data: JSON string with QueueStatusUpdate object
|
||||
*
|
||||
* Connection is kept alive until client disconnects.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, request }) => {
|
||||
const searchParams = url.searchParams;
|
||||
const itemIdFilter = searchParams.get('id');
|
||||
const statusFilter = searchParams.get('status');
|
||||
|
||||
// Validate status filter if provided
|
||||
const validStatuses = ['pending', 'in_progress', 'success', 'unhealthy', 'error'];
|
||||
if (statusFilter && !validStatuses.includes(statusFilter)) {
|
||||
return new Response(`Invalid status filter. Must be one of: ${validStatuses.join(', ')}`, {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
});
|
||||
}
|
||||
|
||||
// Validate item ID filter if provided
|
||||
if (itemIdFilter) {
|
||||
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
if (!uuidPattern.test(itemIdFilter)) {
|
||||
return new Response('Invalid queue item ID format', {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create SSE response stream
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
// Send initial connection message
|
||||
const connectionMsg = `event: connection\ndata: {"type":"connection","timestamp":"${new Date().toISOString()}","message":"Connected to queue stream"}\n\n`;
|
||||
controller.enqueue(new TextEncoder().encode(connectionMsg));
|
||||
|
||||
// Send current queue state as initial data
|
||||
try {
|
||||
const currentItems = queueManager.getAll();
|
||||
let filteredItems = currentItems;
|
||||
|
||||
// Apply filters
|
||||
if (itemIdFilter) {
|
||||
filteredItems = currentItems.filter(item => item.id === itemIdFilter);
|
||||
}
|
||||
if (statusFilter) {
|
||||
filteredItems = filteredItems.filter(item => item.status === statusFilter);
|
||||
}
|
||||
|
||||
// Send initial state for each matching item
|
||||
for (const item of filteredItems) {
|
||||
const update: QueueStatusUpdate = {
|
||||
type: 'status_change',
|
||||
itemId: item.id,
|
||||
status: item.status,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: item.url,
|
||||
progress: item.phases,
|
||||
results: item.results,
|
||||
error: item.error
|
||||
};
|
||||
|
||||
const sseMessage = `event: queue-update\ndata: ${JSON.stringify(update)}\n\n`;
|
||||
controller.enqueue(new TextEncoder().encode(sseMessage));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending initial queue state:', error);
|
||||
}
|
||||
|
||||
// Subscribe to queue updates
|
||||
const unsubscribe = queueManager.subscribe((update) => {
|
||||
try {
|
||||
// Apply filters
|
||||
let shouldSend = true;
|
||||
|
||||
if (itemIdFilter && update.itemId !== itemIdFilter) {
|
||||
shouldSend = false;
|
||||
}
|
||||
|
||||
if (statusFilter && update.status !== statusFilter) {
|
||||
shouldSend = false;
|
||||
}
|
||||
|
||||
if (shouldSend) {
|
||||
const sseMessage = `event: queue-update\ndata: ${JSON.stringify(update)}\n\n`;
|
||||
controller.enqueue(new TextEncoder().encode(sseMessage));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending queue update:', error);
|
||||
// Don't close the stream on individual message errors
|
||||
}
|
||||
});
|
||||
|
||||
// Handle client disconnect
|
||||
request.signal.addEventListener('abort', () => {
|
||||
try {
|
||||
unsubscribe();
|
||||
const disconnectMsg = `event: disconnect\ndata: {"type":"disconnect","timestamp":"${new Date().toISOString()}","message":"Connection closed"}\n\n`;
|
||||
controller.enqueue(new TextEncoder().encode(disconnectMsg));
|
||||
controller.close();
|
||||
} catch (error) {
|
||||
// Ignore errors during cleanup
|
||||
console.error('Error during SSE cleanup:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Keep-alive ping every 30 seconds to prevent connection timeout
|
||||
const keepAliveInterval = setInterval(() => {
|
||||
try {
|
||||
const pingMsg = `event: ping\ndata: {"type":"ping","timestamp":"${new Date().toISOString()}"}\n\n`;
|
||||
controller.enqueue(new TextEncoder().encode(pingMsg));
|
||||
} catch (error) {
|
||||
console.error('Error sending keep-alive ping:', error);
|
||||
clearInterval(keepAliveInterval);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Clean up interval on stream close
|
||||
request.signal.addEventListener('abort', () => {
|
||||
clearInterval(keepAliveInterval);
|
||||
});
|
||||
},
|
||||
|
||||
cancel() {
|
||||
// This is called when the stream is cancelled by the client
|
||||
console.log('Queue SSE stream cancelled by client');
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Cache-Control',
|
||||
'Access-Control-Expose-Headers': 'Content-Type'
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user