import { requireAuthSse } from '@/lib/auth'; import { getStateSnapshot, sseFrame } from '@/lib/game/state'; import { subscribe } from '@/lib/sse'; // ISS-129 (v0.57.0): Server-Sent Events feed of state snapshots — the current snapshot on connect, // then one after every sim tick (broadcast from lib/sim/engine.ts). One-way push; player actions // stay REST POSTs. Heartbeats keep idle proxies from dropping the connection. ISS-133 (v0.59.0): // frames carry the sseFrame projection (terminal package history dropped) — same shape as the // pushed frames, so a connect never costs one full-history frame. v1.1.0 (ISS-107): guarded — // EventSource cannot set headers, so the session comes from X-Flight-Session OR ?session= // (requireAuthSse). ponytail: validated at CONNECT only — a session deleted/expired mid-stream keeps // receiving pushes until it reconnects (private infra, 7-day sessions; per-push re-validation would // be one indexed lookup per tick if that ever matters). const encoder = new TextEncoder(); const HEARTBEAT_MS = 15_000; export async function GET(req: Request) { const auth = await requireAuthSse(req); if (!auth.ok) return auth.response; let heartbeat: ReturnType | null = null; let unsubscribe: (() => void) | null = null; const cleanup = () => { if (heartbeat !== null) clearInterval(heartbeat); if (unsubscribe !== null) unsubscribe(); }; const stream = new ReadableStream({ start(controller) { // Snapshot FIRST, then subscribe — a broadcast can never land before the initial event, so // events are strictly ordered by sim clock. A tick in the gap just means the client waits ≤1 s // for the next push instead of seeing an out-of-order frame. getStateSnapshot() .then((snapshot) => { unsubscribe = subscribe((text) => { try { // broadcast carries the bare snapshot JSON — wrap it in an SSE frame here (the route owns // wire framing, matching the initial snapshot + heartbeat above; lib/sse stays generic). controller.enqueue(encoder.encode(`data: ${text}\n\n`)); } catch { // stream already closed — cleanup runs on cancel } }); heartbeat = setInterval(() => { try { controller.enqueue(encoder.encode(': ping\n\n')); } catch { // same as above } }, HEARTBEAT_MS); controller.enqueue(encoder.encode(`data: ${JSON.stringify(sseFrame(snapshot))}\n\n`)); }) .catch(() => { cleanup(); try { controller.close(); } catch { // already closed } }); }, cancel() { cleanup(); // client went away — drop the subscription + heartbeat }, }); return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Accel-Buffering': 'no', // prod sits behind Traefik — don't buffer the stream }, }); }