feat: add Jackbox API Logger CLI tool

Standalone tool that connects to a Jackbox room by code, captures all
websocket events, and logs summarized output to console + full JSON to
a JSONL file. Consolidates scattered scripts/ws-probe.js,
ws-lifecycle-test.js, and get-jackbox-player-count.js into one
cohesive debugging tool.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
cottongin
2026-05-03 00:49:48 -04:00
parent c5ffe23404
commit 195448644a
2 changed files with 493 additions and 0 deletions

492
tools/jackbox-logger.js Normal file
View File

@@ -0,0 +1,492 @@
#!/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 <ROOM_CODE> [options]
${C.bold}Options:${C.reset}
--role <shard|audience|player> Connection role (default: shard)
--name <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);
});