/** * 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, 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 // Singleton socket instance let socketInstance: Socket | null = null let reconnectionAttempts = 0 let reconnectionTimeout: NodeJS.Timeout | null = null // Singleton reactive state (shared across all useWebSocket calls) const isConnected = ref(false) const isConnecting = ref(false) const connectionError = ref(null) const lastConnectionAttempt = ref(null) export function useWebSocket() { // State is now module-level singleton (above) // ============================================================================ // Stores // ============================================================================ const authStore = useAuthStore() const gameStore = useGameStore() const uiStore = useUiStore() // ============================================================================ // Computed // ============================================================================ const socket = computed((): TypedSocket | null => { return socketInstance as TypedSocket | null }) const canConnect = computed(() => { return authStore.isAuthenticated && authStore.isTokenValid }) const shouldReconnect = computed(() => { return ( !isConnected.value && canConnect.value && reconnectionAttempts < MAX_RECONNECTION_ATTEMPTS ) }) // ============================================================================ // Connection Management // ============================================================================ /** * Connect to WebSocket server with JWT authentication */ function connect() { if (!canConnect.value) { console.warn('[WebSocket] Cannot connect: not authenticated or token invalid') return } if (isConnected.value || isConnecting.value) { console.warn('[WebSocket] Already connected or connecting') return } isConnecting.value = true connectionError.value = null lastConnectionAttempt.value = Date.now() const config = useRuntimeConfig() const wsUrl = config.public.wsUrl console.log('[WebSocket] Connecting to', wsUrl) // Create or reuse socket instance if (!socketInstance) { socketInstance = io(wsUrl, { auth: { token: authStore.token, }, autoConnect: false, reconnection: false, // We handle reconnection manually for better control transports: ['websocket', 'polling'], }) setupEventListeners() } else { // Update auth token if reconnecting socketInstance.auth = { token: authStore.token, } } // Connect socketInstance.connect() } /** * Disconnect from WebSocket server */ function disconnect() { console.log('[WebSocket] Disconnecting') // Clear reconnection timer if (reconnectionTimeout) { clearTimeout(reconnectionTimeout) reconnectionTimeout = null } // Disconnect socket if (socketInstance) { socketInstance.disconnect() } isConnected.value = false isConnecting.value = false connectionError.value = null reconnectionAttempts = 0 } /** * Reconnect with exponential backoff */ function scheduleReconnection() { 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, reconnectionAttempts), RECONNECTION_DELAY_MAX ) console.log( `[WebSocket] Scheduling reconnection attempt ${reconnectionAttempts + 1}/${MAX_RECONNECTION_ATTEMPTS} in ${delay}ms` ) reconnectionTimeout = setTimeout(() => { reconnectionAttempts++ connect() }, delay) } // ============================================================================ // Event Listeners Setup // ============================================================================ /** * Set up all WebSocket event listeners */ function setupEventListeners() { if (!socketInstance) return // ======================================== // Connection Events // ======================================== socketInstance.on('connect', () => { console.log('[WebSocket] Connected successfully') isConnected.value = true isConnecting.value = false connectionError.value = null reconnectionAttempts = 0 // Update game store gameStore.setConnected(true) // Show success toast uiStore.showSuccess('Connected to game server') }) socketInstance.on('disconnect', (reason) => { console.log('[WebSocket] Disconnected:', reason) isConnected.value = false isConnecting.value = 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() } }) 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() }) socketInstance.on('connected', (data) => { console.log('[WebSocket] Server confirmed connection for user:', data.user_id) }) 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}`) }) socketInstance.on('heartbeat_ack', () => { // Heartbeat acknowledged - connection is healthy // No action needed, just prevents timeout }) // ======================================== // Game State Events // ======================================== socketInstance.on('game_state', (state) => { console.log('[WebSocket] Full game state received (from request_game_state)') gameStore.setGameState(state) }) socketInstance.on('game_state_update', (state) => { console.log('[WebSocket] Game state update received') gameStore.setGameState(state) }) socketInstance.on('game_state_sync', (data) => { console.log('[WebSocket] Full game state sync received') gameStore.setGameState(data.state) // Add recent plays to history data.recent_plays.forEach((play) => { gameStore.addPlayToHistory(play) }) }) socketInstance.on('inning_change', (data) => { console.log(`[WebSocket] Inning change: ${data.half} ${data.inning}`) uiStore.showInfo(`${data.half === 'top' ? 'Top' : 'Bottom'} ${data.inning}`, 3000) }) 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 // ======================================== socketInstance.on('decision_required', (prompt) => { console.log('[WebSocket] Decision required:', prompt.phase) gameStore.setDecisionPrompt(prompt) }) socketInstance.on('defensive_decision_submitted', (data) => { console.log('[WebSocket] Defensive decision submitted') gameStore.clearDecisionPrompt() // Request updated game state to get new decision_phase if (socketInstance && gameStore.gameId) { 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) } }) socketInstance.on('offensive_decision_submitted', (data) => { console.log('[WebSocket] Offensive decision submitted') gameStore.clearDecisionPrompt() // Request updated game state to get new decision_phase if (socketInstance && gameStore.gameId) { socketInstance.emit('request_game_state', { game_id: gameStore.gameId }) } uiStore.showSuccess('Offense set. Ready to play!', 3000) }) // ======================================== // Manual Workflow Events // ======================================== 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) }) socketInstance.on('outcome_accepted', (data) => { console.log('[WebSocket] Outcome accepted:', data.outcome) gameStore.clearPendingRoll() uiStore.showSuccess('Outcome submitted. Resolving play...', 3000) }) 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) uiStore.showSuccess(data.description, 5000) }) // ======================================== // Substitution Events // ======================================== socketInstance.on('player_substituted', (data) => { console.log('[WebSocket] Player substituted:', data.type) uiStore.showInfo(data.message, 5000) // Request updated lineup if (socketInstance) { socketInstance.emit('get_lineup', { game_id: gameStore.gameId!, team_id: data.team_id, }) } }) socketInstance.on('substitution_confirmed', (data) => { console.log('[WebSocket] Substitution confirmed') uiStore.showSuccess(data.message, 5000) }) // ======================================== // Data Response Events // ======================================== socketInstance.on('lineup_data', (data) => { console.log('[WebSocket] Lineup data received for team:', data.team_id) gameStore.updateLineup(data.team_id, data.players) }) 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 // ======================================== socketInstance.on('error', (data) => { console.error('[WebSocket] Server error:', data.message) gameStore.setError(data.message) uiStore.showError(data.message, 7000) }) socketInstance.on('outcome_rejected', (data) => { console.error('[WebSocket] Outcome rejected:', data.message) uiStore.showError(`Invalid outcome: ${data.message}`, 7000) }) socketInstance.on('substitution_error', (data) => { console.error('[WebSocket] Substitution error:', data.message) uiStore.showError(`Substitution failed: ${data.message}`, 7000) }) socketInstance.on('invalid_action', (data) => { console.error('[WebSocket] Invalid action:', data.reason) uiStore.showError(`Invalid action: ${data.reason}`, 7000) }) 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 */ let heartbeatInterval: NodeJS.Timeout | null = null function startHeartbeat() { if (heartbeatInterval) return heartbeatInterval = setInterval(() => { if (socketInstance?.connected) { socketInstance.emit('heartbeat') } }, 30000) // Every 30 seconds } function stopHeartbeat() { if (heartbeatInterval) { clearInterval(heartbeatInterval) heartbeatInterval = null } } // ============================================================================ // Watchers // ============================================================================ // Auto-connect when authenticated watch( () => authStore.isAuthenticated, (authenticated) => { if (authenticated && !isConnected.value) { connect() startHeartbeat() } else if (!authenticated && isConnected.value) { disconnect() stopHeartbeat() } }, { immediate: false } ) // ============================================================================ // Lifecycle // ============================================================================ onUnmounted(() => { console.log('[WebSocket] Component unmounted, cleaning up') stopHeartbeat() // 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, } }