This commit is contained in:
2026-07-13 23:56:19 -07:00
parent 778f0f7994
commit 19f61ac454
10 changed files with 287 additions and 145 deletions
+116 -57
View File
@@ -1,9 +1,9 @@
import { useEffect, useRef } from "react";
import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Logger } from "@/lib/logger";
import { useAuth } from "@/contexts/auth-context";
import { tournamentQueries } from "@/features/tournaments/queries";
import { reactionKeys, reactionQueries } from "@/features/reactions/queries";
import { tournamentKeys } from "@/features/tournaments/queries";
import { reactionKeys } from "@/features/reactions/queries";
const logger = new Logger('ServerEvents');
@@ -12,11 +12,14 @@ type SSEEvent = {
[key: string]: any;
};
type EventHandler = (event: SSEEvent, queryClient: ReturnType<typeof useQueryClient>, currentSessionId?: string) => void;
type EventHandler = (event: SSEEvent, queryClient: ReturnType<typeof useQueryClient>) => void;
const INVALIDATE_DEBOUNCE_MS = 1500;
const INVALIDATE_DEBOUNCE_MS = 1000;
const INVALIDATE_JITTER_MS = 1500;
const invalidateTimers = new Map<string, ReturnType<typeof setTimeout>>();
const WATCHDOG_MS = 45_000;
function debouncedInvalidate(
queryClient: ReturnType<typeof useQueryClient>,
filters: { queryKey: readonly unknown[] }
@@ -25,10 +28,11 @@ function debouncedInvalidate(
const existing = invalidateTimers.get(key);
if (existing) clearTimeout(existing);
const delay = INVALIDATE_DEBOUNCE_MS + Math.random() * INVALIDATE_JITTER_MS;
invalidateTimers.set(key, setTimeout(() => {
invalidateTimers.delete(key);
queryClient.invalidateQueries(filters);
}, INVALIDATE_DEBOUNCE_MS));
}, delay));
}
function clearPendingInvalidations() {
@@ -39,53 +43,107 @@ function clearPendingInvalidations() {
}
const eventHandlers: Record<string, EventHandler> = {
"connected": () => {
logger.info("New Connection");
},
"ping": () => {},
"heartbeat": () => {},
"test": (event) => {
logger.info("Test event", event);
},
"tournament": (event, queryClient) => {
debouncedInvalidate(queryClient, { queryKey: ['tournaments'] });
debouncedInvalidate(queryClient, { queryKey: ['players', 'unenrolled'] });
},
"match": (event, queryClient) => {
debouncedInvalidate(queryClient, tournamentQueries.details(event.tournamentId))
debouncedInvalidate(queryClient, tournamentQueries.current())
debouncedInvalidate(queryClient, { queryKey: tournamentKeys.details(event.tournamentId) });
debouncedInvalidate(queryClient, { queryKey: tournamentKeys.current });
debouncedInvalidate(queryClient, { queryKey: ['players', 'stats'] });
debouncedInvalidate(queryClient, { queryKey: ['players', 'matches'] });
debouncedInvalidate(queryClient, { queryKey: ['players', 'activity'] });
debouncedInvalidate(queryClient, { queryKey: ['teams', 'stats'] });
debouncedInvalidate(queryClient, { queryKey: ['teams', 'matches'] });
debouncedInvalidate(queryClient, { queryKey: ['matches'] });
},
"reaction": (event, queryClient) => {
queryClient.invalidateQueries(reactionQueries.match(event.matchId));
queryClient.setQueryData(reactionKeys.match(event.matchId), () => event.reactions);
}
},
"team": (event, queryClient) => {
debouncedInvalidate(queryClient, { queryKey: ['teams'] });
debouncedInvalidate(queryClient, { queryKey: ['tournaments'] });
},
"player": (event, queryClient) => {
debouncedInvalidate(queryClient, { queryKey: ['players'] });
},
"badge": (event, queryClient) => {
debouncedInvalidate(queryClient, { queryKey: ['badges'] });
},
};
export function useServerEvents() {
const queryClient = useQueryClient();
const { user } = useAuth();
const retryCountRef = useRef(0);
const shouldConnectRef = useRef(true);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (typeof window === 'undefined') return;
if (!user?.id) return;
shouldConnectRef.current = true;
retryCountRef.current = 0;
let disposed = false;
let eventSource: EventSource | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let watchdogTimer: ReturnType<typeof setTimeout> | null = null;
let retryCount = 0;
let hasConnectedOnce = false;
const connectEventSource = () => {
if (!shouldConnectRef.current) return;
const disconnect = () => {
if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
eventSource?.close();
eventSource = null;
};
const reconnect = (delay: number) => {
if (disposed) return;
disconnect();
reconnectTimer = setTimeout(connect, delay);
};
const eventSource = new EventSource(`/api/events/$`);
const armWatchdog = () => {
if (watchdogTimer) clearTimeout(watchdogTimer);
watchdogTimer = setTimeout(() => {
logger.warn(`SSE watchdog: no messages in ${WATCHDOG_MS}ms, reconnecting`);
retryCount = 0;
reconnect(0);
}, WATCHDOG_MS);
};
const connect = () => {
if (disposed || eventSource) return;
eventSource = new EventSource(`/api/events/$`);
armWatchdog();
eventSource.onopen = () => {
retryCountRef.current = 0;
retryCount = 0;
};
eventSource.onmessage = (event) => {
armWatchdog();
try {
const data: SSEEvent = JSON.parse(event.data);
logger.info("Event received", data);
if (data.type !== "ping") {
logger.info("Event received", data);
}
if (data.type === "connected") {
if (hasConnectedOnce) {
setTimeout(() => {
if (!disposed) queryClient.invalidateQueries();
}, Math.random() * 2000);
}
hasConnectedOnce = true;
return;
}
const handler = eventHandlers[data.type];
if (handler) {
handler(data, queryClient, user?.id);
handler(data, queryClient);
} else {
logger.warn(`Unhandled SSE event type: ${data.type}`);
}
@@ -94,50 +152,51 @@ export function useServerEvents() {
}
};
eventSource.onerror = (error) => {
eventSource.onerror = async (error) => {
if (disposed) return;
logger.error("SSE connection error", error);
eventSource.close();
disconnect();
if (shouldConnectRef.current && retryCountRef.current < 10) {
retryCountRef.current += 1;
const delay = Math.min(
1000 * Math.pow(1.5, retryCountRef.current - 1),
15000
);
retryCount += 1;
const delay = Math.min(1000 * Math.pow(1.5, retryCount - 1), 15000);
logger.info(`SSE reconnection attempt ${retryCount} in ${Math.round(delay)}ms`);
logger.info(
`SSE reconnection attempt ${retryCountRef.current}/10 in ${delay}ms`
);
timeoutRef.current = setTimeout(() => {
if (shouldConnectRef.current) {
connectEventSource();
}
}, delay);
} else if (retryCountRef.current >= 10) {
logger.error("SSE max reconnection attempts reached");
try {
const { attemptRefreshingSession } = await import('supertokens-web-js/recipe/session');
await attemptRefreshingSession();
} catch {
}
};
return eventSource;
reconnect(delay);
};
};
const eventSource = connectEventSource();
const wake = () => {
if (disposed) return;
if (!eventSource || eventSource.readyState === EventSource.CLOSED) {
retryCount = 0;
reconnect(0);
}
};
const handleVisibility = () => {
if (document.visibilityState === 'visible') wake();
};
window.addEventListener('online', wake);
document.addEventListener('visibilitychange', handleVisibility);
connect();
return () => {
logger.info("Closing SSE connection");
shouldConnectRef.current = false;
disposed = true;
clearPendingInvalidations();
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
window.removeEventListener('online', wake);
document.removeEventListener('visibilitychange', handleVisibility);
if (eventSource) {
eventSource.close();
}
disconnect();
};
}, [user?.id]);
}
}, [user?.id, queryClient]);
}