Files
Curio/src/app/api/lessons/stream/route.ts
T
arnaudne 5bf6013460 feat: full feature buildout — streaming, i18n, mastery map, admin, jobs
Progressive lesson streaming via onSegment callback (fixes SSE for non-English
users — locale was shadowed in lesson-reader useEffect). Adds: BullMQ workers,
Redis stream buffer, token budget enforcement, Langfuse tracing, golden-eval
runner, Playwright e2e scaffolding, lesson depth/locale/preferences schema,
mastery map UI, admin panel (blueprints/users/reports/quality/misconceptions),
image queries, source citations, view transitions, reading animations, i18n
(next-intl), PDF export, surprise endpoint, and 402 passing unit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 22:08:14 +02:00

81 lines
2.7 KiB
TypeScript

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<void>((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<Response> {
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',
},
});
}