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

@@ -1,84 +0,0 @@
/**
* Server-Sent Events (SSE) endpoint for real-time extraction progress
*
* This endpoint streams extraction progress updates to the frontend
* using the SSE protocol. Each event contains status updates, method attempts,
* retry information, and final results.
*/
import { json, type RequestHandler } from '@sveltejs/kit';
import { extractTextAndThumbnail, type ProgressEvent } from '$lib/server/extraction';
import { extractRecipe } from '$lib/server/parser';
export const POST: RequestHandler = async ({ request }) => {
const { url } = await request.json();
if (!url) {
return json({ error: 'URL is required' }, { status: 400 });
}
// Create a ReadableStream for SSE
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
// Helper to send SSE message
const sendEvent = (event: ProgressEvent) => {
const data = JSON.stringify(event);
const message = `event: progress\ndata: ${data}\n\n`;
controller.enqueue(encoder.encode(message));
};
try {
// Extract with progress callback
const extracted = await extractTextAndThumbnail(url, sendEvent);
// Parse recipe from extracted text
sendEvent({
type: 'status',
message: 'Parsing recipe...',
timestamp: new Date().toISOString()
});
const recipe = await extractRecipe(extracted.bodyText);
// Send final result
const completeEvent: ProgressEvent = {
type: 'complete',
message: 'Extraction and parsing completed',
data: {
recipe,
thumbnail: extracted.thumbnail
},
timestamp: new Date().toISOString()
};
const completeMessage = `event: complete\ndata: ${JSON.stringify(completeEvent)}\n\n`;
controller.enqueue(encoder.encode(completeMessage));
controller.close();
} catch (error) {
// Send error event
const errorEvent: ProgressEvent = {
type: 'error',
message: error instanceof Error ? error.message : 'Unknown error occurred',
timestamp: new Date().toISOString()
};
const errorMessage = `event: error\ndata: ${JSON.stringify(errorEvent)}\n\n`;
controller.enqueue(encoder.encode(errorMessage));
controller.close();
}
}
});
// Return SSE response
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
}
});
};

View File

