2026-03-22 04:03:38 +08:00
|
|
|
/**
|
|
|
|
|
* SSE endpoint for real-time job progress tracking.
|
|
|
|
|
*
|
|
|
|
|
* GET /api/v1/jobs/:jobId/progress
|
|
|
|
|
*
|
|
|
|
|
* Sends Server-Sent Events with progress data until the job finishes.
|
|
|
|
|
*/
|
|
|
|
|
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
|
|
|
|
|
|
|
|
|
export interface JobProgress {
|
|
|
|
|
jobId: string;
|
|
|
|
|
status: "processing" | "completed" | "failed";
|
|
|
|
|
totalFiles: number;
|
|
|
|
|
completedFiles: number;
|
|
|
|
|
failedFiles: number;
|
|
|
|
|
/** Names of files that failed, with error messages. */
|
|
|
|
|
errors: Array<{ filename: string; error: string }>;
|
|
|
|
|
/** Current file being processed (if any). */
|
|
|
|
|
currentFile?: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 01:36:57 +08:00
|
|
|
export interface SingleFileProgress {
|
|
|
|
|
jobId: string;
|
|
|
|
|
type: "single";
|
|
|
|
|
phase: "processing" | "complete" | "failed";
|
|
|
|
|
stage?: string;
|
|
|
|
|
percent: number;
|
|
|
|
|
error?: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 04:03:38 +08:00
|
|
|
/** In-memory store of job progress, keyed by jobId. */
|
|
|
|
|
const jobProgressStore = new Map<string, JobProgress>();
|
|
|
|
|
|
|
|
|
|
/** SSE listeners waiting for updates, keyed by jobId. */
|
2026-03-23 01:36:57 +08:00
|
|
|
const listeners = new Map<
|
|
|
|
|
string,
|
|
|
|
|
Set<(data: JobProgress | SingleFileProgress) => void>
|
|
|
|
|
>();
|
2026-03-22 04:03:38 +08:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create or update progress for a job.
|
|
|
|
|
*/
|
|
|
|
|
export function updateJobProgress(progress: JobProgress): void {
|
|
|
|
|
jobProgressStore.set(progress.jobId, progress);
|
|
|
|
|
// Notify all SSE listeners
|
|
|
|
|
const subs = listeners.get(progress.jobId);
|
|
|
|
|
if (subs) {
|
|
|
|
|
for (const cb of subs) {
|
|
|
|
|
cb(progress);
|
|
|
|
|
}
|
|
|
|
|
// If the job is done, clean up listeners after a brief delay
|
|
|
|
|
if (progress.status === "completed" || progress.status === "failed") {
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
listeners.delete(progress.jobId);
|
|
|
|
|
jobProgressStore.delete(progress.jobId);
|
|
|
|
|
}, 5000);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 01:36:57 +08:00
|
|
|
export function updateSingleFileProgress(
|
|
|
|
|
progress: Omit<SingleFileProgress, "type">,
|
|
|
|
|
): void {
|
|
|
|
|
const event: SingleFileProgress = { ...progress, type: "single" };
|
|
|
|
|
const subs = listeners.get(progress.jobId);
|
|
|
|
|
if (subs) {
|
|
|
|
|
for (const cb of subs) {
|
|
|
|
|
cb(event);
|
|
|
|
|
}
|
|
|
|
|
if (progress.phase === "complete" || progress.phase === "failed") {
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
listeners.delete(progress.jobId);
|
|
|
|
|
}, 5000);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 04:03:38 +08:00
|
|
|
export async function registerProgressRoutes(
|
|
|
|
|
app: FastifyInstance,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
app.get(
|
|
|
|
|
"/api/v1/jobs/:jobId/progress",
|
|
|
|
|
async (
|
|
|
|
|
request: FastifyRequest<{ Params: { jobId: string } }>,
|
|
|
|
|
reply: FastifyReply,
|
|
|
|
|
) => {
|
|
|
|
|
const { jobId } = request.params;
|
|
|
|
|
|
2026-03-24 00:41:54 +08:00
|
|
|
// Take over the response from Fastify for SSE streaming
|
|
|
|
|
reply.hijack();
|
|
|
|
|
|
2026-03-22 04:03:38 +08:00
|
|
|
// Send SSE headers via the raw Node response
|
|
|
|
|
reply.raw.writeHead(200, {
|
|
|
|
|
"Content-Type": "text/event-stream",
|
|
|
|
|
"Cache-Control": "no-cache",
|
|
|
|
|
Connection: "keep-alive",
|
|
|
|
|
"X-Accel-Buffering": "no",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Helper to send an SSE message
|
2026-03-23 01:36:57 +08:00
|
|
|
const sendEvent = (data: JobProgress | SingleFileProgress) => {
|
2026-03-22 04:03:38 +08:00
|
|
|
reply.raw.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// If the job already has progress, send it immediately
|
|
|
|
|
const existing = jobProgressStore.get(jobId);
|
|
|
|
|
if (existing) {
|
|
|
|
|
sendEvent(existing);
|
|
|
|
|
if (
|
|
|
|
|
existing.status === "completed" ||
|
|
|
|
|
existing.status === "failed"
|
|
|
|
|
) {
|
|
|
|
|
reply.raw.end();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Subscribe to updates
|
|
|
|
|
if (!listeners.has(jobId)) {
|
|
|
|
|
listeners.set(jobId, new Set());
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 01:36:57 +08:00
|
|
|
const callback = (data: JobProgress | SingleFileProgress) => {
|
2026-03-22 04:03:38 +08:00
|
|
|
sendEvent(data);
|
2026-03-23 01:36:57 +08:00
|
|
|
if (
|
|
|
|
|
("status" in data &&
|
|
|
|
|
(data.status === "completed" || data.status === "failed")) ||
|
|
|
|
|
("phase" in data &&
|
|
|
|
|
(data.phase === "complete" || data.phase === "failed"))
|
|
|
|
|
) {
|
2026-03-22 04:03:38 +08:00
|
|
|
reply.raw.end();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
listeners.get(jobId)!.add(callback);
|
|
|
|
|
|
|
|
|
|
// Clean up on client disconnect
|
|
|
|
|
request.raw.on("close", () => {
|
|
|
|
|
const subs = listeners.get(jobId);
|
|
|
|
|
if (subs) {
|
|
|
|
|
subs.delete(callback);
|
|
|
|
|
if (subs.size === 0) {
|
|
|
|
|
listeners.delete(jobId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|