Add implementation plan for ecast shard monitor
10-task TDD plan covering: jackbox-api extension, EcastShardClient class, event broadcasting, reconnection, route rewiring, graceful shutdown, old module removal, doc updates, and manual smoke test. Made-with: Cursor
This commit is contained in:
722
docs/plans/2026-03-20-shard-monitor-implementation.md
Normal file
722
docs/plans/2026-03-20-shard-monitor-implementation.md
Normal file
@@ -0,0 +1,722 @@
|
||||
# Ecast Shard Monitor Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Replace the Puppeteer-based audience join and REST-polling room monitor with a single WebSocket shard client that monitors Jackbox rooms in real-time.
|
||||
|
||||
**Architecture:** A new `EcastShardClient` class connects as a shard to the Jackbox ecast server via the `ws` library. One REST call validates the room and gets the `host` field. The shard connection then handles lobby monitoring, player counting, game start/end detection, and reconnection. The module exports `startMonitor`, `stopMonitor`, and `cleanupAllShards` as drop-in replacements for the old two-module API.
|
||||
|
||||
**Tech Stack:** Node.js, `ws` library (already installed), ecast WebSocket protocol (`ecast-v0`), Jest for tests.
|
||||
|
||||
**Design doc:** `docs/plans/2026-03-20-shard-monitor-design.md`
|
||||
|
||||
**Ecast API reference:** `docs/jackbox-ecast-api.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Extend `jackbox-api.js` with `getRoomInfo`
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/utils/jackbox-api.js`
|
||||
- Test: `tests/api/jackbox-api.test.js` (create)
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Create `tests/api/jackbox-api.test.js`:
|
||||
|
||||
```javascript
|
||||
const { getRoomInfo } = require('../../backend/utils/jackbox-api');
|
||||
|
||||
describe('getRoomInfo', () => {
|
||||
test('is exported as a function', () => {
|
||||
expect(typeof getRoomInfo).toBe('function');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/jackbox-api.test.js --verbose --forceExit`
|
||||
Expected: FAIL — `getRoomInfo` is not exported.
|
||||
|
||||
**Step 3: Implement `getRoomInfo`**
|
||||
|
||||
In `backend/utils/jackbox-api.js`, add a new function that calls `GET /api/v2/rooms/{code}` and returns the full room body including `host`, `appTag`, `audienceEnabled`, `maxPlayers`, `locked`, `full`. On failure, return `{ exists: false }`.
|
||||
|
||||
The existing `checkRoomStatus` stays for now (other code may still reference it during migration).
|
||||
|
||||
```javascript
|
||||
async function getRoomInfo(roomCode) {
|
||||
try {
|
||||
const response = await fetch(`${JACKBOX_API_BASE}/rooms/${roomCode}`, {
|
||||
headers: DEFAULT_HEADERS
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const body = data.body || data;
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
host: body.host,
|
||||
audienceHost: body.audienceHost,
|
||||
appTag: body.appTag,
|
||||
appId: body.appId,
|
||||
code: body.code,
|
||||
locked: body.locked || false,
|
||||
full: body.full || false,
|
||||
maxPlayers: body.maxPlayers || 8,
|
||||
minPlayers: body.minPlayers || 0,
|
||||
audienceEnabled: body.audienceEnabled || false,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(`[Jackbox API] Error getting room info for ${roomCode}:`, e.message);
|
||||
return { exists: false };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Export it alongside `checkRoomStatus`:
|
||||
|
||||
```javascript
|
||||
module.exports = { checkRoomStatus, getRoomInfo };
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/jackbox-api.test.js --verbose --forceExit`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/utils/jackbox-api.js tests/api/jackbox-api.test.js
|
||||
git commit -m "feat: add getRoomInfo to jackbox-api for full room data including host"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create `EcastShardClient` — connection and welcome handling
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/utils/ecast-shard-client.js`
|
||||
- Test: `tests/api/ecast-shard-client.test.js` (create)
|
||||
|
||||
This task builds the core class with: constructor, `connect()`, `client/welcome` parsing, `here` map player counting, and the `disconnect()` method. No event broadcasting yet — that's Task 3.
|
||||
|
||||
**Step 1: Write failing tests**
|
||||
|
||||
Create `tests/api/ecast-shard-client.test.js`. Since we can't connect to real Jackbox servers in tests, test the pure logic: `here` map parsing, player counting, entity parsing. Export these as static/utility methods on the class for testability.
|
||||
|
||||
```javascript
|
||||
const { EcastShardClient } = require('../../backend/utils/ecast-shard-client');
|
||||
|
||||
describe('EcastShardClient', () => {
|
||||
describe('parsePlayersFromHere', () => {
|
||||
test('counts only player roles, excludes host and shard', () => {
|
||||
const here = {
|
||||
'1': { id: 1, roles: { host: {} } },
|
||||
'2': { id: 2, roles: { player: { name: 'Alice' } } },
|
||||
'3': { id: 3, roles: { player: { name: 'Bob' } } },
|
||||
'5': { id: 5, roles: { shard: {} } },
|
||||
};
|
||||
const result = EcastShardClient.parsePlayersFromHere(here);
|
||||
expect(result.playerCount).toBe(2);
|
||||
expect(result.playerNames).toEqual(['Alice', 'Bob']);
|
||||
});
|
||||
|
||||
test('returns zero for empty here or host-only', () => {
|
||||
const here = { '1': { id: 1, roles: { host: {} } } };
|
||||
const result = EcastShardClient.parsePlayersFromHere(here);
|
||||
expect(result.playerCount).toBe(0);
|
||||
expect(result.playerNames).toEqual([]);
|
||||
});
|
||||
|
||||
test('handles null or undefined here', () => {
|
||||
expect(EcastShardClient.parsePlayersFromHere(null).playerCount).toBe(0);
|
||||
expect(EcastShardClient.parsePlayersFromHere(undefined).playerCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRoomEntity', () => {
|
||||
test('extracts lobby state from room entity val', () => {
|
||||
const roomVal = {
|
||||
state: 'Lobby',
|
||||
lobbyState: 'CanStart',
|
||||
gameCanStart: true,
|
||||
gameIsStarting: false,
|
||||
gameFinished: false,
|
||||
};
|
||||
const result = EcastShardClient.parseRoomEntity(roomVal);
|
||||
expect(result.gameState).toBe('Lobby');
|
||||
expect(result.lobbyState).toBe('CanStart');
|
||||
expect(result.gameCanStart).toBe(true);
|
||||
expect(result.gameStarted).toBe(false);
|
||||
expect(result.gameFinished).toBe(false);
|
||||
});
|
||||
|
||||
test('detects game started from Gameplay state', () => {
|
||||
const roomVal = { state: 'Gameplay', lobbyState: 'Countdown', gameCanStart: true, gameIsStarting: false, gameFinished: false };
|
||||
const result = EcastShardClient.parseRoomEntity(roomVal);
|
||||
expect(result.gameStarted).toBe(true);
|
||||
});
|
||||
|
||||
test('detects game finished', () => {
|
||||
const roomVal = { state: 'Gameplay', lobbyState: '', gameCanStart: true, gameIsStarting: false, gameFinished: true };
|
||||
const result = EcastShardClient.parseRoomEntity(roomVal);
|
||||
expect(result.gameFinished).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePlayerJoinFromTextDescriptions', () => {
|
||||
test('extracts player name from join description', () => {
|
||||
const val = {
|
||||
latestDescriptions: [
|
||||
{ category: 'TEXT_DESCRIPTION_PLAYER_JOINED', text: 'Charlie joined.' }
|
||||
]
|
||||
};
|
||||
const result = EcastShardClient.parsePlayerJoinFromTextDescriptions(val);
|
||||
expect(result).toEqual([{ name: 'Charlie', isVIP: false }]);
|
||||
});
|
||||
|
||||
test('extracts VIP join', () => {
|
||||
const val = {
|
||||
latestDescriptions: [
|
||||
{ category: 'TEXT_DESCRIPTION_PLAYER_JOINED_VIP', text: 'Alice joined and is the VIP.' }
|
||||
]
|
||||
};
|
||||
const result = EcastShardClient.parsePlayerJoinFromTextDescriptions(val);
|
||||
expect(result).toEqual([{ name: 'Alice', isVIP: true }]);
|
||||
});
|
||||
|
||||
test('returns empty array for no joins', () => {
|
||||
const val = { latestDescriptions: [] };
|
||||
expect(EcastShardClient.parsePlayerJoinFromTextDescriptions(val)).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
|
||||
Expected: FAIL — module does not exist.
|
||||
|
||||
**Step 3: Implement EcastShardClient**
|
||||
|
||||
Create `backend/utils/ecast-shard-client.js` with:
|
||||
|
||||
1. **Static utility methods** (`parsePlayersFromHere`, `parseRoomEntity`, `parsePlayerJoinFromTextDescriptions`) — pure functions, tested above.
|
||||
2. **Constructor** — takes `{ sessionId, gameId, roomCode, maxPlayers }`, initializes internal state.
|
||||
3. **`connect(roomInfo)`** — accepts the result of `getRoomInfo()`. Opens a WebSocket to `wss://{host}/api/v2/rooms/{code}/play?role=shard&name=GamePicker&userId=gamepicker-{sessionId}&format=json` with `Sec-WebSocket-Protocol: ecast-v0` and `Origin: https://jackbox.tv`.
|
||||
4. **`handleMessage(data)`** — dispatcher that routes `client/welcome`, `object`, `error`, `client/connected`, `client/disconnected` to handler methods.
|
||||
5. **`handleWelcome(result)`** — parses `here`, `entities.room`, stores `secret`/`id`.
|
||||
6. **`disconnect()`** — closes the WebSocket gracefully.
|
||||
7. **Internal state:** `playerCount`, `playerNames`, `lobbyState`, `gameState`, `gameStarted`, `gameFinished`, `maxPlayers`, `secret`, `id`, `ws`.
|
||||
|
||||
Do NOT add broadcasting or reconnection yet — those are Tasks 3 and 4.
|
||||
|
||||
Key implementation details for the WebSocket connection:
|
||||
|
||||
```javascript
|
||||
const WebSocket = require('ws');
|
||||
|
||||
// In connect():
|
||||
this.ws = new WebSocket(url, ['ecast-v0'], {
|
||||
headers: { 'Origin': 'https://jackbox.tv' },
|
||||
handshakeTimeout: 10000,
|
||||
});
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
|
||||
Expected: PASS (all 8 tests)
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/utils/ecast-shard-client.js tests/api/ecast-shard-client.test.js
|
||||
git commit -m "feat: add EcastShardClient with connection, welcome parsing, and player counting"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add event broadcasting and entity update handling
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/utils/ecast-shard-client.js`
|
||||
- Modify: `tests/api/ecast-shard-client.test.js`
|
||||
|
||||
This task wires up the WebSocket message handlers to broadcast events via `WebSocketManager` and update the `session_games` DB row.
|
||||
|
||||
**Step 1: Write failing tests for entity update handlers**
|
||||
|
||||
Add tests to `tests/api/ecast-shard-client.test.js`:
|
||||
|
||||
```javascript
|
||||
describe('handleRoomUpdate', () => {
|
||||
test('detects game start transition', () => {
|
||||
// Create client instance, set initial state to Lobby
|
||||
// Call handleRoomUpdate with Gameplay state
|
||||
// Verify gameStarted flipped and handler would broadcast
|
||||
});
|
||||
|
||||
test('detects game end transition', () => {
|
||||
// Create client, set gameStarted = true
|
||||
// Call handleRoomUpdate with gameFinished: true
|
||||
// Verify gameFinished flipped
|
||||
});
|
||||
|
||||
test('detects lobby state change', () => {
|
||||
// Create client, set lobbyState to WaitingForMore
|
||||
// Call handleRoomUpdate with CanStart
|
||||
// Verify lobbyState updated
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Since broadcasting and DB writes involve external dependencies, use a test approach where the client accepts a `broadcaster` callback in its constructor options. The callback receives `(eventType, data)`. This makes the class testable without mocking the WebSocketManager singleton.
|
||||
|
||||
Constructor signature becomes:
|
||||
|
||||
```javascript
|
||||
constructor({ sessionId, gameId, roomCode, maxPlayers, onEvent })
|
||||
```
|
||||
|
||||
Where `onEvent` is `(eventType, eventData) => void`. The module-level `startMonitor` function provides a default `onEvent` that calls `wsManager.broadcastEvent(...)` and writes to the DB.
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
|
||||
Expected: FAIL on new tests.
|
||||
|
||||
**Step 3: Implement entity update handlers**
|
||||
|
||||
Add to `EcastShardClient`:
|
||||
|
||||
- **`handleRoomUpdate(roomVal)`** — called when an `object` message arrives with `key: "room"` (or `key: "bc:room"` for some games). Compares new state against stored state. Broadcasts:
|
||||
- `lobby.updated` if `lobbyState` changed
|
||||
- `game.started` if `state` changed to `"Gameplay"` and `gameStarted` was false
|
||||
- `game.ended` if `gameFinished` changed to true
|
||||
- Updates `playerCount` in DB via `updatePlayerCount()` on game start and end.
|
||||
|
||||
- **`handleTextDescriptionsUpdate(val)`** — called when `object` with `key: "textDescriptions"` arrives. Uses `parsePlayerJoinFromTextDescriptions` to detect joins. Broadcasts `lobby.player-joined` for each new join. Updates internal `playerNames` list.
|
||||
|
||||
- **`handleClientConnected(result)`** — if shards receive `client/connected`, update internal `here` tracking and recount players. Broadcast `lobby.player-joined` if the new connection is a player.
|
||||
|
||||
- **`updatePlayerCount(count, status)`** — writes to `session_games` and calls `this.onEvent('player-count.updated', ...)` for DB-triggered updates.
|
||||
|
||||
Add the module-level `startMonitor` function:
|
||||
|
||||
```javascript
|
||||
async function startMonitor(sessionId, gameId, roomCode, maxPlayers = 8) {
|
||||
const monitorKey = `${sessionId}-${gameId}`;
|
||||
if (activeShards.has(monitorKey)) return;
|
||||
|
||||
const roomInfo = await getRoomInfo(roomCode);
|
||||
if (!roomInfo.exists) {
|
||||
// set failed status in DB, broadcast room.disconnected
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new EcastShardClient({
|
||||
sessionId, gameId, roomCode,
|
||||
maxPlayers: roomInfo.maxPlayers || maxPlayers,
|
||||
onEvent: (type, data) => {
|
||||
const wsManager = getWebSocketManager();
|
||||
if (wsManager) wsManager.broadcastEvent(type, data, parseInt(sessionId));
|
||||
}
|
||||
});
|
||||
|
||||
activeShards.set(monitorKey, client);
|
||||
await client.connect(roomInfo);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/utils/ecast-shard-client.js tests/api/ecast-shard-client.test.js
|
||||
git commit -m "feat: add event broadcasting and entity update handlers to shard client"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add reconnection logic
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/utils/ecast-shard-client.js`
|
||||
- Modify: `tests/api/ecast-shard-client.test.js`
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
```javascript
|
||||
describe('reconnection state machine', () => {
|
||||
test('buildReconnectUrl uses stored secret and id', () => {
|
||||
const client = new EcastShardClient({
|
||||
sessionId: 1, gameId: 1, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {}
|
||||
});
|
||||
client.secret = 'abc-123';
|
||||
client.shardId = 5;
|
||||
client.host = 'ecast-prod-use2.jackboxgames.com';
|
||||
|
||||
const url = client.buildReconnectUrl();
|
||||
expect(url).toContain('secret=abc-123');
|
||||
expect(url).toContain('id=5');
|
||||
expect(url).toContain('role=shard');
|
||||
expect(url).toContain('ecast-prod-use2.jackboxgames.com');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
|
||||
Expected: FAIL — `buildReconnectUrl` doesn't exist.
|
||||
|
||||
**Step 3: Implement reconnection**
|
||||
|
||||
Add to `EcastShardClient`:
|
||||
|
||||
- **`handleClose(code, reason)`** — called on WebSocket `close` event. If `gameFinished` or `manuallyStopped`, do nothing. Otherwise, call `attemptReconnect()`.
|
||||
- **`attemptReconnect()`** — calls `getRoomInfo(roomCode)`. If room gone, finalize. If room exists, try `reconnectWithBackoff()`.
|
||||
- **`reconnectWithBackoff()`** — attempts up to 3 reconnections with 2s/4s/8s delays. Uses `buildReconnectUrl()` with stored `secret`/`id`. On success, resumes message handling transparently. On failure, set status `'failed'`, broadcast `room.disconnected`.
|
||||
- **`buildReconnectUrl()`** — constructs `wss://{host}/api/v2/rooms/{code}/play?role=shard&name=GamePicker&format=json&secret={secret}&id={id}`.
|
||||
- **`handleError(err)`** — logs the error, defers to `handleClose` for reconnection decisions.
|
||||
|
||||
Also handle ecast error opcode 2027 ("room already closed") in `handleMessage` — treat as game-ended.
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/utils/ecast-shard-client.js tests/api/ecast-shard-client.test.js
|
||||
git commit -m "feat: add reconnection logic with exponential backoff to shard client"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add module exports (`startMonitor`, `stopMonitor`, `cleanupAllShards`)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/utils/ecast-shard-client.js`
|
||||
- Modify: `tests/api/ecast-shard-client.test.js`
|
||||
|
||||
**Step 1: Write failing tests**
|
||||
|
||||
```javascript
|
||||
const { startMonitor, stopMonitor, cleanupAllShards } = require('../../backend/utils/ecast-shard-client');
|
||||
|
||||
describe('module exports', () => {
|
||||
test('startMonitor is exported', () => {
|
||||
expect(typeof startMonitor).toBe('function');
|
||||
});
|
||||
|
||||
test('stopMonitor is exported', () => {
|
||||
expect(typeof stopMonitor).toBe('function');
|
||||
});
|
||||
|
||||
test('cleanupAllShards is exported', () => {
|
||||
expect(typeof cleanupAllShards).toBe('function');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
|
||||
Expected: FAIL if not yet exported.
|
||||
|
||||
**Step 3: Finalize module exports**
|
||||
|
||||
Ensure these are all exported from `backend/utils/ecast-shard-client.js`:
|
||||
|
||||
```javascript
|
||||
const activeShards = new Map();
|
||||
|
||||
async function startMonitor(sessionId, gameId, roomCode, maxPlayers = 8) {
|
||||
// ... (implemented in Task 3)
|
||||
}
|
||||
|
||||
async function stopMonitor(sessionId, gameId) {
|
||||
const monitorKey = `${sessionId}-${gameId}`;
|
||||
const client = activeShards.get(monitorKey);
|
||||
if (client) {
|
||||
client.manuallyStopped = true;
|
||||
client.disconnect();
|
||||
activeShards.delete(monitorKey);
|
||||
|
||||
// Update DB status unless already completed
|
||||
const game = db.prepare(
|
||||
'SELECT player_count_check_status FROM session_games WHERE session_id = ? AND id = ?'
|
||||
).get(sessionId, gameId);
|
||||
|
||||
if (game && game.player_count_check_status !== 'completed' && game.player_count_check_status !== 'failed') {
|
||||
db.prepare(
|
||||
'UPDATE session_games SET player_count_check_status = ? WHERE session_id = ? AND id = ?'
|
||||
).run('stopped', sessionId, gameId);
|
||||
}
|
||||
|
||||
client.onEvent('room.disconnected', {
|
||||
sessionId, gameId, roomCode: client.roomCode,
|
||||
reason: 'manually_stopped',
|
||||
finalPlayerCount: client.playerCount
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupAllShards() {
|
||||
for (const [, client] of activeShards) {
|
||||
client.manuallyStopped = true;
|
||||
client.disconnect();
|
||||
}
|
||||
activeShards.clear();
|
||||
console.log('[Shard Monitor] Cleaned up all active shards');
|
||||
}
|
||||
|
||||
module.exports = { EcastShardClient, startMonitor, stopMonitor, cleanupAllShards };
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/utils/ecast-shard-client.js tests/api/ecast-shard-client.test.js
|
||||
git commit -m "feat: add startMonitor, stopMonitor, cleanupAllShards module exports"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Rewire `sessions.js` routes
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/routes/sessions.js` (lines 7–8 imports, lines 394–401, 617–624, 638–644, 844–875, 877–893)
|
||||
- Test: `tests/api/regression-sessions.test.js` (verify existing tests still pass)
|
||||
|
||||
**Step 1: Run existing session tests as baseline**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/regression-sessions.test.js --verbose --forceExit`
|
||||
Expected: PASS (capture baseline)
|
||||
|
||||
**Step 2: Replace imports**
|
||||
|
||||
In `backend/routes/sessions.js`, replace lines 7–8:
|
||||
|
||||
```javascript
|
||||
// Old
|
||||
const { stopPlayerCountCheck } = require('../utils/player-count-checker');
|
||||
const { startRoomMonitor, stopRoomMonitor } = require('../utils/room-monitor');
|
||||
|
||||
// New
|
||||
const { startMonitor, stopMonitor } = require('../utils/ecast-shard-client');
|
||||
```
|
||||
|
||||
**Step 3: Replace call sites**
|
||||
|
||||
Search and replace across the file. There are 5 call sites:
|
||||
|
||||
1. **Line ~397** (`POST /:id/games`): `startRoomMonitor(req.params.id, result.lastInsertRowid, room_code, game.max_players)` → `startMonitor(req.params.id, result.lastInsertRowid, room_code, game.max_players)`
|
||||
|
||||
2. **Lines ~620–621** (`PATCH .../status`): Replace both `stopRoomMonitor(sessionId, gameId)` and `stopPlayerCountCheck(sessionId, gameId)` with single `stopMonitor(sessionId, gameId)`
|
||||
|
||||
3. **Lines ~640–641** (`DELETE .../games/:gameId`): Same — replace two stop calls with single `stopMonitor(sessionId, gameId)`
|
||||
|
||||
4. **Line ~866** (`POST .../start-player-check`): `startRoomMonitor(...)` → `startMonitor(...)`
|
||||
|
||||
5. **Lines ~883–884** (`POST .../stop-player-check`): Replace two stop calls with single `stopMonitor(sessionId, gameId)`
|
||||
|
||||
**Step 4: Run tests to verify nothing broke**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/regression-sessions.test.js --verbose --forceExit`
|
||||
Expected: PASS (same as baseline — these tests don't exercise actual Jackbox connections)
|
||||
|
||||
Also run the full test suite:
|
||||
|
||||
Run: `cd backend && npx jest --config ../jest.config.js --runInBand --verbose --forceExit`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/routes/sessions.js
|
||||
git commit -m "refactor: rewire sessions routes to use ecast shard client"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Wire graceful shutdown in `server.js`
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/server.js`
|
||||
|
||||
**Step 1: Add shutdown handler**
|
||||
|
||||
In `backend/server.js`, import `cleanupAllShards` and add signal handlers inside the `if (require.main === module)` block:
|
||||
|
||||
```javascript
|
||||
const { cleanupAllShards } = require('./utils/ecast-shard-client');
|
||||
|
||||
// Inside the if (require.main === module) block, after server.listen:
|
||||
const shutdown = async () => {
|
||||
console.log('Shutting down gracefully...');
|
||||
await cleanupAllShards();
|
||||
server.close(() => process.exit(0));
|
||||
};
|
||||
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
```
|
||||
|
||||
**Step 2: Verify server still starts**
|
||||
|
||||
Run: `cd backend && timeout 5 node server.js || true`
|
||||
Expected: Server starts, prints port message, exits on timeout.
|
||||
|
||||
**Step 3: Run full test suite**
|
||||
|
||||
Run: `cd backend && npx jest --config ../jest.config.js --runInBand --verbose --forceExit`
|
||||
Expected: PASS
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/server.js
|
||||
git commit -m "feat: wire graceful shutdown for shard connections on SIGTERM/SIGINT"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Delete old files and remove Puppeteer dependency
|
||||
|
||||
**Files:**
|
||||
- Delete: `backend/utils/player-count-checker.js`
|
||||
- Delete: `backend/utils/room-monitor.js`
|
||||
- Modify: `backend/package.json` (remove `puppeteer` from dependencies)
|
||||
|
||||
**Step 1: Verify no remaining imports of old modules**
|
||||
|
||||
Search the codebase for any remaining `require('./player-count-checker')`, `require('./room-monitor')`, `require('../utils/player-count-checker')`, `require('../utils/room-monitor')`. After Task 6, `sessions.js` should be the only file that imported them and it now imports from `ecast-shard-client`. The old `room-monitor.js` had a lazy require of `player-count-checker` which is going away with it.
|
||||
|
||||
If any other files reference these modules, update them first.
|
||||
|
||||
**Step 2: Delete the files**
|
||||
|
||||
```bash
|
||||
rm backend/utils/player-count-checker.js backend/utils/room-monitor.js
|
||||
```
|
||||
|
||||
**Step 3: Remove Puppeteer dependency**
|
||||
|
||||
```bash
|
||||
cd backend && npm uninstall puppeteer
|
||||
```
|
||||
|
||||
**Step 4: Run full test suite**
|
||||
|
||||
Run: `cd backend && npx jest --config ../jest.config.js --runInBand --verbose --forceExit`
|
||||
Expected: PASS — no test should depend on the deleted files.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: remove Puppeteer and old room-monitor/player-count-checker modules"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Update WebSocket documentation
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/api/websocket.md`
|
||||
|
||||
**Step 1: Read current websocket.md**
|
||||
|
||||
Read `docs/api/websocket.md` and identify the server-to-client event table.
|
||||
|
||||
**Step 2: Update the event table**
|
||||
|
||||
Replace the old events with the new contract:
|
||||
|
||||
| Event | Description |
|
||||
|-------|-------------|
|
||||
| `room.connected` | Shard connected to Jackbox room (replaces `audience.joined`) |
|
||||
| `lobby.player-joined` | A player joined the lobby |
|
||||
| `lobby.updated` | Lobby state changed |
|
||||
| `game.started` | Game transitioned to Gameplay |
|
||||
| `game.ended` | Game finished |
|
||||
| `room.disconnected` | Shard lost connection to room |
|
||||
| `game.added` | New game added to session (unchanged) |
|
||||
| `session.started` | Session created (unchanged) |
|
||||
| `session.ended` | Session closed (unchanged) |
|
||||
| `vote.received` | Vote recorded (unchanged) |
|
||||
| `player-count.updated` | Manual player count override (unchanged) |
|
||||
|
||||
Add payload examples for each new event (from design doc).
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/api/websocket.md
|
||||
git commit -m "docs: update websocket event reference with new shard monitor events"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Smoke test with a real Jackbox room (manual)
|
||||
|
||||
This task is manual verification — not automated.
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Start the backend: `cd backend && npm run dev`
|
||||
2. Create a session via API, add a game with a room code from an active Jackbox game
|
||||
3. Watch backend logs for `[Shard Monitor]` messages:
|
||||
- REST room info fetched
|
||||
- WebSocket connected as shard
|
||||
- `client/welcome` parsed, player count reported
|
||||
- Player join detected when someone joins the lobby
|
||||
- Game start detected when the game begins
|
||||
- Game end detected when the game finishes
|
||||
4. Connect a WebSocket client to `/api/sessions/live`, authenticate, subscribe to the session, and verify events arrive:
|
||||
- `room.connected`
|
||||
- `lobby.player-joined`
|
||||
- `game.started`
|
||||
- `game.ended`
|
||||
- `room.disconnected`
|
||||
5. Test `stop-player-check` endpoint — verify shard disconnects cleanly
|
||||
6. Test reconnection — kill and restart the backend mid-game, call `start-player-check` again
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Task | Description | Files |
|
||||
|------|-------------|-------|
|
||||
| 1 | `getRoomInfo` in jackbox-api | `jackbox-api.js`, test |
|
||||
| 2 | `EcastShardClient` core + parsing | `ecast-shard-client.js`, test |
|
||||
| 3 | Event broadcasting + entity handlers | `ecast-shard-client.js`, test |
|
||||
| 4 | Reconnection logic | `ecast-shard-client.js`, test |
|
||||
| 5 | Module exports | `ecast-shard-client.js`, test |
|
||||
| 6 | Rewire sessions routes | `sessions.js` |
|
||||
| 7 | Graceful shutdown | `server.js` |
|
||||
| 8 | Delete old files + remove Puppeteer | `player-count-checker.js`, `room-monitor.js`, `package.json` |
|
||||
| 9 | Update docs | `websocket.md` |
|
||||
| 10 | Manual smoke test | — |
|
||||
Reference in New Issue
Block a user