@@ -1,42 +1,43 @@
import { extractTextAndThumbnail } from '$lib/server/extraction';
import { extractRecipe } from '$lib/server/parser';
import { json } from '@sveltejs/kit';
/**
* DEPRECATED: Legacy synchronous extraction endpoint
*
* This endpoint is deprecated and will be removed in a future version.
* Use the new async queue system instead:
*
* POST /api/queue - Submit URL for async processing
* GET /api/queue/stream - Real-time progress updates via SSE
*
* Migration Guide: /docs/MIGRATION.md
*/
export async function POST({ request }) {
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
export const POST: RequestHandler = async ({ request }) => {
const { url } = await request.json();
console.log('Processing URL:', url);
console.warn('[DEPRECATED] /api/extract endpoint called - use /api/queue instead');
console.warn('URL attempted:', url);
try {
// Step 1: Extract text and thumbnail from page
const { bodyText, thumbnail } = await extractTextAndThumbnail(url);
// Step 2: Parse recipe from extracted text
const recipe = await extractRecipe(bodyText);
if (!recipe) {
return json({ error: 'No recipe found in provided text' }, { status: 400 });
return json(
{
error: 'Endpoint deprecated',
message: 'This endpoint is deprecated. Use the new async queue system.',
migration: {
newEndpoint: 'POST /api/queue',
progressUpdates: 'GET /api/queue/stream',
documentation: '/docs/MIGRATION.md',
breakingChange: true,
removedIn: 'v2.0.0'
}
},
{
status: 410, // 410 Gone - resource no longer available
headers: {
'X-Deprecated': 'true',
'X-Migration-Guide': '/docs/MIGRATION.md',
'X-New-Endpoint': '/api/queue'
}
}
// Step 3: Enrich recipe with metadata
if (recipe.description) {
recipe.description += `\n\nLink: ${url}`;
} else {
recipe.description = `Link: ${url}`;
}
if (thumbnail) {
recipe.image = thumbnail;
}
return json({ recipe, bodyText });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('Recipe extraction pipeline error:', errorMessage);
return json(
{ error: errorMessage || 'Failed to process URL' },
{ status: error instanceof Error && error.message.includes('scrape') ? 500 : 400 }
);
}
}
);
};

View File

@@ -0,0 +1,113 @@
/**
* Push Notification Subscription API
*
* Handles web push notification subscription/unsubscription
* for queue processing updates.
*/
import { json } from '@sveltejs/kit';
import { pushNotificationService } from '$lib/server/notifications/PushNotificationService.js';
import type { RequestHandler } from './$types.js';
/**
* Subscribe to push notifications
*
* POST /api/notifications/subscribe
*
* Body:
* {
* "subscription": {
* "endpoint": "https://...",
* "keys": {
* "p256dh": "...",
* "auth": "..."
* }
* },
* "clientId": "unique-client-id"
* }
*/
export const POST: RequestHandler = async ({ request }) => {
try {
const { subscription, clientId } = await request.json();
// Validate required fields
if (!subscription || !subscription.endpoint || !subscription.keys) {
return json(
{ error: 'Invalid subscription object' },
{ status: 400 }
);
}
if (!clientId || typeof clientId !== 'string') {
return json(
{ error: 'Client ID is required' },
{ status: 400 }
);
}
// Subscribe client
await pushNotificationService.subscribe(clientId, {
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth
}
});
console.log(`[NotificationAPI] Client ${clientId} subscribed to push notifications`);
return json({
success: true,
message: 'Successfully subscribed to push notifications',
subscriptionCount: pushNotificationService.getSubscriptionCount()
});
} catch (error) {
console.error('[NotificationAPI] Subscription error:', error);
return json(
{ error: 'Failed to subscribe to notifications' },
{ status: 500 }
);
}
};
/**
* Unsubscribe from push notifications
*
* DELETE /api/notifications/subscribe
*
* Body:
* {
* "clientId": "unique-client-id"
* }
*/
export const DELETE: RequestHandler = async ({ request }) => {
try {
const { clientId } = await request.json();
if (!clientId || typeof clientId !== 'string') {
return json(
{ error: 'Client ID is required' },
{ status: 400 }
);
}
// Unsubscribe client
await pushNotificationService.unsubscribe(clientId);
console.log(`[NotificationAPI] Client ${clientId} unsubscribed from push notifications`);
return json({
success: true,
message: 'Successfully unsubscribed from push notifications',
subscriptionCount: pushNotificationService.getSubscriptionCount()
});
} catch (error) {
console.error('[NotificationAPI] Unsubscription error:', error);
return json(
{ error: 'Failed to unsubscribe from notifications' },
{ status: 500 }
);
}
};

View File

@@ -0,0 +1,46 @@
/**
* VAPID Public Key API
*
* Returns the public key for web push notifications.
* Required by browsers to create push subscriptions.
*/
import { json } from '@sveltejs/kit';
import { pushNotificationService } from '$lib/server/notifications/PushNotificationService.js';
import type { RequestHandler } from './$types.js';
/**
* Get VAPID public key
*
* GET /api/notifications/vapid-key
*
* Response:
* {
* "publicKey": "BDummyPublicKeyForDevelopment",
* "applicationServerKey": "BDummyPublicKeyForDevelopment"
* }
*/
export const GET: RequestHandler = async () => {
try {
const publicKey = pushNotificationService.getPublicVapidKey();
if (!publicKey) {
return json(
{ error: 'VAPID public key not configured' },
{ status: 503 }
);
}
return json({
publicKey,
applicationServerKey: publicKey // Alias for compatibility
});
} catch (error) {
console.error('[NotificationAPI] VAPID key error:', error);
return json(
{ error: 'Failed to get VAPID public key' },
{ status: 500 }
);
}
};

View File

