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. const encoder = new TextEncoder(); const HEARTBEAT_MS = 15_000; export async function GET() { 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 }, }); }