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:
Giancarmine Salucci
2025-12-22 03:00:29 +01:00
parent 35d6f6e40a
commit 8545744bb1
47 changed files with 12827 additions and 363 deletions

View 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'
}
});
};