import { useEffect, useRef, useCallback, useState } from 'react' import { useAuthStore } from '../store/auth' const isDev = import.meta.env.DEV export interface WSMessage { type: string ticket_id?: number message?: string title?: string user_id?: number is_admin?: boolean } interface UseWebSocketOptions { onMessage?: (message: WSMessage) => void onConnect?: () => void onDisconnect?: () => void } export function useWebSocket(options: UseWebSocketOptions = {}) { const { accessToken, isAuthenticated } = useAuthStore() const wsRef = useRef(null) const reconnectTimeoutRef = useRef | null>(null) const pingIntervalRef = useRef | null>(null) const [isConnected, setIsConnected] = useState(false) const reconnectAttemptsRef = useRef(0) const maxReconnectAttempts = 5 const optionsRef = useRef(options) // Update options ref when they change useEffect(() => { optionsRef.current = options }, [options]) const cleanup = useCallback(() => { if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current) reconnectTimeoutRef.current = null } if (pingIntervalRef.current) { clearInterval(pingIntervalRef.current) pingIntervalRef.current = null } if (wsRef.current) { wsRef.current.close() wsRef.current = null } }, []) const connect = useCallback(() => { if (!accessToken || !isAuthenticated) { return } // Don't reconnect if already connected if (wsRef.current?.readyState === WebSocket.OPEN) { return } cleanup() // Build WebSocket URL const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' let host = window.location.host // Handle VITE_API_URL - can be absolute URL or relative path const apiUrl = import.meta.env.VITE_API_URL if (apiUrl && (apiUrl.startsWith('http://') || apiUrl.startsWith('https://'))) { try { host = new URL(apiUrl).host } catch { // If URL parsing fails, use window.location.host } } // If apiUrl is relative (like /api), use window.location.host const wsUrl = `${protocol}//${host}/cabinet/ws?token=${accessToken}` try { const ws = new WebSocket(wsUrl) wsRef.current = ws ws.onopen = () => { if (isDev) console.log('[WS] Connected') setIsConnected(true) reconnectAttemptsRef.current = 0 optionsRef.current.onConnect?.() // Setup ping interval (every 25 seconds) pingIntervalRef.current = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping' })) } }, 25000) } ws.onmessage = (event) => { try { const message = JSON.parse(event.data) as WSMessage // Ignore pong messages if (message.type === 'pong' || message.type === 'connected') { return } optionsRef.current.onMessage?.(message) } catch (e) { if (isDev) console.error('[WS] Failed to parse message:', e) } } ws.onclose = (event) => { if (isDev) console.log('[WS] Disconnected:', event.code, event.reason) setIsConnected(false) optionsRef.current.onDisconnect?.() if (pingIntervalRef.current) { clearInterval(pingIntervalRef.current) pingIntervalRef.current = null } // Attempt to reconnect if not closed intentionally if (event.code !== 1000 && reconnectAttemptsRef.current < maxReconnectAttempts) { const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000) if (isDev) console.log(`[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`) reconnectTimeoutRef.current = setTimeout(() => { reconnectAttemptsRef.current++ connect() }, delay) } } ws.onerror = (error) => { if (isDev) console.error('[WS] Error:', error) } } catch (e) { if (isDev) console.error('[WS] Failed to connect:', e) } }, [accessToken, isAuthenticated, cleanup]) // Connect when authenticated useEffect(() => { if (isAuthenticated && accessToken) { connect() } else { cleanup() setIsConnected(false) } return cleanup }, [isAuthenticated, accessToken, connect, cleanup]) return { isConnected } }