/** * GET /api/v1/jobs/:id/stream — stream real-time job progress via SSE. * * Headers: * Last-Event-ID (optional) — triggers replay of last cached event */ import type { RequestHandler } from './$types'; import { getClient } from '$lib/server/db/client.js'; import { JobQueue } from '$lib/server/pipeline/job-queue.js'; import { getBroadcaster } from '$lib/server/pipeline/progress-broadcaster.js'; import { handleServiceError } from '$lib/server/utils/validation.js'; export const GET: RequestHandler = ({ params, request }) => { try { const db = getClient(); const queue = new JobQueue(db); const jobId = params.id; // Get the job from the queue const job = queue.getJob(jobId); if (!job) { return new Response('Not found', { status: 404 }); } // Get broadcaster const broadcaster = getBroadcaster(); if (!broadcaster) { return new Response('Service unavailable', { status: 503 }); } // Create a new readable stream for SSE const stream = new ReadableStream({ async start(controller) { try { // Send initial job state as first event const initialData = { jobId, stage: job.stage, stageDetail: job.stageDetail, progress: job.progress, processedFiles: job.processedFiles, totalFiles: job.totalFiles, status: job.status, error: job.error }; controller.enqueue(`event: job-progress\ndata: ${JSON.stringify(initialData)}\n\n`); // Check for Last-Event-ID header for reconnect const lastEventId = request.headers.get('Last-Event-ID'); if (lastEventId) { const lastEvent = broadcaster.getLastEvent(jobId); if (lastEvent && lastEvent.id >= parseInt(lastEventId, 10)) { controller.enqueue(`id: ${lastEvent.id}\nevent: ${lastEvent.event}\ndata: ${lastEvent.data}\n\n`); } } // Check if job is already done or failed - close immediately after first event if (job.status === 'done' || job.status === 'failed') { if (job.status === 'done') { controller.enqueue(`event: job-done\ndata: ${JSON.stringify({ jobId })}\n\n`); } else { controller.enqueue( `event: job-failed\ndata: ${JSON.stringify({ jobId, error: job.error })}\n\n` ); } controller.close(); return; } // Subscribe to broadcaster for live events const eventStream = broadcaster.subscribe(jobId); const reader = eventStream.getReader(); // Pipe broadcaster events to the response try { while (true) { const { done, value } = await reader.read(); if (done) break; controller.enqueue(value); // Check if the incoming event indicates job completion if ( value.includes('event: job-done') || value.includes('event: job-failed') ) { controller.close(); break; } } } finally { reader.releaseLock(); try { controller.close(); } catch { // Stream may already be closed after a terminal event. } } } catch (err) { console.error('SSE stream error:', err); try { controller.close(); } catch { // Stream may already be closed. } } } }); return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', 'Access-Control-Allow-Origin': '*' } }); } catch (err) { return handleServiceError(err); } }; export const OPTIONS: RequestHandler = () => { return new Response(null, { status: 204, headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization, Last-Event-ID' } }); };