/** * WebSocket Hook for Real-Time Updates * ===================================== * Manages WebSocket connection, subscriptions, and message handling. */ import { useEffect, useRef, useState, useCallback } from 'react'; import { useAppStore } from '../store/appStore'; const WS_BASE_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8000/ws'; interface WebSocketMessage { type: string; [key: string]: any; } interface UseWebSocketOptions { onConnect?: () => void; onDisconnect?: () => void; onMessage?: (message: WebSocketMessage) => void; onError?: (error: Event) => void; reconnectInterval?: number; maxReconnectAttempts?: number; } export function useWebSocket(options: UseWebSocketOptions = {}) { const { onConnect, onDisconnect, onMessage, onError, reconnectInterval = 5000, maxReconnectAttempts = 10 } = options; const [isConnected, setIsConnected] = useState(false); const [isConnecting, setIsConnecting] = useState(false); const [subscribedChannels, setSubscribedChannels] = useState>(new Set()); const [lastMessage, setLastMessage] = useState(null); const wsRef = useRef(null); const reconnectAttemptsRef = useRef(0); const reconnectTimeoutRef = useRef | null>(null); const heartbeatIntervalRef = useRef | null>(null); const token = useAppStore((state) => state.authToken) || localStorage.getItem('access_token'); // Connect to WebSocket const connect = useCallback(() => { if (wsRef.current?.readyState === WebSocket.OPEN) { return; } if (isConnecting) { return; } setIsConnecting(true); // Build URL with auth token const url = token ? `${WS_BASE_URL}?token=${token}` : WS_BASE_URL; try { const ws = new WebSocket(url); wsRef.current = ws; ws.onopen = () => { console.log('WebSocket connected'); setIsConnected(true); setIsConnecting(false); reconnectAttemptsRef.current = 0; onConnect?.(); // Start heartbeat heartbeatIntervalRef.current = setInterval(() => { ws.send(JSON.stringify({ action: 'ping' })); }, 30000); // Resubscribe to previous channels subscribedChannels.forEach(channel => { ws.send(JSON.stringify({ action: 'subscribe', channel })); }); }; ws.onmessage = (event) => { try { const message = JSON.parse(event.data); setLastMessage(message); onMessage?.(message); } catch (e) { console.error('Failed to parse WebSocket message:', e); } }; ws.onclose = () => { console.log('WebSocket disconnected'); setIsConnected(false); setIsConnecting(false); onDisconnect?.(); // Clear heartbeat if (heartbeatIntervalRef.current) { clearInterval(heartbeatIntervalRef.current); } // Attempt reconnection if (reconnectAttemptsRef.current < maxReconnectAttempts) { reconnectAttemptsRef.current++; console.log(`Reconnecting... Attempt ${reconnectAttemptsRef.current}`); reconnectTimeoutRef.current = setTimeout(() => { connect(); }, reconnectInterval * Math.min(reconnectAttemptsRef.current, 5)); // Exponential backoff } }; ws.onerror = (error) => { console.error('WebSocket error:', error); onError?.(error); }; } catch (error) { console.error('Failed to create WebSocket:', error); setIsConnecting(false); } }, [token, isConnecting, onConnect, onDisconnect, onMessage, onError, reconnectInterval, maxReconnectAttempts, subscribedChannels]); // Disconnect const disconnect = useCallback(() => { // Clear reconnection timeout if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); reconnectTimeoutRef.current = null; } // Clear heartbeat if (heartbeatIntervalRef.current) { clearInterval(heartbeatIntervalRef.current); } // Close connection if (wsRef.current) { wsRef.current.close(); wsRef.current = null; } setIsConnected(false); setIsConnecting(false); }, []); // Subscribe to channel const subscribe = useCallback((channel: string) => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ action: 'subscribe', channel })); } setSubscribedChannels(prev => new Set([...prev, channel])); }, []); // Unsubscribe from channel const unsubscribe = useCallback((channel: string) => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ action: 'unsubscribe', channel })); } setSubscribedChannels(prev => { const next = new Set(prev); next.delete(channel); return next; }); }, []); // Send message const sendMessage = useCallback((message: object) => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify(message)); return true; } return false; }, []); // Connect on mount, disconnect on unmount useEffect(() => { connect(); return () => { disconnect(); }; }, [connect, disconnect]); return { isConnected, isConnecting, lastMessage, subscribedChannels: Array.from(subscribedChannels), connect, disconnect, subscribe, unsubscribe, sendMessage }; } // Specialized hook for alerts export function useAlerts() { const [alerts, setAlerts] = useState([]); const handleMessage = useCallback((message: WebSocketMessage) => { if (message.type === 'alert') { setAlerts(prev => [message.data, ...prev].slice(0, 100)); // Keep last 100 } }, []); const { isConnected, subscribe, unsubscribe } = useWebSocket({ onMessage: handleMessage }); useEffect(() => { if (isConnected) { subscribe('alerts'); subscribe('whale_alerts'); subscribe('scam_alerts'); } return () => { unsubscribe('alerts'); unsubscribe('whale_alerts'); unsubscribe('scam_alerts'); }; }, [isConnected, subscribe, unsubscribe]); return { alerts, isConnected }; } // Specialized hook for Muncher Map real-time updates export function useMuncherMapRealtime(graphId: string | null) { const [updates, setUpdates] = useState([]); const handleMessage = useCallback((message: WebSocketMessage) => { if (message.type === 'graph_update' && message.graph_id === graphId) { setUpdates(prev => [...prev, message]); } }, [graphId]); const { isConnected, subscribe, unsubscribe } = useWebSocket({ onMessage: handleMessage }); useEffect(() => { if (isConnected && graphId) { subscribe('network_graph_updates'); } return () => { unsubscribe('network_graph_updates'); }; }, [isConnected, graphId, subscribe, unsubscribe]); return { updates, isConnected, clearUpdates: () => setUpdates([]) }; } // Hook for price updates export function usePriceUpdates(tokens: string[]) { const [prices, setPrices] = useState>({}); const handleMessage = useCallback((message: WebSocketMessage) => { if (message.type === 'price_update') { setPrices(prev => ({ ...prev, [message.token]: { price: message.price, change24h: message.change_24h } })); } }, []); const { isConnected, subscribe, unsubscribe } = useWebSocket({ onMessage: handleMessage }); useEffect(() => { if (isConnected && tokens.length > 0) { subscribe('price_updates'); } return () => { unsubscribe('price_updates'); }; }, [isConnected, tokens, subscribe, unsubscribe]); return { prices, isConnected }; }