134 lines
3.7 KiB
TypeScript
134 lines
3.7 KiB
TypeScript
/**
|
|
* 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<string>({
|
|
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'
|
|
}
|
|
});
|
|
};
|