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 };
}