import { NextRequest } from "next/server"; import { Job } from "bullmq"; import { getPipelineQueue, getValhallaQueue } from "@/lib/queue"; import type { PipelineJobData, JobProgress } from "@/lib/queue"; import type { SSEEvent } from "@transportationer/shared"; export const runtime = "nodejs"; function fmt(event: SSEEvent): string { return `data: ${JSON.stringify(event)}\n\n`; } export async function GET( req: NextRequest, { params }: { params: Promise<{ id: string }> }, ): Promise { const { id } = await params; const queue = getPipelineQueue(); const encoder = new TextEncoder(); let timer: ReturnType | null = null; // Resolve citySlug from the refresh-city job that was returned to the UI. // We track progress by citySlug across all pipeline stages because // refresh-city itself completes almost immediately after enqueueing children. let citySlug: string; try { const job = await Job.fromId(queue, id); if (!job) { return new Response(fmt({ type: "failed", jobId: id, error: "Job not found" }), { headers: { "Content-Type": "text/event-stream" }, }); } citySlug = job.data.citySlug ?? ""; } catch { return new Response(fmt({ type: "failed", jobId: id, error: "Queue unavailable" }), { headers: { "Content-Type": "text/event-stream" }, }); } const valhallaQueue = getValhallaQueue(); const stream = new ReadableStream({ start(ctrl) { const enqueue = (e: SSEEvent) => { try { ctrl.enqueue(encoder.encode(fmt(e))); } catch { /* controller already closed */ } }; const cleanup = () => { if (timer) { clearInterval(timer); timer = null; } try { ctrl.close(); } catch { /* already closed */ } }; const poll = async () => { try { // 1. Find the currently active stage across both queues. const [pipelineActive, valhallaActive] = await Promise.all([ queue.getActive(0, 100), valhallaQueue.getActive(0, 100), ]); const activeJob = [...pipelineActive, ...valhallaActive].find( (j) => j.data.citySlug === citySlug && j.data.type !== "refresh-city", ); if (activeJob) { const p = activeJob.progress as JobProgress | undefined; if (p?.stage) { enqueue({ type: "progress", stage: p.stage, pct: p.pct, message: p.message, bytesDownloaded: p.bytesDownloaded, totalBytes: p.totalBytes, }); } else { enqueue({ type: "heartbeat" }); } return; } // 2. No active stage — check for a recent failure in either queue. const [pipelineFailed, valhallaFailed] = await Promise.all([ queue.getFailed(0, 50), valhallaQueue.getFailed(0, 50), ]); const recentFail = [...pipelineFailed, ...valhallaFailed].find( (j) => j.data.citySlug === citySlug && j.data.type !== "refresh-city" && Date.now() - (j.finishedOn ?? 0) < 600_000, ); if (recentFail) { enqueue({ type: "failed", jobId: recentFail.id ?? "", error: recentFail.failedReason ?? "Pipeline stage failed", }); cleanup(); return; } // 3. Check if compute-scores completed recently → full pipeline done. const completed = await queue.getCompleted(0, 100); const finalDone = completed.find( (j) => j.data.citySlug === citySlug && j.data.type === "compute-scores" && Date.now() - (j.finishedOn ?? 0) < 3_600_000, ); if (finalDone) { enqueue({ type: "completed", jobId: finalDone.id ?? "" }); cleanup(); return; } // 4. Still pending — heartbeat. enqueue({ type: "heartbeat" }); } catch { enqueue({ type: "heartbeat" }); } }; poll(); timer = setInterval(poll, 1000); req.signal.addEventListener("abort", cleanup); }, cancel() { if (timer) clearInterval(timer); }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", "X-Accel-Buffering": "no", }, }); }