File size: 4,635 Bytes
e4d7d50 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 | /**
* ChessEcon — Real Backend WebSocket Hook
* Connects to the Python FastAPI /ws endpoint and dispatches
* all game, coaching, economy, and training events to the dashboard.
*
* FIX (reconnect-loop): Callbacks (onMessage, onOpen, onClose) are stored in
* refs so they never appear in the useCallback/useEffect dependency arrays.
* Previously, unstable function references caused the effect to re-run on
* every render, immediately cancelling and recreating the WebSocket.
* Also added CONNECTING guard and proper ping-interval cleanup on close.
*/
import { useEffect, useRef, useCallback } from "react";
export type WSEventType =
| "game_start"
| "move"
| "coaching_request"
| "coaching_result"
| "game_end"
| "training_step"
| "economy_update"
| "pong";
export interface WSMessage {
type: WSEventType;
data: Record<string, unknown>;
}
interface UseBackendWSOptions {
url: string;
onMessage: (msg: WSMessage) => void;
onOpen?: () => void;
onClose?: () => void;
enabled?: boolean;
}
export function useBackendWS({
url,
onMessage,
onOpen,
onClose,
enabled = true,
}: UseBackendWSOptions) {
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const pingTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const mountedRef = useRef(true);
// Store callbacks in refs so they never invalidate useCallback/useEffect
const onMessageRef = useRef(onMessage);
const onOpenRef = useRef(onOpen);
const onCloseRef = useRef(onClose);
const enabledRef = useRef(enabled);
// Keep refs in sync with latest props on every render (no deps needed)
onMessageRef.current = onMessage;
onOpenRef.current = onOpen;
onCloseRef.current = onClose;
enabledRef.current = enabled;
const clearPing = useCallback(() => {
if (pingTimer.current) {
clearInterval(pingTimer.current);
pingTimer.current = null;
}
}, []);
// connect is stable — only depends on url (not on any callback)
const connect = useCallback(() => {
if (!enabledRef.current || !mountedRef.current) return;
// Do not create a new socket if one is already open or connecting
if (
wsRef.current?.readyState === WebSocket.OPEN ||
wsRef.current?.readyState === WebSocket.CONNECTING
) return;
try {
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
if (onOpenRef.current) onOpenRef.current();
// Start ping interval to keep connection alive
clearPing();
pingTimer.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ action: "ping" }));
} else {
clearPing();
}
}, 30_000);
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data) as WSMessage;
onMessageRef.current(msg);
} catch {
// ignore malformed messages
}
};
ws.onclose = () => {
clearPing();
if (onCloseRef.current) onCloseRef.current();
// Auto-reconnect after 3 seconds if still enabled and mounted
if (mountedRef.current && enabledRef.current) {
reconnectTimer.current = setTimeout(connect, 3_000);
}
};
ws.onerror = () => {
ws.close();
};
} catch {
// WebSocket not available (e.g., SSR) — retry after delay
if (mountedRef.current && enabledRef.current) {
reconnectTimer.current = setTimeout(connect, 3_000);
}
}
// Only depends on url — callbacks are read from refs
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url, clearPing]);
const send = useCallback((action: string, payload: Record<string, unknown> = {}) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ action, ...payload }));
}
}, []);
const disconnect = useCallback(() => {
if (reconnectTimer.current) {
clearTimeout(reconnectTimer.current);
reconnectTimer.current = null;
}
clearPing();
wsRef.current?.close();
wsRef.current = null;
}, [clearPing]);
// Only re-run when enabled or url changes — not on every render
useEffect(() => {
mountedRef.current = true;
if (enabled) {
connect();
} else {
disconnect();
}
return () => {
mountedRef.current = false;
disconnect();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, url]);
return { send, disconnect, connect };
}
|