feat: extract WebSocket client into ES module

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-20 12:52:59 -04:00
parent 284830a24b
commit 1ed647208e

374
js/websocket-client.js Normal file
View File

@@ -0,0 +1,374 @@
/**
* 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<typeof setInterval> | null} */
this._heartbeatInterval = null;
/** @type {ReturnType<typeof setTimeout> | 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<string, unknown>} 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);
}
}