Files
Flight-Ops/app/api/events/route.ts

69 lines
2.6 KiB
TypeScript

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<typeof setInterval> | null = null;
let unsubscribe: (() => void) | null = null;
const cleanup = () => {
if (heartbeat !== null) clearInterval(heartbeat);
if (unsubscribe !== null) unsubscribe();
};
const stream = new ReadableStream<Uint8Array>({
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
},
});
}