import { type NextRequest } from 'next/server'; import { getStreamSegments, isStreamDone } from '@/lib/cache/stream'; import { DEFAULT_DEPTH, type LessonDepth } from '@/lib/generation/depth'; import type { AgeGroup } from '@/schemas/preferences'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; const POLL_INTERVAL_MS = 500; const SEGMENT_EMIT_DELAY_MS = 300; // paced delivery between segments const MAX_DURATION_MS = 28_000; // leave headroom before 30s Vercel limit const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); function sseEvent(event: string, data: string): string { return `event: ${event}\ndata: ${data}\n\n`; } export async function GET(request: NextRequest): Promise { const { searchParams } = new URL(request.url); const key = searchParams.get('key') ?? ''; const locale = searchParams.get('locale') ?? 'en'; const depth = (searchParams.get('depth') ?? DEFAULT_DEPTH) as LessonDepth; const ageGroup = (searchParams.get('ageGroup') ?? 'adult') as AgeGroup; const position = parseInt(searchParams.get('position') ?? '0', 10); const encoder = new TextEncoder(); const abortSignal = request.signal; const stream = new ReadableStream({ async start(controller) { const enqueue = (event: string, data: string) => { try { controller.enqueue(encoder.encode(sseEvent(event, data))); } catch { // Controller may be closed if client disconnected } }; let emittedCount = 0; const deadline = Date.now() + MAX_DURATION_MS; try { while (Date.now() < deadline && !abortSignal.aborted) { const segments = await getStreamSegments(key, locale, depth, ageGroup, position); // Emit newly-arrived segments one-by-one with pacing while (emittedCount < segments.length && !abortSignal.aborted) { enqueue('segment', JSON.stringify(segments[emittedCount])); emittedCount++; if (emittedCount < segments.length) { await sleep(SEGMENT_EMIT_DELAY_MS); } } const done = await isStreamDone(key, locale, depth, ageGroup, position); if (done && emittedCount >= segments.length) { enqueue('ready', '{}'); break; } await sleep(POLL_INTERVAL_MS); } } catch { // Swallow — client falls back to the 4s poll } finally { try { controller.close(); } catch { /* already closed */ } } }, }); return new Response(stream, { headers: { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache, no-transform', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', }, }); }