strat-gameplay-webapp/frontend-sba/composables/useWebSocket.ts
Cal Corum e0c12467b0 CLAUDE: Improve UX with single-click OAuth, enhanced games list, and layout fix
Frontend UX improvements:
- Single-click Discord OAuth from home page (no intermediate /auth page)
- Auto-redirect authenticated users from home to /games
- Fixed Nuxt layout system - app.vue now wraps NuxtPage with NuxtLayout
- Games page now has proper card container with shadow/border styling
- Layout header includes working logout with API cookie clearing

Games list enhancements:
- Display team names (lname) instead of just team IDs
- Show current score for each team
- Show inning indicator (Top/Bot X) for active games
- Responsive header with wrapped buttons on mobile

Backend improvements:
- Added team caching to SbaApiClient (1-hour TTL)
- Enhanced GameListItem with team names, scores, inning data
- Games endpoint now enriches response with SBA API team data

Docker optimizations:
- Optimized Dockerfile using --chown flag on COPY (faster than chown -R)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 16:14:00 -06:00

765 lines
24 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
// ============================================================================
// 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<ServerToClientEvents, ClientToServerEvents> | null
reconnectionAttempts: number
reconnectionTimeout: ReturnType<typeof setTimeout> | null
connectionTimeoutId: ReturnType<typeof setTimeout> | null
heartbeatInterval: ReturnType<typeof setInterval> | null
stuckStateCheckInterval: ReturnType<typeof setInterval> | 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<string | null>(null)
const lastConnectionAttempt = ref<number | null>(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,
}
}