171 lines
5.2 KiB
JavaScript
171 lines
5.2 KiB
JavaScript
class DashboardWebSocket {
|
|
constructor({ onGamesUpdate, onShutdown } = {}) {
|
|
this._socket = null;
|
|
this._reconnectTimer = null;
|
|
this._shuttingDown = false;
|
|
this._pendingRequests = new Map();
|
|
this._requestSeq = 0;
|
|
this._onGamesUpdate = onGamesUpdate || (() => {});
|
|
this._onShutdown = onShutdown || (() => {});
|
|
}
|
|
|
|
get isShuttingDown() { return this._shuttingDown; }
|
|
|
|
connect() {
|
|
if (this._shuttingDown) return;
|
|
if (this._socket && (
|
|
this._socket.readyState === WebSocket.OPEN ||
|
|
this._socket.readyState === WebSocket.CONNECTING
|
|
)) return;
|
|
|
|
const wsUrl = this._buildUrl();
|
|
try {
|
|
this._socket = new WebSocket(wsUrl);
|
|
} catch {
|
|
this._scheduleReconnect();
|
|
return;
|
|
}
|
|
|
|
this._socket.addEventListener("message", (event) => {
|
|
let payload = null;
|
|
try { payload = JSON.parse(event.data); } catch { return; }
|
|
if (!payload || !payload.type) return;
|
|
|
|
if (payload.type === "dashboard_ws_shutdown") {
|
|
this._shuttingDown = true;
|
|
if (this._reconnectTimer) {
|
|
clearTimeout(this._reconnectTimer);
|
|
this._reconnectTimer = null;
|
|
}
|
|
this._rejectAll("Server shutting down");
|
|
if (this._socket) this._socket.close();
|
|
this._onShutdown();
|
|
return;
|
|
}
|
|
|
|
if (payload.type === "dashboard_game_replay") {
|
|
const requestId = String(payload.request_id || "");
|
|
if (!requestId) return;
|
|
const pending = this._pendingRequests.get(requestId);
|
|
if (!pending) return;
|
|
this._pendingRequests.delete(requestId);
|
|
window.clearTimeout(pending.timeoutId);
|
|
if (payload.error) {
|
|
pending.reject(new Error(String(payload.error)));
|
|
return;
|
|
}
|
|
pending.resolve(payload.replay || null);
|
|
return;
|
|
}
|
|
|
|
if (payload.type === "dashboard_games_update") {
|
|
this._onGamesUpdate(payload);
|
|
}
|
|
});
|
|
|
|
this._socket.addEventListener("close", () => {
|
|
this._socket = null;
|
|
this._rejectAll("Dashboard websocket disconnected");
|
|
if (!this._shuttingDown) this._scheduleReconnect();
|
|
});
|
|
|
|
this._socket.addEventListener("error", () => {
|
|
if (this._socket) this._socket.close();
|
|
});
|
|
}
|
|
|
|
waitForOpen(timeoutMs = 4000) {
|
|
if (this._shuttingDown) return Promise.resolve(false);
|
|
if (!this._socket || this._socket.readyState === WebSocket.CLOSED) this.connect();
|
|
if (this._socket && this._socket.readyState === WebSocket.OPEN) return Promise.resolve(true);
|
|
|
|
return new Promise((resolve) => {
|
|
if (!this._socket) { resolve(false); return; }
|
|
const socketRef = this._socket;
|
|
let settled = false;
|
|
const cleanup = () => {
|
|
socketRef.removeEventListener("open", onOpen);
|
|
socketRef.removeEventListener("close", onClose);
|
|
socketRef.removeEventListener("error", onError);
|
|
window.clearTimeout(timeoutId);
|
|
};
|
|
const finish = (value) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
cleanup();
|
|
resolve(value);
|
|
};
|
|
const onOpen = () => finish(true);
|
|
const onClose = () => finish(false);
|
|
const onError = () => finish(false);
|
|
const timeoutId = window.setTimeout(() => finish(false), timeoutMs);
|
|
socketRef.addEventListener("open", onOpen);
|
|
socketRef.addEventListener("close", onClose);
|
|
socketRef.addEventListener("error", onError);
|
|
});
|
|
}
|
|
|
|
async requestReplay(gameId) {
|
|
const isOpen = await this.waitForOpen();
|
|
if (!isOpen || !this._socket || this._socket.readyState !== WebSocket.OPEN) {
|
|
throw new Error("Dashboard websocket unavailable");
|
|
}
|
|
|
|
const requestId = `replay-${Date.now()}-${this._requestSeq++}`;
|
|
return await new Promise((resolve, reject) => {
|
|
const timeoutId = window.setTimeout(() => {
|
|
this._pendingRequests.delete(requestId);
|
|
reject(new Error(`Replay websocket timeout for ${gameId}`));
|
|
}, 4000);
|
|
|
|
this._pendingRequests.set(requestId, { resolve, reject, timeoutId });
|
|
try {
|
|
this._socket.send(JSON.stringify({
|
|
type: "dashboard_game_replay_request",
|
|
request_id: requestId,
|
|
game_id: gameId,
|
|
}));
|
|
} catch (error) {
|
|
window.clearTimeout(timeoutId);
|
|
this._pendingRequests.delete(requestId);
|
|
reject(error);
|
|
}
|
|
});
|
|
}
|
|
|
|
shutdown() {
|
|
this._shuttingDown = true;
|
|
if (this._reconnectTimer) {
|
|
clearTimeout(this._reconnectTimer);
|
|
this._reconnectTimer = null;
|
|
}
|
|
this._rejectAll("Dashboard unloading");
|
|
if (this._socket) {
|
|
this._socket.close();
|
|
this._socket = null;
|
|
}
|
|
}
|
|
|
|
_buildUrl() {
|
|
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
|
return `${protocol}://${window.location.host}/dashboard/ws/games`;
|
|
}
|
|
|
|
_scheduleReconnect() {
|
|
if (this._shuttingDown) return;
|
|
if (this._reconnectTimer) return;
|
|
this._reconnectTimer = window.setTimeout(() => {
|
|
this._reconnectTimer = null;
|
|
this.connect();
|
|
}, 1500);
|
|
}
|
|
|
|
_rejectAll(message) {
|
|
for (const pending of this._pendingRequests.values()) {
|
|
window.clearTimeout(pending.timeoutId);
|
|
pending.reject(new Error(message));
|
|
}
|
|
this._pendingRequests.clear();
|
|
}
|
|
}
|