@@ -0,0 +1,150 @@
/**
* Queue API Endpoints
*
* Provides HTTP interface for queue operations:
* - POST /api/queue - Enqueue Instagram URL for processing
* - GET /api/queue - List all queue items with optional status filtering
*/
import { json, error } from '@sveltejs/kit';
import { queueManager } from '$lib/server/queue/QueueManager';
import type { RequestHandler } from './$types';
/**
* POST /api/queue - Enqueue Instagram URL
*
* Body: { url: string }
* Returns: { id: string, url: string, status: string, enqueuedAt: string }
*
* Validates Instagram URL format and enqueues for processing.
* Returns 400 for invalid URLs, 500 for server errors.
*/
export const POST: RequestHandler = async ({ request }) => {
try {
// Parse JSON body with proper error handling
let body;
try {
body = await request.json();
} catch (jsonError) {
return error(400, { message: 'Invalid JSON in request body' });
}
// Validate request body
if (!body || typeof body !== 'object') {
return error(400, { message: 'Request body must be JSON object' });
}
const { url } = body;
// Validate URL presence
if (!url || typeof url !== 'string') {
return error(400, { message: 'URL is required and must be a string' });
}
// Validate Instagram URL format
const instagramUrlPattern = /^https:\/\/(www\.)?instagram\.com\/p\/[a-zA-Z0-9_-]+\/?$/;
if (!instagramUrlPattern.test(url)) {
return error(400, {
message: 'Invalid Instagram URL format. Expected: https://instagram.com/p/{post-id}'
});
}
// Enqueue the URL
const queueItem = queueManager.enqueue(url);
// Return minimal response (full details available at GET /api/queue/{id})
return json({
id: queueItem.id,
url: queueItem.url,
status: queueItem.status,
enqueuedAt: queueItem.enqueuedAt
});
} catch (err) {
console.error('Failed to enqueue URL:', err);
return error(500, { message: 'Internal server error' });
}
};
/**
* GET /api/queue - List queue items
*
* Query params:
* - status?: string - Filter by status (pending, in_progress, success, unhealthy, error)
* - limit?: number - Maximum items to return (default: 50, max: 200)
* - offset?: number - Pagination offset (default: 0)
*
* Returns: { items: QueueItem[], total: number, hasMore: boolean }
*/
export const GET: RequestHandler = async ({ url }) => {
try {
const searchParams = url.searchParams;
// Parse query parameters
const statusFilter = searchParams.get('status');
const limitParam = searchParams.get('limit');
const offsetParam = searchParams.get('offset');
// Validate and parse limit
let limit = 50; // default
if (limitParam) {
const parsedLimit = parseInt(limitParam, 10);
if (isNaN(parsedLimit) || parsedLimit < 1) {
return error(400, { message: 'Limit must be a positive integer' });
}
if (parsedLimit > 200) {
return error(400, { message: 'Limit cannot exceed 200' });
}
limit = parsedLimit;
}
// Validate and parse offset
let offset = 0; // default
if (offsetParam) {
const parsedOffset = parseInt(offsetParam, 10);
if (isNaN(parsedOffset) || parsedOffset < 0) {
return error(400, { message: 'Offset must be a non-negative integer' });
}
offset = parsedOffset;
}
// Validate status filter
const validStatuses = ['pending', 'in_progress', 'success', 'unhealthy', 'error'];
if (statusFilter && !validStatuses.includes(statusFilter)) {
return error(400, {
message: `Invalid status filter. Must be one of: ${validStatuses.join(', ')}`
});
}
// Get all items
let items = queueManager.getAll();
const totalCount = items.length;
// Apply status filter
if (statusFilter) {
items = items.filter(item => item.status === statusFilter);
}
// Sort by enqueued time (newest first)
items.sort((a, b) => new Date(b.enqueuedAt).getTime() - new Date(a.enqueuedAt).getTime());
// Apply pagination
const paginatedItems = items.slice(offset, offset + limit);
const hasMore = (offset + limit) < items.length;
return json({
items: paginatedItems,
total: statusFilter ? items.length : totalCount,
hasMore,
pagination: {
offset,
limit,
count: paginatedItems.length
}
});
} catch (err) {
console.error('Failed to list queue items:', err);
return error(500, { message: 'Internal server error' });
}
};

View File

