77 lines
3.1 KiB
TypeScript
77 lines
3.1 KiB
TypeScript
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<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
|
|
},
|
|
});
|
|
}
|