feat: implement SSE-based real-time event distribution system with replay support and heartbeat functionality
This commit is contained in:
@@ -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