@@ -0,0 +1,97 @@
/**
* Individual Queue Item API Endpoints
*
* Provides HTTP interface for individual queue item operations:
* - GET /api/queue/[id] - Get specific queue item details
* - DELETE /api/queue/[id] - Remove queue item
*/
import { json, error } from '@sveltejs/kit';
import { queueManager } from '$lib/server/queue/QueueManager';
import type { RequestHandler } from './$types';
/**
* GET /api/queue/[id] - Get queue item by ID
*
* Returns full queue item details including progress events and results.
* Returns 404 if item not found, 400 for invalid ID format.
*/
export const GET: RequestHandler = async ({ params }) => {
try {
const { id } = params;
// Validate ID parameter
if (!id || typeof id !== 'string') {
return error(400, { message: 'Queue item ID is required' });
}
// Validate UUID format (basic check)
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(id)) {
return error(400, { message: 'Invalid queue item ID format' });
}
// Get queue item
const queueItem = queueManager.get(id);
if (!queueItem) {
return error(404, { message: 'Queue item not found' });
}
// Return full item details
return json(queueItem);
} catch (err) {
console.error('Failed to get queue item:', err);
return error(500, { message: 'Internal server error' });
}
};
/**
* DELETE /api/queue/[id] - Remove queue item
*
* Removes an item from the queue.
* Returns 404 if item not found, 400 for invalid ID format,
* 409 if item is currently being processed.
*/
export const DELETE: RequestHandler = async ({ params }) => {
try {
const { id } = params;
// Validate ID parameter
if (!id || typeof id !== 'string') {
return error(400, { message: 'Queue item ID is required' });
}
// Validate UUID format
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(id)) {
return error(400, { message: 'Invalid queue item ID format' });
}
// Check if item exists
const existingItem = queueManager.get(id);
if (!existingItem) {
return error(404, { message: 'Queue item not found' });
}
// Prevent deletion of in-progress items
if (existingItem.status === 'in_progress') {
return error(409, {
message: 'Cannot delete item that is currently being processed'
});
}
// Remove the item
const success = queueManager.remove(id);
return json({
success,
message: 'Queue item removed successfully'
});
} catch (err) {
console.error('Failed to delete queue item:', err);
return error(500, { message: 'Internal server error' });
}
};

View File

@@ -0,0 +1,69 @@
/**
* Queue Item Retry API Endpoint
*
* Provides HTTP interface for retrying failed queue items:
* - POST /api/queue/[id]/retry - Retry failed/unhealthy queue item
*/
import { json, error } from '@sveltejs/kit';
import { queueManager } from '$lib/server/queue/QueueManager';
import type { RequestHandler } from './$types';
/**
* POST /api/queue/[id]/retry - Retry queue item
*
* Resets a failed or unhealthy queue item to pending status for reprocessing.
* Only items with status 'error' or 'unhealthy' can be retried.
*
* Returns the updated queue item on success.
* Returns 404 if item not found, 400 for invalid operations, 409 for wrong status.
*/
export const POST: RequestHandler = async ({ params }) => {
try {
const { id } = params;
// Validate ID parameter
if (!id || typeof id !== 'string') {
return error(400, { message: 'Queue item ID is required' });
}
// Validate UUID format (basic check)
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(id)) {
return error(400, { message: 'Invalid queue item ID format' });
}
// Check if item exists
const existingItem = queueManager.get(id);
if (!existingItem) {
return error(404, { message: 'Queue item not found' });
}
// Check if item can be retried
if (existingItem.status !== 'error' && existingItem.status !== 'unhealthy') {
return error(409, {
message: `Cannot retry item with status '${existingItem.status}'. Only 'error' and 'unhealthy' items can be retried.`
});
}
// Retry the item
const retryResult = queueManager.retry(id);
if (!retryResult) {
// This shouldn't happen given our checks above, but handle it gracefully
return error(500, { message: 'Failed to retry queue item' });
}
// Return the updated item
const updatedItem = queueManager.get(id);
return json({
success: true,
item: updatedItem,
message: 'Queue item has been reset and will be reprocessed'
});
} catch (err) {
console.error('Failed to retry queue item:', err);
return error(500, { message: 'Internal server error' });
}
};

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