feat: add reconnection logic with exponential backoff to shard client

This commit is contained in:
cottongin
2026-03-20 11:24:48 -04:00
parent 3f21299720
commit de395d3a28
2 changed files with 148 additions and 8 deletions

View File

@@ -1,5 +1,7 @@
const WebSocket = require('ws');
const { getRoomInfo } = require('./jackbox-api');
class EcastShardClient {
static parsePlayersFromHere(here) {
if (here == null || typeof here !== 'object') {
@@ -85,6 +87,11 @@ class EcastShardClient {
this.manuallyStopped = false;
this.seq = 0;
this.appTag = null;
this.reconnecting = false;
}
buildReconnectUrl() {
return `wss://${this.host}/api/v2/rooms/${this.roomCode}/play?role=shard&name=GamePicker&format=json&secret=${this.secret}&id=${this.shardId}`;
}
handleMessage(message) {
@@ -241,16 +248,27 @@ class EcastShardClient {
handleError(result) {
console.error(`[Shard Monitor] Ecast error ${result?.code}: ${result?.msg}`);
if (result?.code === 2027) {
this.gameFinished = true;
this.onEvent('game.ended', {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
playerCount: this.playerCount,
players: [...this.playerNames],
});
this.onEvent('room.disconnected', {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
reason: 'room_closed',
finalPlayerCount: this.playerCount,
});
this.disconnect();
}
}
async connect(roomInfo) {
this.disconnect();
this.host = roomInfo.host;
this.maxPlayers = roomInfo.maxPlayers || this.maxPlayers;
this.appTag = roomInfo.appTag;
const url = `wss://${this.host}/api/v2/rooms/${this.roomCode}/play?role=shard&name=GamePicker&userId=gamepicker-${this.sessionId}&format=json`;
_openWebSocket(url) {
return new Promise((resolve, reject) => {
let welcomeTimeoutId = null;
@@ -291,6 +309,10 @@ class EcastShardClient {
this.ws.on('close', (code, reason) => {
console.log(`[Shard Monitor] Disconnected from room ${this.roomCode} (code: ${code})`);
this.ws = null;
if (!this.manuallyStopped && !this.gameFinished && this.secret != null && this.host != null) {
void this.reconnectWithBackoff();
}
});
welcomeTimeoutId = setTimeout(() => {
@@ -303,6 +325,82 @@ class EcastShardClient {
});
}
async connect(roomInfo, reconnectUrl) {
this.disconnect();
this.shardId = null;
this.secret = null;
this.host = roomInfo.host;
this.maxPlayers = roomInfo.maxPlayers || this.maxPlayers;
this.appTag = roomInfo.appTag;
const url =
reconnectUrl ||
`wss://${this.host}/api/v2/rooms/${this.roomCode}/play?role=shard&name=GamePicker&userId=gamepicker-${this.sessionId}&format=json`;
return this._openWebSocket(url);
}
async reconnect() {
const url = this.buildReconnectUrl();
this.disconnect();
this.shardId = null;
return this._openWebSocket(url);
}
async reconnectWithBackoff() {
if (this.reconnecting || this.manuallyStopped || this.gameFinished) {
return false;
}
this.reconnecting = true;
const delays = [2000, 4000, 8000];
try {
for (let i = 0; i < delays.length; i++) {
await new Promise((r) => setTimeout(r, delays[i]));
const roomInfo = await getRoomInfo(this.roomCode);
if (!roomInfo.exists) {
this.gameFinished = true;
this.onEvent('game.ended', {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
playerCount: this.playerCount,
players: [...this.playerNames],
});
this.onEvent('room.disconnected', {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
reason: 'room_closed',
finalPlayerCount: this.playerCount,
});
return false;
}
try {
await this.reconnect();
console.log(`[Shard Monitor] Reconnected to room ${this.roomCode} (attempt ${i + 1})`);
return true;
} catch (e) {
console.error(`[Shard Monitor] Reconnect attempt ${i + 1} failed:`, e.message);
}
}
this.onEvent('room.disconnected', {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
reason: 'connection_failed',
finalPlayerCount: this.playerCount,
});
return false;
} finally {
this.reconnecting = false;
}
}
disconnect() {
if (this.ws) {
try {

View File

@@ -354,4 +354,46 @@ describe('EcastShardClient', () => {
});
});
});
describe('buildReconnectUrl', () => {
test('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');
});
});
describe('handleError with code 2027', () => {
test('marks game as finished and emits events on room-closed error', () => {
const events = [];
const client = new EcastShardClient({
sessionId: 1,
gameId: 5,
roomCode: 'TEST',
maxPlayers: 8,
onEvent: (type, data) => events.push({ type, data }),
});
client.playerCount = 4;
client.playerNames = ['A', 'B', 'C', 'D'];
client.handleError({ code: 2027, msg: 'the room has already been closed' });
expect(client.gameFinished).toBe(true);
expect(events.some(e => e.type === 'game.ended')).toBe(true);
expect(events.some(e => e.type === 'room.disconnected' && e.data.reason === 'room_closed')).toBe(true);
});
});
});