feat: implement SSE-based real-time event distribution system with replay support and heartbeat functionality
This commit is contained in:
@@ -13,7 +13,12 @@ export async function GET(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const upstream = await fetch(`${API_BASE.replace(/\/+$/, "")}/api/events`, {
|
||||
const upstreamUrl = new URL(`${API_BASE.replace(/\/+$/, "")}/api/events`);
|
||||
req.nextUrl.searchParams.forEach((value, key) => {
|
||||
upstreamUrl.searchParams.append(key, value);
|
||||
});
|
||||
|
||||
const upstream = await fetch(upstreamUrl.toString(), {
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
|
||||
@@ -24,7 +24,7 @@ import type {
|
||||
} from "@/lib/dashboard-types";
|
||||
import { buildDebBaselinePath } from "@/lib/temperature-chart-paths";
|
||||
import { DASHBOARD_REFRESH_POLICY_MS } from "@/lib/refresh-policy";
|
||||
import { useLatestPatch, type CityPatch } from "@/hooks/use-sse-patches";
|
||||
import { useLatestPatch, useSseResyncVersion, type CityPatch } from "@/hooks/use-sse-patches";
|
||||
import { Panel } from "@/components/dashboard/scan-terminal/Panel";
|
||||
import { rowName, temp } from "@/components/dashboard/scan-terminal/utils";
|
||||
|
||||
@@ -82,6 +82,28 @@ function getVisibleTemperatureSeries(
|
||||
});
|
||||
}
|
||||
|
||||
function getActiveTemperatureSeries(
|
||||
city: string,
|
||||
chartSeries: EvidenceSeries[],
|
||||
userToggledKeys: Record<string, boolean>,
|
||||
showRunwayDetails: boolean,
|
||||
) {
|
||||
const rawVisible = getVisibleTemperatureSeries(city, chartSeries, userToggledKeys);
|
||||
const hasRunwayMax = rawVisible.some((item) => item.key === "runway_max");
|
||||
|
||||
return rawVisible.filter((item) => {
|
||||
const isIndividualRunway =
|
||||
item.key.startsWith("runway_") && item.key !== "runway_max";
|
||||
if (showRunwayDetails) {
|
||||
return item.key !== "runway_max";
|
||||
}
|
||||
if (!hasRunwayMax) {
|
||||
return true;
|
||||
}
|
||||
return !isIndividualRunway;
|
||||
});
|
||||
}
|
||||
|
||||
function buildRunwayPlates(
|
||||
amos: AmosData | null | undefined,
|
||||
row: ScanOpportunityRow | null,
|
||||
@@ -194,8 +216,8 @@ type RunwayHistorySeries = {
|
||||
|
||||
const MAX_OBS_POINTS = 1440;
|
||||
const HOURLY_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar;
|
||||
const FULL_DAY_SLOT_MINUTES = 30;
|
||||
const FULL_DAY_SLOTS = 48;
|
||||
const FULL_DAY_SLOT_MINUTES = 1;
|
||||
const FULL_DAY_SLOTS = (24 * 60) / FULL_DAY_SLOT_MINUTES;
|
||||
const SLOT_INTERVAL_MS = FULL_DAY_SLOT_MINUTES * 60 * 1000;
|
||||
const _hourlyCache = new Map<string, { ts: number; data: HourlyForecast }>();
|
||||
const _hourlyRequestCache = new Map<string, Promise<HourlyForecast>>();
|
||||
@@ -622,7 +644,12 @@ function mergePatchIntoHourly(
|
||||
const amosChanges = changes.amos as Record<string, any> | undefined;
|
||||
const obsTimeVal = obsTime || amosChanges?.observation_time || amosChanges?.observation_time_local;
|
||||
const runwayObs = amosChanges?.runway_obs;
|
||||
if (runwayObs && Array.isArray(runwayObs.point_temperatures) && obsTimeVal) {
|
||||
const runwayPoints = Array.isArray(changes.runway_points)
|
||||
? changes.runway_points
|
||||
: runwayObs && Array.isArray(runwayObs.point_temperatures)
|
||||
? runwayObs.point_temperatures
|
||||
: [];
|
||||
if (runwayPoints.length && obsTimeVal) {
|
||||
const history: Record<string, Array<Record<string, unknown>>> = {};
|
||||
const sourceHistory = next.runwayPlateHistory || (next.amos as any)?.runway_plate_history || {};
|
||||
|
||||
@@ -634,10 +661,10 @@ function mergePatchIntoHourly(
|
||||
});
|
||||
|
||||
// Append new points from point_temperatures
|
||||
runwayObs.point_temperatures.forEach((pt: any) => {
|
||||
runwayPoints.forEach((pt: any) => {
|
||||
const rwy = pt.runway || "";
|
||||
if (!rwy) return;
|
||||
const tempVal = validNumber(pt.target_runway_max) ?? validNumber(pt.tdz_temp) ?? validNumber(pt.end_temp);
|
||||
const tempVal = validNumber(pt.temp) ?? validNumber(pt.target_runway_max) ?? validNumber(pt.tdz_temp) ?? validNumber(pt.end_temp);
|
||||
if (tempVal === null) return;
|
||||
|
||||
const rwyHistory = history[rwy] || [];
|
||||
@@ -653,6 +680,13 @@ function mergePatchIntoHourly(
|
||||
});
|
||||
|
||||
next.runwayPlateHistory = history;
|
||||
next.amos = {
|
||||
...(next.amos || {}),
|
||||
runway_obs: {
|
||||
...((next.amos as any)?.runway_obs || {}),
|
||||
point_temperatures: runwayPoints,
|
||||
},
|
||||
} as any;
|
||||
if (next.amos) {
|
||||
(next.amos as any).runway_plate_history = history;
|
||||
}
|
||||
@@ -1405,13 +1439,14 @@ export function LiveTemperatureThresholdChart({
|
||||
const [hourly, setHourly] = useState<HourlyForecast>(null);
|
||||
const city = String(row?.city || "").toLowerCase().trim();
|
||||
const latestPatch = useLatestPatch(city);
|
||||
const resyncVersion = useSseResyncVersion();
|
||||
const [timeframe, setTimeframe] = useState<"1D" | "3D">("1D");
|
||||
const [userToggledKeys, setUserToggledKeys] = useState<Record<string, boolean>>({});
|
||||
const [liveTemp, setLiveTemp] = useState<number | null>(null);
|
||||
const lastPatchAtRef = useRef<number>(Date.now());
|
||||
const lastAppliedPatchRevisionRef = useRef<number>(0);
|
||||
|
||||
const [showRunwayDetails, setShowRunwayDetails] = useState<boolean>(false);
|
||||
const [showRunwayDetails, setShowRunwayDetails] = useState<boolean>(true);
|
||||
const [refAreaLeft, setRefAreaLeft] = useState<number | null>(null);
|
||||
const [refAreaRight, setRefAreaRight] = useState<number | null>(null);
|
||||
const [zoomRange, setZoomRange] = useState<[number, number] | null>(null);
|
||||
@@ -1420,6 +1455,7 @@ export function LiveTemperatureThresholdChart({
|
||||
useEffect(() => {
|
||||
setUserToggledKeys({});
|
||||
setZoomRange(null);
|
||||
setShowRunwayDetails(true);
|
||||
lastPatchAtRef.current = Date.now();
|
||||
lastAppliedPatchRevisionRef.current = 0;
|
||||
}, [city, timeframe]);
|
||||
@@ -1480,6 +1516,20 @@ export function LiveTemperatureThresholdChart({
|
||||
setHourly((prev) => mergePatchIntoHourly(prev ?? seedHourlyForecastFromRow(row), latestPatch));
|
||||
}, [latestPatch, row]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resyncVersion || !city) return;
|
||||
let cancelled = false;
|
||||
fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })
|
||||
.then((data) => {
|
||||
if (cancelled || !data) return;
|
||||
setHourly(data);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [resyncVersion, city, targetResolution]);
|
||||
|
||||
// ── SSE fallback: only full-fetch if a visible chart has seen no patch for 2 minutes ──
|
||||
useEffect(() => {
|
||||
if (!shouldPollLiveChart({ city, compact, isActive, isMaximized })) return;
|
||||
@@ -1581,15 +1631,12 @@ export function LiveTemperatureThresholdChart({
|
||||
};
|
||||
|
||||
const activeSeries = useMemo(() => {
|
||||
const rawVisible = getVisibleTemperatureSeries(city, chartSeries, userToggledKeys);
|
||||
return rawVisible.filter((s) => {
|
||||
const isIndividualRunway = s.key.startsWith("runway_") && s.key !== "runway_max";
|
||||
if (showRunwayDetails) {
|
||||
return s.key !== "runway_max";
|
||||
} else {
|
||||
return !isIndividualRunway;
|
||||
}
|
||||
});
|
||||
return getActiveTemperatureSeries(
|
||||
city,
|
||||
chartSeries,
|
||||
userToggledKeys,
|
||||
showRunwayDetails,
|
||||
);
|
||||
}, [chartSeries, userToggledKeys, city, showRunwayDetails]);
|
||||
|
||||
const normalizedKey = normalizeCityKey(row?.city);
|
||||
@@ -1903,7 +1950,7 @@ export function LiveTemperatureThresholdChart({
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[11px] font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{isEn ? "METAR Settlement (30m) · Daily High" : `${metarHeaderLabel} · 当日最高`}
|
||||
{isEn ? "METAR Settlement · Daily High" : `${metarHeaderLabel} · 当日最高`}
|
||||
</span>
|
||||
<span className="text-2xl font-bold font-mono text-blue-600 mt-1">
|
||||
{temp(observedHighMetar)}
|
||||
@@ -2226,5 +2273,7 @@ export function __buildTemperatureChartDataForTest(
|
||||
|
||||
export const __isTemperatureSeriesVisibleByDefaultForTest = isTemperatureSeriesVisibleByDefault;
|
||||
export const __getVisibleTemperatureSeriesForTest = getVisibleTemperatureSeries;
|
||||
export const __getActiveTemperatureSeriesForTest = getActiveTemperatureSeries;
|
||||
export const __getObservationDisplayMetricsForTest = getObservationDisplayMetrics;
|
||||
export const __shouldPollLiveChartForTest = shouldPollLiveChart;
|
||||
export const __mergePatchIntoHourlyForTest = mergePatchIntoHourly;
|
||||
|
||||
@@ -21,15 +21,35 @@ export function runTests() {
|
||||
const sseManager = fs.readFileSync(sseManagerPath, "utf8");
|
||||
assert(sseManager.includes("asyncio.Queue"), "SSE manager must keep asyncio.Queue connections");
|
||||
assert(sseManager.includes("broadcast("), "SSE manager must expose broadcast(city, changes)");
|
||||
assert(sseManager.includes("broadcast_event"), "SSE manager must broadcast stored replayable events");
|
||||
assert(sseManager.includes("event_stream("), "SSE manager must expose an async event_stream(user_id)");
|
||||
assert(sseManager.includes("_queue_cities"), "SSE manager must track per-connection city subscriptions");
|
||||
assert(sseManager.includes("revision"), "SSE patches must carry monotonic revision numbers");
|
||||
assert(sseManager.includes("30"), "SSE stream must include a 30-second heartbeat");
|
||||
assert(sseManager.includes("data: "), "SSE stream must emit data: JSON frames");
|
||||
|
||||
const schemaPath = path.join(repoRoot, "web", "realtime_patch_schema.py");
|
||||
assert(fs.existsSync(schemaPath), "backend must define a versioned realtime patch schema module");
|
||||
const schema = fs.readFileSync(schemaPath, "utf8");
|
||||
assert(schema.includes("city_observation_patch.v1"), "patch schema must expose city_observation_patch.v1");
|
||||
assert(schema.includes("normalize_observation_patch"), "patch schema must normalize collector payloads");
|
||||
assert(schema.includes("runway_points"), "patch schema must preserve runway point observations");
|
||||
|
||||
const storePath = path.join(repoRoot, "web", "realtime_event_store.py");
|
||||
assert(fs.existsSync(storePath), "backend must define a realtime event replay store");
|
||||
const store = fs.readFileSync(storePath, "utf8");
|
||||
assert(store.includes("observation_patch_events"), "event store must use the SQLite observation_patch_events table");
|
||||
assert(store.includes("replay_events"), "event store must expose replay_events");
|
||||
assert(store.includes("replay_requires_resync"), "event store must detect incomplete replay windows");
|
||||
|
||||
const sseRouterPath = path.join(repoRoot, "web", "routers", "sse_router.py");
|
||||
assert(fs.existsSync(sseRouterPath), "FastAPI backend must define web/routers/sse_router.py");
|
||||
const sseRouter = fs.readFileSync(sseRouterPath, "utf8");
|
||||
assert(sseRouter.includes('"/api/events"'), "SSE router must expose GET /api/events");
|
||||
assert(sseRouter.includes("cities"), "SSE route must accept a cities query parameter");
|
||||
assert(sseRouter.includes("since_revision"), "SSE route must accept since_revision for replay");
|
||||
assert(sseRouter.includes("replay_limit"), "SSE route must bound replay batches");
|
||||
assert(sseRouter.includes("resync_required"), "SSE route must emit resync_required when replay is incomplete");
|
||||
assert(sseRouter.includes('"/api/internal/collector-patch"'), "SSE router must expose collector patch ingest endpoint");
|
||||
assert(sseRouter.includes("StreamingResponse"), "SSE route must return StreamingResponse");
|
||||
assert(sseRouter.includes('"text/event-stream"'), "SSE route must use text/event-stream media type");
|
||||
@@ -53,14 +73,24 @@ export function runTests() {
|
||||
const hook = fs.readFileSync(hookPath, "utf8");
|
||||
assert(hook.includes("new EventSource"), "frontend patch hook must connect with EventSource");
|
||||
assert(hook.includes("/api/events"), "frontend patch hook must subscribe to /api/events");
|
||||
assert(hook.includes("city_observation_patch.v1"), "frontend patch hook must accept v1 observation patch events");
|
||||
assert(hook.includes("subscribedCities"), "frontend patch hook must track the visible city subscription set");
|
||||
assert(hook.includes("since_revision"), "frontend patch hook must reconnect with since_revision");
|
||||
assert(hook.includes("resync_required"), "frontend patch hook must react to server resync_required events");
|
||||
assert(hook.includes("lastRevision"), "frontend patch hook must track the global last processed revision");
|
||||
assert(hook.includes("Map<"), "frontend patch hook must keep latest patches in a Map");
|
||||
assert(hook.includes("useLatestPatch"), "frontend patch hook must export useLatestPatch(city)");
|
||||
assert(hook.includes("revision"), "frontend patch hook must track revisions and skip stale patches");
|
||||
assert(hook.includes("setTimeout"), "frontend patch hook must implement explicit reconnect backoff");
|
||||
|
||||
const bffEventsRoute = readFrontendFile("app", "api", "events", "route.ts");
|
||||
assert(bffEventsRoute.includes("searchParams"), "Next.js SSE proxy must forward query parameters to FastAPI");
|
||||
|
||||
const chart = readFrontendFile("components", "dashboard", "scan-terminal", "LiveTemperatureThresholdChart.tsx");
|
||||
assert(chart.includes("useLatestPatch"), "temperature chart must consume useLatestPatch(city)");
|
||||
assert(chart.includes("latestPatch"), "temperature chart must react to incoming SSE patches");
|
||||
assert(chart.includes("useSseResyncVersion"), "temperature chart must resync full detail when SSE replay is incomplete");
|
||||
assert(chart.includes("runway_points"), "temperature chart must merge v1 runway_points into runway history");
|
||||
assert(chart.includes("2 * 60_000"), "temperature chart must wait two minutes without patches before full-fetch fallback");
|
||||
assert(
|
||||
!chart.includes("setInterval(poll, 60_000)"),
|
||||
|
||||
+92
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
__buildTemperatureChartDataForTest,
|
||||
__getActiveTemperatureSeriesForTest,
|
||||
__getObservationDisplayMetricsForTest,
|
||||
__getVisibleTemperatureSeriesForTest,
|
||||
__isTemperatureSeriesVisibleByDefaultForTest,
|
||||
__mergePatchIntoHourlyForTest,
|
||||
} from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
@@ -57,6 +59,7 @@ export function runTests() {
|
||||
|
||||
const { series } = __buildTemperatureChartDataForTest(guangzhou, hourly, "1D");
|
||||
const defaultVisibleSeries = __getVisibleTemperatureSeriesForTest("guangzhou", series, {});
|
||||
const activeDefaultSeries = __getActiveTemperatureSeriesForTest("guangzhou", series, {}, true);
|
||||
|
||||
const settlementRunway = seriesByKey(series, "runway_02L_20R") as any;
|
||||
assert(settlementRunway, "settlement runway should use a stable runway-pair key");
|
||||
@@ -77,6 +80,14 @@ export function runTests() {
|
||||
__isTemperatureSeriesVisibleByDefaultForTest("guangzhou", "runway_02L_20R"),
|
||||
"runway series should be visible by default",
|
||||
);
|
||||
assert(
|
||||
activeDefaultSeries.some((item) => item.key === "runway_02L_20R"),
|
||||
"settlement runway should remain in the active chart series by default",
|
||||
);
|
||||
assert(
|
||||
activeDefaultSeries.some((item) => item.key === "runway_01L_19R"),
|
||||
"auxiliary runway should remain in the active chart series by default",
|
||||
);
|
||||
assert(
|
||||
__isTemperatureSeriesVisibleByDefaultForTest("guangzhou", "settlement"),
|
||||
"settlement/HKO observations should be visible by default",
|
||||
@@ -284,6 +295,87 @@ export function runTests() {
|
||||
assert(madisSeries.label.includes("MADIS"), "US MADIS series should be labeled as NOAA MADIS instead of plain METAR");
|
||||
assert(madisSeries.values.filter((value: number | null) => value !== null).length >= 2, "MADIS series should keep sub-hourly observations");
|
||||
|
||||
const newYorkMinuteStream = __buildTemperatureChartDataForTest(
|
||||
{
|
||||
city: "new york",
|
||||
local_date: "2026-05-25",
|
||||
local_time: "10:04",
|
||||
tz_offset_seconds: -4 * 60 * 60,
|
||||
airport: "KLGA",
|
||||
} as any,
|
||||
{
|
||||
localTime: "10:04",
|
||||
times: ["00:00", "06:00", "12:00", "18:00"],
|
||||
temps: [55, 57, 65, 72],
|
||||
airportPrimary: {
|
||||
source_code: "madis_hfmetar",
|
||||
source_label: "NOAA MADIS",
|
||||
},
|
||||
airportPrimaryTodayObs: [
|
||||
["2026-05-25T14:01:00Z", 73.1],
|
||||
["2026-05-25T14:02:00Z", 73.4],
|
||||
["2026-05-25T14:03:00Z", 73.8],
|
||||
],
|
||||
} as any,
|
||||
"1D",
|
||||
);
|
||||
const minuteLabels = newYorkMinuteStream.data
|
||||
.filter((point) => point.madis !== null)
|
||||
.map((point) => point.label);
|
||||
assert(
|
||||
minuteLabels.includes("10:01") &&
|
||||
minuteLabels.includes("10:02") &&
|
||||
minuteLabels.includes("10:03"),
|
||||
"live observation chart should preserve minute-level SSE patch points instead of collapsing them into a 30-minute bucket",
|
||||
);
|
||||
|
||||
const chengduMergedHourly = __mergePatchIntoHourlyForTest(
|
||||
{
|
||||
localTime: "05:25",
|
||||
times: ["00:00", "06:00", "12:00", "18:00"],
|
||||
temps: [24, 28, 31, 27],
|
||||
runwayPlateHistory: {
|
||||
"02L/20R": [{ time: "05:20", temp: 24.2 }],
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
type: "city_observation_patch.v1",
|
||||
city: "chengdu",
|
||||
revision: 12,
|
||||
changes: {
|
||||
temp: 24.8,
|
||||
obs_time: "2026-05-26 05:26:00",
|
||||
source: "amsc_awos",
|
||||
runway_points: [
|
||||
{
|
||||
runway: "02L/20R",
|
||||
temp: 25.1,
|
||||
tdz_temp: 24.7,
|
||||
mid_temp: 24.9,
|
||||
end_temp: 25.1,
|
||||
target_runway_max: 25.1,
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any,
|
||||
);
|
||||
const chengduMergedChart = __buildTemperatureChartDataForTest(
|
||||
{
|
||||
city: "chengdu",
|
||||
local_date: "2026-05-26",
|
||||
local_time: "05:26",
|
||||
tz_offset_seconds: 8 * 60 * 60,
|
||||
} as any,
|
||||
chengduMergedHourly as any,
|
||||
"1D",
|
||||
);
|
||||
const chengduMergedRunway = seriesByKey(chengduMergedChart.series, "runway_02L_20R") as any;
|
||||
assert(chengduMergedRunway, "v1 runway_points patch should update the runway series");
|
||||
assert(
|
||||
chengduMergedRunway.values.some((value: number | null) => value === 25.1),
|
||||
"v1 runway_points patch should append the latest runway max point to the chart",
|
||||
);
|
||||
|
||||
const shanghaiDebFromDetail = __buildTemperatureChartDataForTest(
|
||||
{
|
||||
city: "shanghai",
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { resolveBackendApiUrl } from "@/lib/backend-api";
|
||||
|
||||
const V1_EVENT_TYPE = "city_observation_patch.v1";
|
||||
|
||||
export type CityPatch = {
|
||||
type?: string;
|
||||
city: string;
|
||||
@@ -11,29 +13,82 @@ export type CityPatch = {
|
||||
ts?: number;
|
||||
};
|
||||
|
||||
type ObservationPatchV1 = {
|
||||
type?: string;
|
||||
city?: string;
|
||||
source?: string;
|
||||
obs_time?: string | null;
|
||||
revision?: number;
|
||||
ts?: number;
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const latestPatches = new Map<string, CityPatch>();
|
||||
const latestRevisions = new Map<string, number>();
|
||||
const cityListeners = new Map<string, Set<() => void>>();
|
||||
const globalListeners = new Set<() => void>();
|
||||
const resyncListeners = new Set<() => void>();
|
||||
const subscribedCities = new Map<string, number>();
|
||||
|
||||
let eventSource: EventSource | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let reconnectAttempt = 0;
|
||||
let patchVersion = 0;
|
||||
let resyncVersion = 0;
|
||||
let lastRevision = 0;
|
||||
let useFallbackUrl = false;
|
||||
let activeConnectionKey = "";
|
||||
|
||||
function normalizeCityKey(city: string | null | undefined) {
|
||||
return String(city || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function subscribedCityList() {
|
||||
return Array.from(subscribedCities.keys()).sort();
|
||||
}
|
||||
|
||||
function notify(city: string) {
|
||||
patchVersion += 1;
|
||||
cityListeners.get(city)?.forEach((listener) => listener());
|
||||
globalListeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function notifyResync(latestServerRevision: number | null) {
|
||||
if (latestServerRevision !== null) {
|
||||
lastRevision = Math.max(lastRevision, latestServerRevision);
|
||||
}
|
||||
resyncVersion += 1;
|
||||
resyncListeners.forEach((listener) => listener());
|
||||
globalListeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function clearReconnectTimer() {
|
||||
if (!reconnectTimer) return;
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
|
||||
function buildSseUrl(baseUrl: string) {
|
||||
const params = new URLSearchParams();
|
||||
const cities = subscribedCityList();
|
||||
if (cities.length) {
|
||||
params.set("cities", cities.join(","));
|
||||
}
|
||||
if (lastRevision > 0) {
|
||||
params.set("since_revision", String(lastRevision));
|
||||
}
|
||||
params.set("replay_limit", "500");
|
||||
|
||||
const query = params.toString();
|
||||
return query ? `${baseUrl}?${query}` : baseUrl;
|
||||
}
|
||||
|
||||
function currentConnectionKey() {
|
||||
return `${useFallbackUrl ? "fallback" : "direct"}:${subscribedCityList().join("|")}:${lastRevision}`;
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer || typeof window === "undefined") return;
|
||||
if (reconnectTimer || typeof window === "undefined" || subscribedCities.size === 0) return;
|
||||
const delayMs = Math.min(30_000, 1_000 * Math.max(1, 2 ** reconnectAttempt));
|
||||
reconnectAttempt += 1;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
@@ -46,30 +101,31 @@ function closeEventSource() {
|
||||
if (!eventSource) return;
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
activeConnectionKey = "";
|
||||
}
|
||||
|
||||
function reconnectNow() {
|
||||
if (typeof window === "undefined") return;
|
||||
clearReconnectTimer();
|
||||
closeEventSource();
|
||||
connectSsePatches();
|
||||
}
|
||||
|
||||
function connectSsePatches() {
|
||||
if (typeof window === "undefined" || eventSource) return;
|
||||
if (typeof window === "undefined" || eventSource || subscribedCities.size === 0) return;
|
||||
|
||||
let url = resolveBackendApiUrl("/api/events");
|
||||
if (useFallbackUrl) {
|
||||
url = "/api/events";
|
||||
console.log("[SSE] Falling back to same-origin BFF proxy URL:", url);
|
||||
} else {
|
||||
console.log("[SSE] Attempting to connect to direct URL:", url);
|
||||
}
|
||||
const baseUrl = useFallbackUrl ? "/api/events" : resolveBackendApiUrl("/api/events");
|
||||
const url = buildSseUrl(baseUrl);
|
||||
activeConnectionKey = currentConnectionKey();
|
||||
|
||||
try {
|
||||
closeEventSource();
|
||||
eventSource = new EventSource(url, { withCredentials: true });
|
||||
|
||||
eventSource.onopen = () => {
|
||||
console.log("[SSE] Connection established successfully to:", url);
|
||||
reconnectAttempt = 0;
|
||||
};
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
console.log("[SSE] Received patch message:", event.data);
|
||||
try {
|
||||
applySsePatch(JSON.parse(event.data));
|
||||
} catch (err) {
|
||||
@@ -81,48 +137,127 @@ function connectSsePatches() {
|
||||
console.error("[SSE] Connection error or stream closed:", err);
|
||||
closeEventSource();
|
||||
if (!useFallbackUrl && url !== "/api/events") {
|
||||
console.warn("[SSE] Direct connection failed. Switching to same-origin BFF proxy fallback for next attempt.");
|
||||
useFallbackUrl = true;
|
||||
}
|
||||
scheduleReconnect();
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("[SSE] Exception thrown while instantiating EventSource:", err);
|
||||
if (!useFallbackUrl && url !== "/api/events") {
|
||||
closeEventSource();
|
||||
if (!useFallbackUrl && baseUrl !== "/api/events") {
|
||||
useFallbackUrl = true;
|
||||
}
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureSsePatchConnection() {
|
||||
connectSsePatches();
|
||||
function ensureSsePatchConnection() {
|
||||
if (subscribedCities.size === 0) {
|
||||
closeEventSource();
|
||||
clearReconnectTimer();
|
||||
return;
|
||||
}
|
||||
if (eventSource && activeConnectionKey === currentConnectionKey()) return;
|
||||
reconnectNow();
|
||||
}
|
||||
|
||||
export function applySsePatch(payload: unknown) {
|
||||
if (!payload || typeof payload !== "object") return false;
|
||||
const patch = payload as Partial<CityPatch>;
|
||||
if (patch.type && patch.type !== "city_patch") return false;
|
||||
function registerCitySubscription(city: string) {
|
||||
const cityKey = normalizeCityKey(city);
|
||||
if (!cityKey) return () => {};
|
||||
|
||||
const previousCount = subscribedCities.get(cityKey) ?? 0;
|
||||
subscribedCities.set(cityKey, previousCount + 1);
|
||||
if (previousCount === 0) {
|
||||
ensureSsePatchConnection();
|
||||
}
|
||||
|
||||
return () => {
|
||||
const nextCount = (subscribedCities.get(cityKey) ?? 1) - 1;
|
||||
if (nextCount <= 0) {
|
||||
subscribedCities.delete(cityKey);
|
||||
} else {
|
||||
subscribedCities.set(cityKey, nextCount);
|
||||
}
|
||||
ensureSsePatchConnection();
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLegacyPatch(patch: Partial<CityPatch>): CityPatch | null {
|
||||
const city = normalizeCityKey(patch.city);
|
||||
const changes = patch.changes;
|
||||
const revision = Number(patch.revision);
|
||||
if (!city || !changes || typeof changes !== "object" || !Number.isFinite(revision)) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
const previousRevision = latestRevisions.get(city) ?? 0;
|
||||
if (revision <= previousRevision) return false;
|
||||
|
||||
const normalizedPatch: CityPatch = {
|
||||
return {
|
||||
type: "city_patch",
|
||||
city,
|
||||
changes: changes as Record<string, unknown>,
|
||||
revision,
|
||||
ts: typeof patch.ts === "number" ? patch.ts : Date.now(),
|
||||
};
|
||||
latestRevisions.set(city, revision);
|
||||
latestPatches.set(city, normalizedPatch);
|
||||
notify(city);
|
||||
}
|
||||
|
||||
function normalizeV1Patch(patch: ObservationPatchV1): CityPatch | null {
|
||||
const city = normalizeCityKey(patch.city);
|
||||
const revision = Number(patch.revision);
|
||||
const payload = patch.payload;
|
||||
if (!city || !payload || typeof payload !== "object" || !Number.isFinite(revision)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const changes: Record<string, unknown> = {
|
||||
...payload,
|
||||
source: typeof patch.source === "string" ? patch.source : payload.source,
|
||||
obs_time: typeof patch.obs_time === "string" ? patch.obs_time : payload.obs_time,
|
||||
schema_type: V1_EVENT_TYPE,
|
||||
};
|
||||
|
||||
return {
|
||||
type: V1_EVENT_TYPE,
|
||||
city,
|
||||
changes,
|
||||
revision,
|
||||
ts: typeof patch.ts === "number" ? patch.ts : Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeIncomingPatch(payload: unknown): CityPatch | null {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const patch = payload as Partial<CityPatch> & ObservationPatchV1;
|
||||
if (patch.type === "city_patch" || !patch.type) {
|
||||
return normalizeLegacyPatch(patch);
|
||||
}
|
||||
if (patch.type === V1_EVENT_TYPE) {
|
||||
return normalizeV1Patch(patch);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function applySsePatch(payload: unknown) {
|
||||
if (!payload || typeof payload !== "object") return false;
|
||||
const event = payload as { type?: string; latest_revision?: number };
|
||||
|
||||
if (event.type === "connected" || event.type === "heartbeat") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.type === "resync_required") {
|
||||
const latestServerRevision = Number(event.latest_revision);
|
||||
notifyResync(Number.isFinite(latestServerRevision) ? latestServerRevision : null);
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedPatch = normalizeIncomingPatch(payload);
|
||||
if (!normalizedPatch) return false;
|
||||
|
||||
const previousRevision = latestRevisions.get(normalizedPatch.city) ?? 0;
|
||||
if (normalizedPatch.revision <= previousRevision) return false;
|
||||
|
||||
latestRevisions.set(normalizedPatch.city, normalizedPatch.revision);
|
||||
latestPatches.set(normalizedPatch.city, normalizedPatch);
|
||||
lastRevision = Math.max(lastRevision, normalizedPatch.revision);
|
||||
notify(normalizedPatch.city);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -131,14 +266,6 @@ export function getLatestPatchesSnapshot() {
|
||||
}
|
||||
|
||||
export function useSsePatchVersion() {
|
||||
if (typeof window !== "undefined") {
|
||||
ensureSsePatchConnection();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
ensureSsePatchConnection();
|
||||
}, []);
|
||||
|
||||
return useSyncExternalStore(
|
||||
(listener) => {
|
||||
globalListeners.add(listener);
|
||||
@@ -149,16 +276,24 @@ export function useSsePatchVersion() {
|
||||
);
|
||||
}
|
||||
|
||||
export function useSseResyncVersion() {
|
||||
return useSyncExternalStore(
|
||||
(listener) => {
|
||||
resyncListeners.add(listener);
|
||||
return () => resyncListeners.delete(listener);
|
||||
},
|
||||
() => resyncVersion,
|
||||
() => 0,
|
||||
);
|
||||
}
|
||||
|
||||
export function useLatestPatch(city: string | null | undefined) {
|
||||
const cityKey = normalizeCityKey(city);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
ensureSsePatchConnection();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
ensureSsePatchConnection();
|
||||
}, []);
|
||||
if (!cityKey) return undefined;
|
||||
return registerCitySubscription(cityKey);
|
||||
}, [cityKey]);
|
||||
|
||||
return useSyncExternalStore(
|
||||
(listener) => {
|
||||
@@ -177,3 +312,4 @@ export function useLatestPatch(city: string | null | undefined) {
|
||||
}
|
||||
|
||||
export const __applySsePatchForTest = applySsePatch;
|
||||
export const __buildSseUrlForTest = buildSseUrl;
|
||||
|
||||
Reference in New Issue
Block a user