#!/usr/bin/env node const https = require('https'); const fs = require('fs'); const path = require('path'); let WebSocket; try { WebSocket = require('ws'); } catch (_) { try { WebSocket = require('../backend/node_modules/ws'); } catch (_2) { console.error('Error: WebSocket library (ws) not found.'); console.error('Run: cd backend && npm install'); process.exit(1); } } // --------------------------------------------------------------------------- // ANSI helpers // --------------------------------------------------------------------------- const C = { reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', magenta: '\x1b[35m', cyan: '\x1b[36m', white: '\x1b[37m', }; // --------------------------------------------------------------------------- // CLI argument parsing // --------------------------------------------------------------------------- function parseArgs() { const args = process.argv.slice(2); if (args.includes('--help') || args.includes('-h') || args.length === 0) { console.log(` ${C.bold}Jackbox API Logger${C.reset} Connects to a Jackbox game room and logs all WebSocket events. ${C.bold}Usage:${C.reset} node tools/jackbox-logger.js [options] ${C.bold}Options:${C.reset} --role Connection role (default: shard) --name Display name (default: JBLogger) --no-file Skip writing to log file --verbose Print full JSON to console --help, -h Show this help ${C.bold}Examples:${C.reset} node tools/jackbox-logger.js ABCD node tools/jackbox-logger.js ABCD --role audience node tools/jackbox-logger.js ABCD --verbose --no-file `); process.exit(0); } const opts = { roomCode: null, role: 'shard', name: 'JBLogger', noFile: false, verbose: false, }; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === '--role') { opts.role = args[++i]; } else if (arg === '--name') { opts.name = args[++i]; } else if (arg === '--no-file') { opts.noFile = true; } else if (arg === '--verbose') { opts.verbose = true; } else if (!arg.startsWith('-') && !opts.roomCode) { opts.roomCode = arg.toUpperCase(); } } if (!opts.roomCode) { console.error(`${C.red}Error: room code is required${C.reset}`); process.exit(1); } if (!['shard', 'audience', 'player'].includes(opts.role)) { console.error(`${C.red}Error: --role must be shard, audience, or player${C.reset}`); process.exit(1); } return opts; } // --------------------------------------------------------------------------- // REST: fetch room info // --------------------------------------------------------------------------- function getRoomInfo(roomCode) { return new Promise((resolve, reject) => { https.get(`https://ecast.jackboxgames.com/api/v2/rooms/${roomCode}`, (res) => { let data = ''; res.on('data', (chunk) => (data += chunk)); res.on('end', () => { try { const json = JSON.parse(data); if (json.ok) resolve(json.body); else reject(new Error(json.error || 'Room not found')); } catch (e) { reject(e); } }); }).on('error', reject); }); } // --------------------------------------------------------------------------- // Timestamp // --------------------------------------------------------------------------- function ts() { return new Date().toISOString().slice(11, 23); } // --------------------------------------------------------------------------- // Console summarizer — merges patterns from ws-probe.js and ws-lifecycle-test.js // --------------------------------------------------------------------------- function summarize(msg) { const r = msg.result || {}; switch (msg.opcode) { case 'client/welcome': { const hereEntries = r.here ? Object.entries(r.here) : []; const players = hereEntries .filter(([, v]) => v.roles?.player) .map(([id, v]) => `${v.roles.player.name}(${id})`); const entityKeys = r.entities ? Object.keys(r.entities) : []; return ( `id=${r.id} reconnect=${r.reconnect} secret=${r.secret}\n` + ` here: ${hereEntries.length} connections [${players.join(', ') || 'no players'}]\n` + ` entities: [${entityKeys.join(', ')}]` ); } case 'object': { if (r.key === 'room' || r.key === 'bc:room') { const v = r.val || {}; return `ROOM state=${v.state} lobby=${v.lobbyState} canStart=${v.gameCanStart} starting=${v.gameIsStarting} finished=${v.gameFinished} v${r.version}`; } if (r.key === 'textDescriptions') { const latest = r.val?.latestDescriptions; if (Array.isArray(latest) && latest.length > 0) { const last = latest[latest.length - 1]; return `TEXT "${last.text}" (${last.category}) v${r.version}`; } return `textDescriptions v${r.version}`; } if (r.key?.startsWith('player:')) { const v = r.val || {}; return `PLAYER ${r.key} state=${v.state || 'init'} name=${v.playerName || '?'} vip=${v.playerIsVIP} v${r.version}`; } const valKeys = r.val ? Object.keys(r.val).slice(0, 5).join(',') : 'null'; return `ENTITY ${r.key} v${r.version} from=${r.from} val=[${valKeys}...]`; } case 'client/connected': { const roleName = r.roles ? Object.keys(r.roles)[0] : r.role; const playerName = r.roles?.player?.name || r.name || ''; return `id=${r.id} userId=${r.userId} role=${roleName} name=${playerName}`; } case 'client/disconnected': { const roleName = r.roles ? Object.keys(r.roles)[0] : r.role; return `id=${r.id} role=${roleName}`; } case 'client/kicked': return JSON.stringify(r).slice(0, 200); case 'room/lock': return 'room locked (game starting)'; case 'room/exit': return 'room closed'; case 'room/get-audience': return `audience connections=${r.connections}`; case 'error': return `code=${r.code}: ${r.msg}`; case 'ok': return `seq response`; default: return JSON.stringify(r).slice(0, 200); } } function opcodeColor(opcode) { if (opcode === 'client/welcome') return C.green; if (opcode === 'error') return C.red; if (opcode === 'client/connected') return C.cyan; if (opcode === 'client/disconnected') return C.yellow; if (opcode === 'room/lock' || opcode === 'room/exit') return C.magenta; if (opcode === 'object') return C.white; return C.dim; } // --------------------------------------------------------------------------- // File logger // --------------------------------------------------------------------------- class FileLogger { constructor(roomCode) { const logsDir = path.join(__dirname, '..', 'logs'); fs.mkdirSync(logsDir, { recursive: true }); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); this.filePath = path.join(logsDir, `jackbox-${roomCode}-${timestamp}.jsonl`); this.stream = fs.createWriteStream(this.filePath, { flags: 'a' }); } write(entry) { this.stream.write(JSON.stringify(entry) + '\n'); } close() { this.stream.end(); } } // --------------------------------------------------------------------------- // Main logger // --------------------------------------------------------------------------- async function main() { const opts = parseArgs(); const { roomCode, role, name, noFile, verbose } = opts; console.log(`${C.bold}Jackbox API Logger${C.reset}`); console.log(`${C.cyan}Room:${C.reset} ${roomCode} ${C.cyan}Role:${C.reset} ${role} ${C.cyan}Name:${C.reset} ${name}`); console.log(); // Fetch room info console.log(`${C.dim}[${ts()}]${C.reset} Fetching room info...`); let roomInfo; try { roomInfo = await getRoomInfo(roomCode); } catch (e) { console.error(`${C.red}Failed to fetch room info: ${e.message}${C.reset}`); process.exit(1); } console.log(`${C.dim}[${ts()}]${C.reset} ${C.green}Room found${C.reset}`); console.log(` ${C.cyan}Game:${C.reset} ${roomInfo.appTag}`); console.log(` ${C.cyan}Host:${C.reset} ${roomInfo.host}`); console.log(` ${C.cyan}Players:${C.reset} max ${roomInfo.maxPlayers}`); console.log(` ${C.cyan}Locked:${C.reset} ${roomInfo.locked}`); console.log(` ${C.cyan}Full:${C.reset} ${roomInfo.full}`); console.log(` ${C.cyan}Audience:${C.reset} ${roomInfo.audienceEnabled ? 'enabled' : 'disabled'}`); console.log(); // File logger let fileLogger = null; if (!noFile) { fileLogger = new FileLogger(roomCode); console.log(`${C.dim}[${ts()}]${C.reset} Logging to ${C.bold}${fileLogger.filePath}${C.reset}`); fileLogger.write({ ts: new Date().toISOString(), direction: 'meta', type: 'session_start', roomCode, role, name, host: roomInfo.host, appTag: roomInfo.appTag, maxPlayers: roomInfo.maxPlayers, locked: roomInfo.locked, full: roomInfo.full, audienceEnabled: roomInfo.audienceEnabled, }); } // State for reconnection let shardId = null; let secret = null; let msgCount = 0; let reconnecting = false; let manuallyStopped = false; const startTime = Date.now(); function buildWsUrl(reconnect) { if (role === 'audience') { return `wss://${roomInfo.audienceHost || roomInfo.host}/api/v2/audience/${roomCode}/play`; } const base = `wss://${roomInfo.host}/api/v2/rooms/${roomCode}/play`; if (reconnect && secret && shardId) { return `${base}?role=${role}&name=${encodeURIComponent(name)}&format=json&secret=${secret}&id=${shardId}`; } return `${base}?role=${role}&name=${encodeURIComponent(name)}&userId=${name}-${Date.now()}&format=json`; } function connect(isReconnect) { const url = buildWsUrl(isReconnect); console.log(`${C.dim}[${ts()}]${C.reset} ${isReconnect ? 'Reconnecting' : 'Connecting'}: ${C.dim}${url}${C.reset}`); const ws = new WebSocket(url, ['ecast-v0'], { headers: { Origin: 'https://jackbox.tv', 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', }, handshakeTimeout: 10000, }); ws.on('open', () => { console.log(`${C.dim}[${ts()}]${C.reset} ${C.green}${C.bold}CONNECTED${C.reset}`); console.log(); }); ws.on('message', (raw) => { msgCount++; try { const msg = JSON.parse(raw.toString()); // Capture credentials from welcome for reconnection if (msg.opcode === 'client/welcome' && msg.result) { shardId = msg.result.id; secret = msg.result.secret; } // Console output const color = opcodeColor(msg.opcode); const summary = summarize(msg); console.log( `${C.dim}[${ts()}]${C.reset} ${C.dim}#${msgCount} pc:${msg.pc ?? '-'}${C.reset} ${color}${C.bold}${msg.opcode}${C.reset} ${summary}` ); if (verbose) { console.log(JSON.stringify(msg, null, 2)); } // File output if (fileLogger) { fileLogger.write({ ts: new Date().toISOString(), direction: 'recv', msgNum: msgCount, pc: msg.pc, opcode: msg.opcode, raw: msg, }); } } catch (e) { console.log(`${C.dim}[${ts()}]${C.reset} ${C.yellow}UNPARSEABLE${C.reset} ${raw.toString().slice(0, 200)}`); if (fileLogger) { fileLogger.write({ ts: new Date().toISOString(), direction: 'recv', msgNum: msgCount, parseError: true, raw: raw.toString().slice(0, 2000), }); } } }); ws.on('close', (code, reason) => { console.log(`${C.dim}[${ts()}]${C.reset} ${C.yellow}DISCONNECTED${C.reset} code=${code} reason=${reason}`); if (fileLogger) { fileLogger.write({ ts: new Date().toISOString(), direction: 'meta', type: 'disconnected', code, reason: reason?.toString(), }); } if (!manuallyStopped && secret != null) { reconnectWithBackoff(ws); } }); ws.on('error', (err) => { console.error(`${C.dim}[${ts()}]${C.reset} ${C.red}WS ERROR: ${err.message}${C.reset}`); if (fileLogger) { fileLogger.write({ ts: new Date().toISOString(), direction: 'meta', type: 'error', message: err.message, }); } }); // SIGINT handler const onSigint = () => { manuallyStopped = true; const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); console.log(); console.log(`${C.bold}--- Session Summary ---${C.reset}`); console.log(` ${C.cyan}Messages:${C.reset} ${msgCount}`); console.log(` ${C.cyan}Duration:${C.reset} ${elapsed}s`); if (fileLogger) { console.log(` ${C.cyan}Log file:${C.reset} ${fileLogger.filePath}`); fileLogger.write({ ts: new Date().toISOString(), direction: 'meta', type: 'session_end', msgCount, durationMs: Date.now() - startTime, }); fileLogger.close(); } console.log(); try { ws.close(1000, 'Logger stopped'); } catch (_) {} setTimeout(() => process.exit(0), 500); }; process.removeAllListeners('SIGINT'); process.on('SIGINT', onSigint); return ws; } async function reconnectWithBackoff() { if (reconnecting || manuallyStopped) return; reconnecting = true; const delays = [2000, 4000, 8000]; for (let i = 0; i < delays.length; i++) { console.log(`${C.dim}[${ts()}]${C.reset} ${C.yellow}Reconnect attempt ${i + 1}/${delays.length} in ${delays[i] / 1000}s...${C.reset}`); await new Promise((r) => setTimeout(r, delays[i])); if (manuallyStopped) { reconnecting = false; return; } try { const freshRoom = await getRoomInfo(roomCode); if (!freshRoom) { console.log(`${C.dim}[${ts()}]${C.reset} ${C.red}Room no longer exists${C.reset}`); reconnecting = false; shutdown(); return; } roomInfo.host = freshRoom.host; connect(true); reconnecting = false; return; } catch (e) { console.error(`${C.dim}[${ts()}]${C.reset} ${C.red}Reconnect attempt ${i + 1} failed: ${e.message}${C.reset}`); } } console.error(`${C.dim}[${ts()}]${C.reset} ${C.red}${C.bold}All reconnect attempts failed. Exiting.${C.reset}`); reconnecting = false; shutdown(); } function shutdown() { const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); console.log(); console.log(`${C.bold}--- Session Summary ---${C.reset}`); console.log(` ${C.cyan}Messages:${C.reset} ${msgCount}`); console.log(` ${C.cyan}Duration:${C.reset} ${elapsed}s`); if (fileLogger) { console.log(` ${C.cyan}Log file:${C.reset} ${fileLogger.filePath}`); fileLogger.write({ ts: new Date().toISOString(), direction: 'meta', type: 'session_end', msgCount, durationMs: Date.now() - startTime, }); fileLogger.close(); } console.log(); process.exit(1); } connect(false); } main().catch((e) => { console.error(`${C.red}Fatal: ${e.message}${C.reset}`); process.exit(1); });