/** * WebSocket client for Jackbox Game Picker API: JWT auth, live session stream, reconnect, heartbeat. */ const MAX_RECONNECT_DELAY_MS = 30_000; const HEARTBEAT_INTERVAL_MS = 30_000; const INITIAL_RECONNECT_DELAY_MS = 1_000; /** * @typedef {'connecting'|'connected'|'disconnected'|'error'} WsConnectionState */ export class WebSocketClient { constructor(options = {}) { const { onStatusChange = () => {}, onEvent = () => {}, onSessionSubscribed = () => {}, } = options; /** @type {(state: WsConnectionState, message?: string) => void} */ this._onStatusChange = onStatusChange; /** @type {(eventType: string, data: unknown) => void} */ this._onEvent = onEvent; /** @type {(sessionId: string | number) => void} */ this._onSessionSubscribed = onSessionSubscribed; /** @type {WebSocket | null} */ this._ws = null; /** @type {string | null} */ this._jwtToken = null; /** @type {string | null} */ this._apiUrl = null; /** @type {ReturnType | null} */ this._heartbeatInterval = null; /** @type {ReturnType | null} */ this._reconnectTimeout = null; this._reconnectDelay = INITIAL_RECONNECT_DELAY_MS; this._intentionalDisconnect = false; /** @type {boolean} */ this._authenticated = false; } /** * @param {string} apiUrl * @param {string} apiKey */ async connect(apiUrl, apiKey) { const base = this._normalizeApiUrl(apiUrl); const key = String(apiKey).trim(); if (!base) { this._onStatusChange('error', 'API URL is required'); return; } if (!key) { this._onStatusChange('error', 'API Key is required'); return; } this._intentionalDisconnect = false; this._apiUrl = base; this._onStatusChange('connecting', 'Authenticating...'); try { const response = await fetch(`${base}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }), }); if (!response.ok) { const errData = await response.json().catch(() => ({})); const msg = typeof errData.error === 'string' ? errData.error : response.statusText || String(response.status); this._onStatusChange('error', `Auth failed: ${msg}`); return; } const data = await response.json(); const token = data.token; if (!token) { this._onStatusChange('error', 'No token in auth response'); return; } this._jwtToken = token; this._onStatusChange('connecting', 'Connecting WebSocket...'); this._connectWebSocket(base); } catch (err) { console.error('[WebSocketClient] Auth error:', err); const message = err instanceof Error ? err.message : String(err); this._onStatusChange('error', `Auth error: ${message}`); } } disconnect() { this._intentionalDisconnect = true; if (this._reconnectTimeout) { clearTimeout(this._reconnectTimeout); this._reconnectTimeout = null; } this._stopHeartbeat(); this._authenticated = false; this._jwtToken = null; if (this._ws) { this._ws.close(); this._ws = null; } this._onStatusChange('disconnected', 'Disconnected'); } /** * @param {string | number} sessionId */ subscribeToSession(sessionId) { if (!this._ws || this._ws.readyState !== WebSocket.OPEN) { console.warn('[WebSocketClient] subscribeToSession: socket not open'); return; } this._ws.send( JSON.stringify({ type: 'subscribe', sessionId }), ); } get isConnected() { return ( this._authenticated && this._ws !== null && this._ws.readyState === WebSocket.OPEN ); } /** * @param {string} apiUrl */ _normalizeApiUrl(apiUrl) { return String(apiUrl).trim().replace(/\/+$/, ''); } /** * @param {string} apiUrl */ _connectWebSocket(apiUrl) { const wsUrl = `${apiUrl.replace(/^http/, 'ws')}/api/sessions/live`; try { this._ws = new WebSocket(wsUrl); } catch (err) { const message = err instanceof Error ? err.message : String(err); this._onStatusChange('error', `WebSocket error: ${message}`); return; } this._ws.addEventListener('open', () => { this._authenticated = false; this._onStatusChange('connecting', 'Authenticating via WebSocket...'); this._reconnectDelay = INITIAL_RECONNECT_DELAY_MS; if (!this._jwtToken) { this._onStatusChange('error', 'Missing JWT for WebSocket auth'); return; } this._ws.send(JSON.stringify({ type: 'auth', token: this._jwtToken })); }); this._ws.addEventListener('message', (event) => { let message; try { message = JSON.parse(event.data); } catch (e) { console.error('[WebSocketClient] Failed to parse message:', event.data); return; } this._handleMessage(message); }); this._ws.addEventListener('close', (event) => { console.log('[WebSocketClient] Disconnected, code:', event.code); this._stopHeartbeat(); this._authenticated = false; this._ws = null; if (!this._intentionalDisconnect) { const secs = Math.round(this._reconnectDelay / 1000); this._onStatusChange( 'connecting', `Reconnecting in ${secs}s...`, ); this._scheduleReconnect(); } else { this._onStatusChange('disconnected', 'Disconnected'); } }); this._ws.addEventListener('error', (err) => { console.error('[WebSocketClient] Socket error:', err); }); } /** * @param {Record} message */ _handleMessage(message) { const type = message.type; switch (type) { case 'auth_success': { this._startHeartbeat(); void this._fetchActiveSessionAndSubscribe(); this._authenticated = true; this._onStatusChange('connected', 'Connected'); break; } case 'auth_error': { const msg = typeof message.message === 'string' ? message.message : 'WebSocket authentication failed'; console.error('[WebSocketClient] Auth error:', msg); this._onStatusChange('error', `WS auth failed: ${msg}`); this._intentionalDisconnect = true; if (this._ws) { this._ws.close(); } break; } case 'subscribed': { const sessionId = message.sessionId; const label = sessionId !== undefined && sessionId !== null ? `Connected (session ${sessionId})` : 'Connected (subscribed)'; this._onStatusChange('connected', label); if (sessionId !== undefined && sessionId !== null) { this._onSessionSubscribed(sessionId); } break; } case 'pong': break; case 'error': { const serverMsg = typeof message.message === 'string' ? message.message : JSON.stringify(message); console.error('[WebSocketClient] Server error:', serverMsg); break; } case 'session.started': { const data = message.data; /** @type {{ session?: { id?: string | number } } | undefined} */ let payload; if (data && typeof data === 'object' && data !== null) { payload = /** @type {{ session?: { id?: string | number } }} */ ( data ); } const id = payload?.session?.id; if ( id !== undefined && id !== null && this._ws && this._ws.readyState === WebSocket.OPEN ) { this._ws.send( JSON.stringify({ type: 'subscribe', sessionId: id }), ); } if (data !== undefined) { this._onEvent('session.started', data); } break; } default: { if ( Object.prototype.hasOwnProperty.call(message, 'data') && message.data !== undefined ) { if (typeof type === 'string') { this._onEvent(type, message.data); } } else { console.log('[WebSocketClient] Unhandled message type:', type); } } } } async _fetchActiveSessionAndSubscribe() { const apiUrl = this._apiUrl; const token = this._jwtToken; if (!apiUrl || !token) return; try { const response = await fetch(`${apiUrl}/api/sessions/active`, { headers: { Authorization: `Bearer ${token}` }, }); if (response.ok) { const session = await response.json(); if (session && session.id !== undefined && session.id !== null) { console.log( '[WebSocketClient] Found active session:', session.id, '— subscribing', ); this.subscribeToSession(session.id); } else { console.log( '[WebSocketClient] No active session found; waiting for session.started', ); } } else { console.log( '[WebSocketClient] Could not fetch active session:', response.status, ); } } catch (err) { console.error('[WebSocketClient] Error fetching active session:', err); } } _startHeartbeat() { this._stopHeartbeat(); this._heartbeatInterval = setInterval(() => { if (this._ws && this._ws.readyState === WebSocket.OPEN) { this._ws.send(JSON.stringify({ type: 'ping' })); } }, HEARTBEAT_INTERVAL_MS); } _stopHeartbeat() { if (this._heartbeatInterval) { clearInterval(this._heartbeatInterval); this._heartbeatInterval = null; } } _scheduleReconnect() { if (this._reconnectTimeout) { clearTimeout(this._reconnectTimeout); } const delay = this._reconnectDelay; this._reconnectTimeout = setTimeout(() => { this._reconnectTimeout = null; const apiUrl = this._apiUrl; const token = this._jwtToken; if (apiUrl && token && !this._intentionalDisconnect) { console.log('[WebSocketClient] Attempting reconnect...'); this._onStatusChange('connecting', 'Reconnecting...'); this._connectWebSocket(apiUrl); } this._reconnectDelay = Math.min( this._reconnectDelay * 2, MAX_RECONNECT_DELAY_MS, ); }, delay); } }