strat-gameplay-webapp/frontend-sba/composables/useWebSocket.ts
Cal Corum ee12f6210e CLAUDE: Fix WebSocket SSR hydration and add connection debugging
Problem: WebSocket wouldn't connect on page load because Pinia auth
state doesn't automatically transfer from SSR to client hydration.
The auth store showed isAuthenticated=false on client mount.

Solution:
- useWebSocket.onMounted now proactively calls checkAuth() if not authenticated
- This ensures auth state is initialized before attempting WebSocket connection
- Added forceReconnect() function to clear stale singleton connections

Debug UI (temporary):
- Added connection status debug info to loading overlay and banner
- Shows Auth/Connected/Connecting/Error states
- Retry button triggers auth check + reconnect

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 23:33:11 -06:00

568 lines
17 KiB
TypeScript

/**
* 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
// Singleton socket instance
let socketInstance: Socket<ServerToClientEvents, ClientToServerEvents> | 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<string | null>(null)
const lastConnectionAttempt = ref<number | null>(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
})
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, {
withCredentials: true, // Send cookies automatically
autoConnect: false,
reconnection: false, // We handle reconnection manually for better control
transports: ['websocket', 'polling'],
})
setupEventListeners()
}
// 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
}
/**
* 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() {
console.log('[WebSocket] Force reconnecting - clearing singleton')
// Clear reconnection timer
if (reconnectionTimeout) {
clearTimeout(reconnectionTimeout)
reconnectionTimeout = null
}
// Fully disconnect and destroy the socket instance
if (socketInstance) {
socketInstance.removeAllListeners()
socketInstance.disconnect()
socketInstance = null
}
// Reset all state
isConnected.value = false
isConnecting.value = false
connectionError.value = null
reconnectionAttempts = 0
// Reconnect after a short delay
setTimeout(() => {
connect()
}, 100)
}
/**
* 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)
// 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
// ========================================
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
// ============================================================================
// Watch for auth changes (not immediate - onMounted handles initial state)
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()
}
}
)
// ============================================================================
// Lifecycle
// ============================================================================
// Initial connection on client-side mount (handles SSR hydration case)
onMounted(async () => {
console.log('[WebSocket] onMounted - isAuthenticated:', authStore.isAuthenticated)
// 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()
// 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,
}
}