From 1983d170999f55305548d58f4f6dfd3a447786c5 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 22 Sep 2026 20:44:13 +0000 Subject: [PATCH] push updated state to clients over SSE (ISS-129) --- CHANGELOG.md | 5 +++ app/api/events/route.test.ts | 41 ++++++++++++++++++++++ app/api/events/route.ts | 66 ++++++++++++++++++++++++++++++++++++ e2e/api.spec.ts | 3 +- e2e/events.spec.ts | 59 ++++++++++++++++++++++++++++++++ lib/db/client.ts | 10 +++--- lib/openapi.test.ts | 3 +- lib/openapi.ts | 16 +++++++++ lib/sim/engine.ts | 35 ++++++++++++++----- lib/sse.test.ts | 45 ++++++++++++++++++++++++ lib/sse.ts | 29 ++++++++++++++++ lib/useGameState.ts | 61 +++++++++++++++++++++++++++++---- package.json | 2 +- 13 files changed, 354 insertions(+), 21 deletions(-) create mode 100644 app/api/events/route.test.ts create mode 100644 app/api/events/route.ts create mode 100644 e2e/events.spec.ts create mode 100644 lib/sse.test.ts create mode 100644 lib/sse.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c855ec..c9857b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v0.57.0 — 2026-09-22 19:19 +- **Push updated state to clients over SSE (ISS-129).** Clients no longer rely on pure polling for live updates: new `GET /api/events` streams full game-state snapshots as Server-Sent Events — the current snapshot immediately on connect, then one after every sim tick (~1 s), so the UI updates push-driven even while idle. New module `lib/sse.ts` is a tiny in-process fan-out registry (`subscribe(fn)` → unsubscribe, `broadcast(text)` with per-subscriber try/catch) — the three process-wide singletons (this subscriber set, the sql.js DB promise (`getGameDb`), and the tick lock (`withLock`)) live on `globalThis`: Next.js can load a module more than once per process (instrumentation.ts and route handlers get separate module instances), so plain module-level state would give each layer its own registry/DB/lock, and background-tick pushes would never reach route-handler subscribers. The broadcast hook lives in `tickNow()` in `lib/sim/engine.ts`: after each locked tick on the singleton path it serializes via the SAME `getStateSnapshot()` that `GET /api/state` returns (no duplicated serialization) and pushes to all subscribers; failures are logged, never fatal. Action mutations reach other clients within one background tick (~1 s); the acting client still gets its immediate post-action snapshot from the POST response as before. The SSE route (`app/api/events/route.ts`) sends `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive` and `X-Accel-Buffering: no` (prod sits behind Traefik), plus a `: ping` heartbeat comment every 15 s so idle proxies don't drop the connection; snapshot-first-then-subscribe ordering guarantees events are strictly ordered by sim clock, and stream cancel unsubscribes + clears the heartbeat; broadcast pushes are wrapped in `data: \n\n` SSE frames at the route (matching the initial snapshot). Client (`lib/useGameState.ts`): one `EventSource('/api/events')` per page — onmessage parses the full snapshot into the shared state (same shape as today); polling stays as an automatic fallback: if no event arrives within 5 s of connect, or on a stream error, the old poll loop starts (immediate catch-up fetch + interval), and it stops again when fresh events flow. EventSource's native reconnect is relied on — no custom reconnect framework. `/api/state` behavior is unchanged (still ticks-then-snapshots; polling remains fully functional). +- **API.** OpenAPI spec gains `GET /api/events` (200 → `text/event-stream`) in `lib/openapi.ts`, so the machine-readable spec at `/api/openapi` stays accurate. +- **Tests.** Unit: new `lib/sse.test.ts` — subscribe/broadcast/unsubscribe semantics, a throwing subscriber doesn't break others, idempotent unsubscribe; new `app/api/events/route.test.ts` (throwaway-DB pattern from the state route test) — SSE headers + first chunk parses as a snapshot with `simState.clockMs`. E2E: new `e2e/events.spec.ts` (+1) — node-side fetch of `/api/events` against the app under test asserts content-type `text/event-stream`, the first event parses as a valid snapshot (`simState.clockMs`), and within ~5 s at least one MORE event arrives with an advanced clock (background tick pushes while idle since v0.56.0 — no scale hook); stream closed in `finally`. The exact-endpoint-list assertions in `lib/openapi.test.ts` and `e2e/api.spec.ts` move twelve → thirteen endpoints (`/api/events` added). + ## v0.56.0 — 2026-09-22 18:28 - **Idle fleet slows the world to real time instead of freezing it (ISS-128).** A fully grounded fleet no longer freezes the sim clock — `tickOnce` in `lib/sim/engine.ts` now advances by `min(elapsed, MAX_TICK_ELAPSED_MS) × IDLE_WORLD_TIME_SCALE` when nothing is flying or in repair, where new constant `IDLE_WORLD_TIME_SCALE = WORLD_TIME_SCALE / 24` (= exactly 1, i.e. real time) lives next to `WORLD_TIME_SCALE` in `lib/worldClock.ts`. Packages keep spawning/expiring and the market stays fresh while idle; an active fleet keeps its full scale including any FLIGHT_TEST_HOOKS override (grounded + hook scale 1000 → advances at ×1, not ×1000), and `lastRealTickMs` still always tracks real time so resuming activity never fast-forwards the idle duration. The client mirror (`advanceSimClock` in `lib/useSimClock.ts`) extrapolates at the same idle rate instead of freezing, so polls re-anchor without a visible jump. - **Tests.** Unit: `lib/sim/engine.test.ts` — the grounded-fleet tests now pin real-time advance clamped to one idle-rate tick (no freeze, no fast-forward), resume-without-catch-up after takeoff, and that a raised timeScale is ignored while grounded; new describe "idle slowdown" pins `IDLE_WORLD_TIME_SCALE = WORLD_TIME_SCALE / 24 = 1`. `lib/useSimClock.test.ts` — the frozen-while-idle test now asserts real-time advance at the idle rate. E2E: `e2e/sim.spec.ts`'s freeze test becomes a bounded real-time delta (advances > 0, ≥ half the wall span clamped to one tick, ≤ wall span + one clamp, clearly below ×24); `e2e/state.spec.ts`'s restart test replaces byte-identical clock equality with derived bounds — never backwards (not re-seeded) and ≤ `MAX_TICK_ELAPSED_MS` of clamped downtime + the live wall span; `e2e/right-pane.spec.ts`'s penalty preview no longer races a frozen clock — it verifies the idle rate, then allows the formula's slope over one UI poll interval (~1 s at ×1) instead of an exact string match. The `activateWorld`/repair wrappers in actions/panes/sim specs are unchanged (a repairing plane still means full scale); only their "freezes" comments were updated. diff --git a/app/api/events/route.test.ts b/app/api/events/route.test.ts new file mode 100644 index 0000000..3392ffa --- /dev/null +++ b/app/api/events/route.test.ts @@ -0,0 +1,41 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { GET } from './route'; + +let dir: string; + +beforeAll(async () => { + // Same pattern as app/api/state/route.test.ts: point the DB singleton at a throwaway file so + // this test never touches ./db/flight.db. getGameDb() reads the env var on first call, and + // vitest gives each test file its own module registry (fresh singleton). + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-events-')); + process.env.FLIGHT_DB_PATH = path.join(dir, 'events.db'); +}); + +afterAll(async () => { + delete process.env.FLIGHT_DB_PATH; + await fs.rm(dir, { recursive: true, force: true }); +}); + +describe('GET /api/events', () => { + it('streams SSE with the current snapshot as the first event', async () => { + const res = await GET(); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/event-stream'); + expect(res.headers.get('cache-control')).toBe('no-cache'); + expect(res.headers.get('x-accel-buffering')).toBe('no'); + + const reader = (res.body as ReadableStream).getReader(); + try { + const { value } = await reader.read(); // first chunk: the initial snapshot event + const text = new TextDecoder().decode(value); + expect(text.startsWith('data: ')).toBe(true); + const snap = JSON.parse(text.slice('data: '.length)) as Record; + expect(typeof (snap.simState as { clockMs: number }).clockMs).toBe('number'); + } finally { + await reader.cancel(); // → stream cancel → unsubscribe from the registry + } + }); +}); diff --git a/app/api/events/route.ts b/app/api/events/route.ts new file mode 100644 index 0000000..6c39304 --- /dev/null +++ b/app/api/events/route.ts @@ -0,0 +1,66 @@ +import { getStateSnapshot } from '@/lib/game/state'; +import { subscribe } from '@/lib/sse'; + +// ISS-129 (v0.57.0): Server-Sent Events feed of full 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. + +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(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 + }, + }); +} diff --git a/e2e/api.spec.ts b/e2e/api.spec.ts index 4766f59..9074d5c 100644 --- a/e2e/api.spec.ts +++ b/e2e/api.spec.ts @@ -13,7 +13,7 @@ test('GET /api/version returns the app version from package.json', async ({ page expect(body.version).toBe(pkg.version); }); -test('GET /api/openapi serves the spec with all twelve endpoints and the package.json version', async ({ page }) => { +test('GET /api/openapi serves the spec with all thirteen endpoints and the package.json version', async ({ page }) => { const res = await page.request.get('/api/openapi'); expect(res.status()).toBe(200); expect(res.headers()['content-type']).toContain('application/json'); @@ -23,6 +23,7 @@ test('GET /api/openapi serves the spec with all twelve endpoints and the package // v0.12.0 swaps the contract accept/dispatch pair for package load/abandon + plane fly. expect(Object.keys(spec.paths).sort()).toEqual([ '/api/airports', + '/api/events', // v0.57.0 (ISS-129) — SSE state stream '/api/market/buy-plane', '/api/market/sell-plane', '/api/packages/{id}/abandon', diff --git a/e2e/events.spec.ts b/e2e/events.spec.ts new file mode 100644 index 0000000..ab9d936 --- /dev/null +++ b/e2e/events.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from '@playwright/test'; + +// ISS-129 (v0.57.0): /api/events pushes full state snapshots over SSE — the current snapshot on +// connect, then one after every sim tick (~1 s; the background tick runs even while idle since +// v0.56.0, so no scale hook is needed). Reached via the same base URL as playwright.config.ts +// (E2E_BASE_URL, defaulting to the live instance) — node-side fetch, no browser involved. +const baseUrl = process.env.E2E_BASE_URL ?? 'https://flight.dev.kkhost.pl'; + +interface StateBody { + simState: { clockMs: number }; +} + +test('GET /api/events streams state snapshots over SSE', async () => { + const res = await fetch(`${baseUrl}/api/events`, { headers: { Accept: 'text/event-stream' } }); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/event-stream'); + expect(res.body).not.toBeNull(); + + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + const clocks: number[] = []; // simState.clockMs of each parsed snapshot event + + try { + // Collect two snapshot events: the initial one, then at least one pushed by a background tick. + const deadline = Date.now() + 15_000; + while (clocks.length < 2 && Date.now() < deadline) { + let chunk: ReadableStreamReadResult; + try { + chunk = await Promise.race([ + reader.read(), + new Promise((_, reject) => + setTimeout(() => reject(new Error('SSE stream stalled')), Math.max(1, deadline - Date.now())), + ), + ]); + } catch { + break; // stalled or past the deadline — let the length assertion report it + } + if (chunk.done) break; // stream ended early — same + buffer += decoder.decode(chunk.value, { stream: true }); + let sep: number; + while ((sep = buffer.indexOf('\n\n')) >= 0) { + const frame = buffer.slice(0, sep); + buffer = buffer.slice(sep + 2); + for (const line of frame.split('\n')) { + if (!line.startsWith('data: ')) continue; // heartbeats are comment lines (" : ping") + const snap = JSON.parse(line.slice('data: '.length)) as StateBody; + clocks.push(snap.simState.clockMs); + } + } + } + } finally { + await reader.cancel(); // close the stream → server unsubscribes + } + + // The initial snapshot arrives on connect; the second comes from a background tick within ~1 s. + expect(clocks.length).toBeGreaterThanOrEqual(2); + expect(clocks[1]).toBeGreaterThan(clocks[0]); // clock advanced between pushes — real updates, not replays +}); diff --git a/lib/db/client.ts b/lib/db/client.ts index 5369cd1..2ea1ebf 100644 --- a/lib/db/client.ts +++ b/lib/db/client.ts @@ -211,12 +211,14 @@ export async function persist(instance: GameDb): Promise { await fs.rename(tmp, instance.file); } -// Singleton for the app process; openGameDb() is the uncached variant (tests). -let ready: Promise | null = null; +// Singleton for the app process; openGameDb() is the uncached variant (tests). ISS-129 (v0.57.0): +// lives on globalThis because Next.js can load this module more than once per process — +// instrumentation.ts and route handlers get separate module instances, so a plain `let` would give +// each its own in-memory sql.js DB (both persisting to the same file). One world per process. +const g = globalThis as unknown as { __flightGameDb?: Promise }; export function getGameDb(): Promise { - if (!ready) ready = open(defaultDbPath()); - return ready; + return (g.__flightGameDb ??= open(defaultDbPath())); } export async function openGameDb(file: string): Promise { diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index 7d56021..7d36bb2 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -3,9 +3,10 @@ import { describe, expect, it } from 'vitest'; import { openApiSpec } from './openapi'; describe('OpenAPI spec', () => { - it('documents exactly the twelve public endpoints (3 read + 9 player actions)', () => { + it('documents exactly the thirteen public endpoints (4 read + 9 player actions)', () => { expect(Object.keys(openApiSpec.paths).sort()).toEqual([ '/api/airports', + '/api/events', // v0.57.0 (ISS-129) — SSE state stream '/api/market/buy-plane', '/api/market/sell-plane', '/api/packages/{id}/abandon', diff --git a/lib/openapi.ts b/lib/openapi.ts index 792aa87..07a2d42 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -51,6 +51,22 @@ export const openApiSpec = { }, }, }, + '/api/events': { + get: { + summary: 'Live state stream (SSE)', + description: + 'Server-Sent Events feed of full game-state snapshots (v0.57.0, ISS-129) — the current snapshot is sent immediately on connect and a fresh one after every sim tick (~1 s), so clients can subscribe instead of polling /api/state. Each event\'s data payload is JSON with exactly the StateSnapshot shape; comment lines starting with ":" are heartbeats.', + operationId: 'subscribeToEvents', + responses: { + 200: { + description: 'SSE stream of state snapshots (text/event-stream)', + content: { + 'text/event-stream': { schema: { type: 'string' } }, + }, + }, + }, + }, + }, '/api/version': { get: { summary: 'App version', diff --git a/lib/sim/engine.ts b/lib/sim/engine.ts index 6f0d637..7d97103 100644 --- a/lib/sim/engine.ts +++ b/lib/sim/engine.ts @@ -7,7 +7,8 @@ import { haversineKm, initialBearingDeg } from '@/lib/geo'; import { weatherFor } from '@/lib/weather'; import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog'; import { SPECIALS } from '@/lib/game/specials'; -import { parseIcaoQueue } from '@/lib/game/state'; +import { getStateSnapshot, parseIcaoQueue } from '@/lib/game/state'; +import { broadcast } from '@/lib/sse'; import { IDLE_WORLD_TIME_SCALE } from '@/lib/worldClock'; import { generatePackage, @@ -46,13 +47,15 @@ export function reputationGainFor(km: number): number { return Math.round((REP_BASE + REP_PER_KM * km) * 100) / 100; } -// Serialize ticks across concurrent requests (Next.js route handlers run in the -// same process): each tick re-reads sim_state after the previous one wrote it, so -// no advance is ever applied twice. -let chain: Promise = Promise.resolve(); +// Serialize ticks across concurrent requests (Next.js route handlers run in the same process): each +// tick re-reads sim_state after the previous one wrote it, so no advance is ever applied twice. +// ISS-129 (v0.57.0): the chain lives on globalThis — instrumentation.ts and route handlers get +// separate module instances from Next.js, so a plain `let` would give each layer its own lock and let +// their ticks interleave against the shared DB below. +const g = globalThis as unknown as { __flightTickChain?: Promise }; function withLock(fn: () => Promise): Promise { - const run = chain.then(fn); - chain = run.then( + const run = (g.__flightTickChain ??= Promise.resolve()).then(fn); + g.__flightTickChain = run.then( () => undefined, () => undefined, ); @@ -416,8 +419,24 @@ export async function spawnCoveragePackages( // --- Public API (singleton DB + lock) — call from every read/action endpoint --- +// ISS-129 (v0.57.0): after each tick on the singleton path, push a fresh snapshot to all SSE +// subscribers (/api/events) — the exact same body GET /api/state returns (getStateSnapshot), so +// pushed and polled states can never diverge in shape. Failures are logged, not fatal: polling +// still works either way. Background ticks (~1 s) cover action mutations for other clients too. +async function broadcastState(): Promise { + try { + broadcast(JSON.stringify(await getStateSnapshot())); + } catch (err) { + console.error('flight: state broadcast failed', err); + } +} + export function tickNow(): Promise { - return withLock(async () => tickOnce(await getGameDb())); + return withLock(async () => { + const row = await tickOnce(await getGameDb()); + await broadcastState(); + return row; + }); } // ISS-117: the world clock runs even with nobody polling — a ~1 s interval drives the same locked diff --git a/lib/sse.test.ts b/lib/sse.test.ts new file mode 100644 index 0000000..a9a3f91 --- /dev/null +++ b/lib/sse.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { broadcast, subscribe } from './sse'; + +// ISS-129 (v0.57.0): the fan-out registry behind /api/events — plain Set semantics, so no DB or +// server needed. Subscribers leak across tests in this file (module-level state), which is fine: +// each test only asserts on its own arrays. + +describe('lib/sse', () => { + it('delivers broadcasts to subscribers and stops after unsubscribe', () => { + const a: string[] = []; + const b: string[] = []; + const unsubA = subscribe((t) => a.push(t)); + const unsubB = subscribe((t) => b.push(t)); + + broadcast('x'); + expect(a).toEqual(['x']); + expect(b).toEqual(['x']); + + unsubA(); + broadcast('y'); + expect(a).toEqual(['x']); // unsubscribed — no more events + expect(b).toEqual(['x', 'y']); + unsubB(); + }); + + it('a throwing subscriber does not break the others', () => { + const got: string[] = []; + subscribe(() => { + throw new Error('boom'); + }); + const unsub = subscribe((t) => got.push(t)); + broadcast('z'); + expect(got).toEqual(['z']); + unsub(); + }); + + it('unsubscribe is idempotent', () => { + const got: string[] = []; + const unsub = subscribe((t) => got.push(t)); + unsub(); + unsub(); // second call must not throw + broadcast('w'); + expect(got).toEqual([]); + }); +}); diff --git a/lib/sse.ts b/lib/sse.ts new file mode 100644 index 0000000..0ef4964 --- /dev/null +++ b/lib/sse.ts @@ -0,0 +1,29 @@ +// ISS-129 (v0.57.0): in-process fan-out registry for the SSE state stream (/api/events). The app +// runs as one long-lived server process (`next start`; instrumentation.ts already keeps background +// sim state alive) — no new dependencies, no WS upgrade needed. The Set lives on globalThis because +// Next.js gives instrumentation.ts (where broadcast() fires from the background tick) and route +// handlers (where subscribe() happens in /api/events) separate module instances; a plain `let` would +// give each its own empty set and no push would ever be delivered. + +type Push = (text: string) => void; + +const g = globalThis as unknown as { __flightSseSubscribers?: Set }; +const subscribers: Set = (g.__flightSseSubscribers ??= new Set()); + +export function subscribe(fn: Push): () => void { + subscribers.add(fn); + return () => { + subscribers.delete(fn); + }; +} + +// Call each subscriber safely — one dead stream must not take the others down. +export function broadcast(text: string): void { + for (const fn of [...subscribers]) { + try { + fn(text); + } catch { + // subscriber's stream is gone; its own cleanup unsubscribes it + } + } +} diff --git a/lib/useGameState.ts b/lib/useGameState.ts index 33ea22e..d72e6c1 100644 --- a/lib/useGameState.ts +++ b/lib/useGameState.ts @@ -35,8 +35,14 @@ export async function fetchState(): Promise { return snapshot; } -// The shell's single polling source (v0.9.0): one GET /api/state loop for the whole page, -// so every pane renders from the same snapshot instead of running its own poll loops. +// The shell's single state source (v0.9.0): one snapshot feed for the whole page, so every pane +// renders from the same snapshot instead of running its own poll loops. ISS-129 (v0.57.0): primary +// transport is SSE — /api/events pushes a full snapshot after every sim tick; polling stays as an +// automatic fallback (no event within 5 s of connect, or on stream error) and stops again when fresh +// events flow. EventSource reconnects natively, so there is no custom reconnect logic here. +const SSE_URL = '/api/events'; +const SSE_SILENCE_MS = 5_000; + export function useGameState(intervalMs = 1000): GameStateResult { const [state, setState] = useState(null); const [loading, setLoading] = useState(true); @@ -70,10 +76,53 @@ export function useGameState(intervalMs = 1000): GameStateResult { }, []); useEffect(() => { - refresh(); // fetch immediately on mount, don't wait for the first interval tick - const id = setInterval(refresh, intervalMs); - return () => clearInterval(id); - }, [intervalMs, refresh]); + let pollId: ReturnType | null = null; + let silenceTimer: ReturnType | null = null; + let closed = false; + + const stopPolling = () => { + if (pollId !== null) clearInterval(pollId); + pollId = null; + }; + // Fallback loop: immediate catch-up fetch, then the old interval. + const startPolling = () => { + if (closed || pollId !== null) return; + refresh(); + pollId = setInterval(refresh, intervalMs); + }; + + let source: EventSource | null = null; + try { + source = new EventSource(SSE_URL); + } catch { + source = null; // no EventSource support — pure polling below + } + + if (source !== null) { + source.onmessage = (event) => { + try { + applySnapshot(JSON.parse(event.data)); + } catch { + // malformed event — the next one overwrites it anyway + } + stopPolling(); // fresh push flow → polling no longer needed + if (silenceTimer !== null) clearTimeout(silenceTimer); // connect check served its purpose + }; + source.onerror = () => startPolling(); // stream trouble → poll until pushes resume + silenceTimer = setTimeout(startPolling, SSE_SILENCE_MS); // connected but silent for 5 s → poll + } else { + startPolling(); + } + + refresh(); // mount: same immediate fetch as before (fast first paint + __flightLastSnapshot) + + return () => { + closed = true; + source?.close(); + stopPolling(); + if (silenceTimer !== null) clearTimeout(silenceTimer); + }; + }, [intervalMs, refresh, applySnapshot]); return { state, loading, error, refresh, applySnapshot }; } diff --git a/package.json b/package.json index 251c02d..3523fa6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.56.0", + "version": "0.57.0", "private": true, "scripts": { "dev": "next dev -p 3000",