5bf6013460
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>
81 lines
2.7 KiB
TypeScript
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',
|
|
},
|
|
});
|
|
}
|