SSE
This commit is contained in:
+116
-57
@@ -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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user