/** * WebSocket Composable * * Manages Socket.io connection with type safety, authentication, and auto-reconnection. * This composable is league-agnostic and will be shared between SBA and PD frontends. * * Features: * - Type-safe Socket.io client (TypedSocket) * - JWT authentication integration * - Auto-reconnection with exponential backoff * - Connection status tracking * - Event listener lifecycle management * - Integration with game store */ import { ref, computed, watch, onMounted, onUnmounted, readonly } from 'vue' import type { Socket } from 'socket.io-client'; import { io } from 'socket.io-client' import type { TypedSocket, ClientToServerEvents, ServerToClientEvents, PlayResult, } from '~/types' import { useAuthStore } from '~/store/auth' import { useGameStore } from '~/store/game' import { useUiStore } from '~/store/ui' // Reconnection configuration const RECONNECTION_DELAY_BASE = 1000 // Start with 1 second const RECONNECTION_DELAY_MAX = 30000 // Max 30 seconds const MAX_RECONNECTION_ATTEMPTS = 10 // ============================================================================ // CLIENT-ONLY Singleton State // These MUST only be accessed/modified on the client side to prevent SSR issues. // SSR can corrupt module-level state that persists across hydration. // // CRITICAL: All module-level state is lazily initialized on first client-side // access to prevent SSR from creating zombie state that persists into hydration. // ============================================================================ // Client-only state container - initialized only on client interface ClientState { socketInstance: Socket | null reconnectionAttempts: number reconnectionTimeout: ReturnType | null connectionTimeoutId: ReturnType | null heartbeatInterval: ReturnType | null stuckStateCheckInterval: ReturnType | null initialized: boolean } // Lazily initialized on client only let clientState: ClientState | null = null function getClientState(): ClientState { // Only initialize on client if (import.meta.client && !clientState) { clientState = { socketInstance: null, reconnectionAttempts: 0, reconnectionTimeout: null, connectionTimeoutId: null, heartbeatInterval: null, stuckStateCheckInterval: null, initialized: true, } console.log('[WebSocket] Client state initialized') } // Return empty state for SSR (won't be used, but prevents errors) return clientState || { socketInstance: null, reconnectionAttempts: 0, reconnectionTimeout: null, connectionTimeoutId: null, heartbeatInterval: null, stuckStateCheckInterval: null, initialized: false, } } // Reactive state - using refs that work with SSR but should only be mutated client-side const isConnected = ref(false) const isConnecting = ref(false) const connectionError = ref(null) const lastConnectionAttempt = ref(null) // Reset reactive state on client hydration to ensure clean slate if (import.meta.client) { // Force reset on hydration - this runs once when module loads on client isConnected.value = false isConnecting.value = false connectionError.value = null lastConnectionAttempt.value = null console.log('[WebSocket] Reactive state reset on client hydration') } export function useWebSocket() { // State is now module-level singleton (above) // ============================================================================ // Configuration (MUST be called during setup, not inside callbacks) // ============================================================================ const config = useRuntimeConfig() const wsUrl = config.public.wsUrl // ============================================================================ // Stores // ============================================================================ const authStore = useAuthStore() const gameStore = useGameStore() const uiStore = useUiStore() // ============================================================================ // Computed // ============================================================================ const socket = computed((): TypedSocket | null => { if (!import.meta.client) return null const state = getClientState() return state.socketInstance as TypedSocket | null }) const canConnect = computed(() => { return authStore.isAuthenticated }) const shouldReconnect = computed(() => { if (!import.meta.client) return false const state = getClientState() return ( !isConnected.value && canConnect.value && state.reconnectionAttempts < MAX_RECONNECTION_ATTEMPTS ) }) // ============================================================================ // Connection Management // ============================================================================ // Connection timeout constant const CONNECTION_TIMEOUT = 10000 // 10 seconds /** * Connect to WebSocket server with JWT authentication */ function connect() { // SSR guard - never attempt connection on server if (!import.meta.client) { console.log('[WebSocket] Skipping connect() - running on server') return } const state = getClientState() if (!state.initialized) { console.warn('[WebSocket] Client state not initialized, skipping connect') return } // REMOVED: Auth guard - let backend authenticate via cookies // The auth store state may not sync properly from SSR to client, // but cookies ARE sent automatically. Backend will reject if invalid. if (isConnected.value || isConnecting.value) { console.warn('[WebSocket] Already connected or connecting') return } isConnecting.value = true connectionError.value = null lastConnectionAttempt.value = Date.now() console.log('[WebSocket] Connecting to', wsUrl) // Set a timeout to reset isConnecting if no event fires // This prevents the "stuck connecting" state that can occur on Safari if (state.connectionTimeoutId) { clearTimeout(state.connectionTimeoutId) } state.connectionTimeoutId = setTimeout(() => { if (isConnecting.value && !isConnected.value) { console.warn('[WebSocket] Connection timeout - resetting state') isConnecting.value = false connectionError.value = 'Connection timeout - no response from server' // Schedule a reconnection attempt scheduleReconnection() } }, CONNECTION_TIMEOUT) try { // Create or reuse socket instance if (!state.socketInstance) { console.log('[WebSocket] Creating socket instance with URL:', wsUrl) if (!io) { console.error('[WebSocket] ERROR: io function is undefined!') connectionError.value = 'Socket.io library not loaded' isConnecting.value = false return } state.socketInstance = io(wsUrl, { withCredentials: true, // Send cookies automatically autoConnect: false, reconnection: false, // We handle reconnection manually for better control transports: ['websocket', 'polling'], }) console.log('[WebSocket] Socket instance created:', !!state.socketInstance) setupEventListeners() } console.log('[WebSocket] Calling socketInstance.connect()') state.socketInstance.connect() } catch (err) { console.error('[WebSocket] Connection error:', err) isConnecting.value = false connectionError.value = err instanceof Error ? err.message : String(err) if (state.connectionTimeoutId) { clearTimeout(state.connectionTimeoutId) state.connectionTimeoutId = null } } } /** * Disconnect from WebSocket server */ function disconnect() { // SSR guard if (!import.meta.client) return const state = getClientState() console.log('[WebSocket] Disconnecting') // Clear reconnection timer if (state.reconnectionTimeout) { clearTimeout(state.reconnectionTimeout) state.reconnectionTimeout = null } // Disconnect socket if (state.socketInstance) { state.socketInstance.disconnect() } isConnected.value = false isConnecting.value = false connectionError.value = null state.reconnectionAttempts = 0 } /** * Force reconnect - clears the singleton and creates a fresh connection. * Use this when the connection is in a bad state and normal reconnection isn't working. */ function forceReconnect() { // SSR guard if (!import.meta.client) return const state = getClientState() console.log('[WebSocket] Force reconnecting - clearing singleton') // Clear reconnection timer if (state.reconnectionTimeout) { clearTimeout(state.reconnectionTimeout) state.reconnectionTimeout = null } // Fully disconnect and destroy the socket instance if (state.socketInstance) { state.socketInstance.removeAllListeners() state.socketInstance.disconnect() state.socketInstance = null } // Reset all state isConnected.value = false isConnecting.value = false connectionError.value = null state.reconnectionAttempts = 0 // Reconnect after a short delay setTimeout(() => { connect() }, 100) } /** * Reconnect with exponential backoff */ function scheduleReconnection() { // SSR guard if (!import.meta.client) return const state = getClientState() if (!shouldReconnect.value) { console.log('[WebSocket] Reconnection not needed or max attempts reached') return } // Calculate delay with exponential backoff const delay = Math.min( RECONNECTION_DELAY_BASE * Math.pow(2, state.reconnectionAttempts), RECONNECTION_DELAY_MAX ) console.log( `[WebSocket] Scheduling reconnection attempt ${state.reconnectionAttempts + 1}/${MAX_RECONNECTION_ATTEMPTS} in ${delay}ms` ) state.reconnectionTimeout = setTimeout(() => { state.reconnectionAttempts++ connect() }, delay) } // ============================================================================ // Event Listeners Setup // ============================================================================ /** * Set up all WebSocket event listeners */ function setupEventListeners() { const state = getClientState() if (!state.socketInstance) return // ======================================== // Connection Events // ======================================== state.socketInstance.on('connect', () => { const s = getClientState() console.log('[WebSocket] Connected successfully') isConnected.value = true isConnecting.value = false connectionError.value = null s.reconnectionAttempts = 0 // Clear connection timeout if (s.connectionTimeoutId) { clearTimeout(s.connectionTimeoutId) s.connectionTimeoutId = null } // Update global flag for plugin failsafe if (typeof window !== 'undefined') { window.__ws_connected = true } // Update game store gameStore.setConnected(true) // Show success toast uiStore.showSuccess('Connected to game server') }) state.socketInstance.on('disconnect', (reason) => { console.log('[WebSocket] Disconnected:', reason) isConnected.value = false isConnecting.value = false // Update global flag for plugin failsafe if (typeof window !== 'undefined') { window.__ws_connected = false } // Update game store gameStore.setConnected(false) // Show warning toast uiStore.showWarning('Disconnected from game server') // Attempt reconnection if not intentional if (reason !== 'io client disconnect') { scheduleReconnection() } }) state.socketInstance.on('connect_error', (error) => { console.error('[WebSocket] Connection error:', error) isConnected.value = false isConnecting.value = false connectionError.value = error.message // Update game store gameStore.setError(error.message) // Show error toast uiStore.showError(`Connection failed: ${error.message}`) // Attempt reconnection scheduleReconnection() }) state.socketInstance.on('connected', (data) => { console.log('[WebSocket] Server confirmed connection for user:', data.user_id) }) state.socketInstance.on('game_joined', (data: { game_id: string; role: string }) => { console.log('[WebSocket] Successfully joined game:', data.game_id, 'as', data.role) uiStore.showSuccess(`Joined game as ${data.role}`) }) state.socketInstance.on('heartbeat_ack', () => { // Heartbeat acknowledged - connection is healthy // No action needed, just prevents timeout }) // ======================================== // Game State Events // ======================================== state.socketInstance.on('game_state', (gameState) => { console.log('[WebSocket] Full game state received (from request_game_state)') gameStore.setGameState(gameState) }) state.socketInstance.on('game_state_update', (gameState) => { const batterInfo = gameState.current_batter ? `lineup_id=${gameState.current_batter.lineup_id}, batting_order=${gameState.current_batter.batting_order}` : 'None' console.log('[WebSocket] Game state update received, current_batter:', batterInfo) console.log('[WebSocket] Full gameState:', JSON.stringify(gameState, null, 2).slice(0, 500)) gameStore.setGameState(gameState) console.log('[WebSocket] After setGameState, store current_batter:', gameStore.currentBatter) }) state.socketInstance.on('game_state_sync', (data) => { console.log('[WebSocket] Full game state sync received') gameStore.setGameState(data.state) // Replace play history with synced plays (O(1) - no iteration/dedup needed) gameStore.setPlayHistory(data.recent_plays) }) state.socketInstance.on('inning_change', (data) => { console.log(`[WebSocket] Inning change: ${data.half} ${data.inning}`) uiStore.showInfo(`${data.half === 'top' ? 'Top' : 'Bottom'} ${data.inning}`, 3000) }) state.socketInstance.on('game_ended', (data) => { console.log('[WebSocket] Game ended:', data.winner_team_id) uiStore.showSuccess( `Game Over! Final: ${data.final_score.away} - ${data.final_score.home}`, 10000 ) }) // ======================================== // Decision Events // ======================================== state.socketInstance.on('decision_required', (prompt) => { console.log('[WebSocket] Decision required:', prompt.phase) gameStore.setDecisionPrompt(prompt) }) state.socketInstance.on('defensive_decision_submitted', (data) => { const s = getClientState() console.log('[WebSocket] Defensive decision submitted') gameStore.clearDecisionPrompt() // Request updated game state to get new decision_phase if (s.socketInstance && gameStore.gameId) { s.socketInstance.emit('request_game_state', { game_id: gameStore.gameId }) } if (data.pending_decision) { uiStore.showInfo('Defense set. Waiting for offense...', 3000) } else { uiStore.showSuccess('Defense set. Ready to play!', 3000) } }) state.socketInstance.on('offensive_decision_submitted', (data) => { const s = getClientState() console.log('[WebSocket] Offensive decision submitted') gameStore.clearDecisionPrompt() // Request updated game state to get new decision_phase if (s.socketInstance && gameStore.gameId) { s.socketInstance.emit('request_game_state', { game_id: gameStore.gameId }) } uiStore.showSuccess('Offense set. Ready to play!', 3000) }) // ======================================== // Manual Workflow Events // ======================================== state.socketInstance.on('dice_rolled', (data) => { console.log('[WebSocket] Dice rolled:', data.roll_id) gameStore.setPendingRoll({ roll_id: data.roll_id, d6_one: data.d6_one, d6_two_a: data.d6_two_a, d6_two_b: data.d6_two_b, d6_two_total: data.d6_two_total, chaos_d20: data.chaos_d20, resolution_d20: data.resolution_d20, check_wild_pitch: data.check_wild_pitch, check_passed_ball: data.check_passed_ball, timestamp: data.timestamp, }) uiStore.showInfo(data.message, 5000) }) state.socketInstance.on('outcome_accepted', (data) => { console.log('[WebSocket] Outcome accepted:', data.outcome) gameStore.clearPendingRoll() uiStore.showSuccess('Outcome submitted. Resolving play...', 3000) }) state.socketInstance.on('play_resolved', (data) => { console.log('[WebSocket] Play resolved:', data.description) // Convert to PlayResult and add to game store const playResult: PlayResult = { play_number: data.play_number, outcome: data.outcome, hit_location: data.hit_location, description: data.description, outs_recorded: data.outs_recorded, runs_scored: data.runs_scored, batter_result: data.batter_result, runners_advanced: data.runners_advanced, is_hit: data.is_hit, is_out: data.is_out, is_walk: data.is_walk, roll_id: data.roll_id, x_check_details: data.x_check_details, } gameStore.setLastPlayResult(playResult) gameStore.addPlayToHistory(playResult) // Clear pending decisions since the play is complete and we'll need new ones for next batter gameStore.clearPendingDecisions() uiStore.showSuccess(data.description, 5000) }) // ======================================== // Substitution Events // ======================================== state.socketInstance.on('player_substituted', (data) => { const s = getClientState() console.log('[WebSocket] Player substituted:', data.type) uiStore.showInfo(data.message, 5000) // Request updated lineup if (s.socketInstance) { s.socketInstance.emit('get_lineup', { game_id: gameStore.gameId!, team_id: data.team_id, }) } }) state.socketInstance.on('substitution_confirmed', (data) => { console.log('[WebSocket] Substitution confirmed') uiStore.showSuccess(data.message, 5000) }) // ======================================== // Data Response Events // ======================================== state.socketInstance.on('lineup_data', (data) => { console.log('[WebSocket] Lineup data received for team:', data.team_id) gameStore.updateLineup(data.team_id, data.players) }) state.socketInstance.on('box_score_data', (data) => { console.log('[WebSocket] Box score data received') // Box score will be handled by dedicated component // Just log for now }) // ======================================== // Error Events // ======================================== state.socketInstance.on('error', (data) => { console.error('[WebSocket] Server error:', data.message) gameStore.setError(data.message) uiStore.showError(data.message, 7000) }) state.socketInstance.on('outcome_rejected', (data) => { console.error('[WebSocket] Outcome rejected:', data.message) uiStore.showError(`Invalid outcome: ${data.message}`, 7000) }) state.socketInstance.on('substitution_error', (data) => { console.error('[WebSocket] Substitution error:', data.message) uiStore.showError(`Substitution failed: ${data.message}`, 7000) }) state.socketInstance.on('invalid_action', (data) => { console.error('[WebSocket] Invalid action:', data.reason) uiStore.showError(`Invalid action: ${data.reason}`, 7000) }) state.socketInstance.on('connection_error', (data) => { console.error('[WebSocket] Connection error:', data.error) connectionError.value = data.error uiStore.showError(`Connection error: ${data.error}`, 7000) }) } // ============================================================================ // Heartbeat // ============================================================================ /** * Send periodic heartbeat to keep connection alive */ function startHeartbeat() { // SSR guard if (!import.meta.client) return const state = getClientState() if (state.heartbeatInterval) return state.heartbeatInterval = setInterval(() => { const s = getClientState() if (s.socketInstance?.connected) { s.socketInstance.emit('heartbeat') } }, 30000) // Every 30 seconds } function stopHeartbeat() { // SSR guard if (!import.meta.client) return const state = getClientState() if (state.heartbeatInterval) { clearInterval(state.heartbeatInterval) state.heartbeatInterval = null } } // ============================================================================ // Watchers // ============================================================================ // Watch for auth changes (not immediate - onMounted handles initial state) // Only set up watcher on client if (import.meta.client) { watch( () => authStore.isAuthenticated, (authenticated, oldValue) => { console.log('[WebSocket] Auth changed:', oldValue, '->', authenticated) if (authenticated && !isConnected.value && !isConnecting.value) { console.log('[WebSocket] Auth became true, connecting...') connect() startHeartbeat() } else if (!authenticated && isConnected.value) { disconnect() stopHeartbeat() } } ) } // ============================================================================ // Stuck State Detection // ============================================================================ // Detects when auth is true but connection hasn't happened (race condition fallback) function startStuckStateDetection() { // SSR guard if (!import.meta.client) return const state = getClientState() if (state.stuckStateCheckInterval) return state.stuckStateCheckInterval = setInterval(() => { // Try to connect if not connected - don't check auth state // Backend will authenticate via cookies if (!isConnected.value && !isConnecting.value) { console.warn('[WebSocket] STUCK STATE DETECTED: Not connected. Attempting connect...') connect() startHeartbeat() } }, 5000) // Check every 5 seconds } function stopStuckStateDetection() { // SSR guard if (!import.meta.client) return const state = getClientState() if (state.stuckStateCheckInterval) { clearInterval(state.stuckStateCheckInterval) state.stuckStateCheckInterval = null } } // ============================================================================ // Lifecycle // ============================================================================ // Initial connection on client-side mount (handles SSR hydration case) onMounted(async () => { console.log('[WebSocket] onMounted - isAuthenticated:', authStore.isAuthenticated) // Start stuck state detection to catch race conditions startStuckStateDetection() // If not authenticated, try to check auth first (handles SSR hydration case) if (!authStore.isAuthenticated) { console.log('[WebSocket] Not authenticated on mount, checking auth...') await authStore.checkAuth() console.log('[WebSocket] After checkAuth - isAuthenticated:', authStore.isAuthenticated) } if (authStore.isAuthenticated && !isConnected.value && !isConnecting.value) { console.log('[WebSocket] Auto-connecting on mount (authenticated)') connect() startHeartbeat() } }) onUnmounted(() => { console.log('[WebSocket] Component unmounted, cleaning up') stopHeartbeat() stopStuckStateDetection() // Don't disconnect on unmount - keep connection alive for app // Only disconnect when explicitly requested or user logs out }) // ============================================================================ // Public API // ============================================================================ return { // State socket, isConnected: readonly(isConnected), isConnecting: readonly(isConnecting), connectionError: readonly(connectionError), canConnect, // Actions connect, disconnect, forceReconnect, } }