Files
OpenFrontIO/src/client/LobbySocket.ts
T
Ryan 3dcd38a58d lobby websocket instead of polling (#2727)
## Description:
Changes game lobbies into websockets instead of polling

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

w.o.n

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: iamlewis <lewismmmm@gmail.com>
2026-01-01 09:38:33 -08:00

178 lines
4.9 KiB
TypeScript

import { GameInfo } from "../core/Schemas";
type LobbyUpdateHandler = (lobbies: GameInfo[]) => void;
interface LobbySocketOptions {
reconnectDelay?: number;
maxWsAttempts?: number;
pollIntervalMs?: number;
}
export class PublicLobbySocket {
private ws: WebSocket | null = null;
private wsReconnectTimeout: number | null = null;
private fallbackPollInterval: number | null = null;
private wsConnectionAttempts = 0;
private wsAttemptCounted = false;
private readonly reconnectDelay: number;
private readonly maxWsAttempts: number;
private readonly pollIntervalMs: number;
private readonly onLobbiesUpdate: LobbyUpdateHandler;
constructor(
onLobbiesUpdate: LobbyUpdateHandler,
options?: LobbySocketOptions,
) {
this.onLobbiesUpdate = onLobbiesUpdate;
this.reconnectDelay = options?.reconnectDelay ?? 3000;
this.maxWsAttempts = options?.maxWsAttempts ?? 3;
this.pollIntervalMs = options?.pollIntervalMs ?? 1000;
}
start() {
this.wsConnectionAttempts = 0;
this.connectWebSocket();
}
stop() {
this.disconnectWebSocket();
this.stopFallbackPolling();
}
private connectWebSocket() {
try {
// Clean up existing WebSocket before creating a new one
if (this.ws) {
this.ws.close();
this.ws = null;
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/lobbies`;
this.ws = new WebSocket(wsUrl);
this.wsAttemptCounted = false;
this.ws.addEventListener("open", () => this.handleOpen());
this.ws.addEventListener("message", (event) => this.handleMessage(event));
this.ws.addEventListener("close", () => this.handleClose());
this.ws.addEventListener("error", (error) => this.handleError(error));
} catch (error) {
this.handleConnectError(error);
}
}
private handleOpen() {
console.log("WebSocket connected: lobby updating");
this.wsConnectionAttempts = 0;
if (this.wsReconnectTimeout !== null) {
clearTimeout(this.wsReconnectTimeout);
this.wsReconnectTimeout = null;
}
this.stopFallbackPolling();
}
private handleMessage(event: MessageEvent) {
try {
const message = JSON.parse(event.data as string);
if (message.type === "lobbies_update") {
this.onLobbiesUpdate(message.data?.lobbies ?? []);
}
} catch (error) {
console.error("Error parsing WebSocket message:", error);
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
try {
this.ws.close();
} catch (closeError) {
console.error(
"Error closing WebSocket after parse failure:",
closeError,
);
}
}
}
}
private handleClose() {
console.log("WebSocket disconnected, attempting to reconnect...");
if (!this.wsAttemptCounted) {
this.wsAttemptCounted = true;
this.wsConnectionAttempts++;
}
if (this.wsConnectionAttempts >= this.maxWsAttempts) {
console.log(
"Max WebSocket attempts reached, falling back to HTTP polling",
);
this.startFallbackPolling();
} else {
this.scheduleReconnect();
}
}
private handleError(error: Event) {
console.error("WebSocket error:", error);
}
private handleConnectError(error: unknown) {
console.error("Error connecting WebSocket:", error);
if (!this.wsAttemptCounted) {
this.wsAttemptCounted = true;
this.wsConnectionAttempts++;
}
if (this.wsConnectionAttempts >= this.maxWsAttempts) {
this.startFallbackPolling();
} else {
this.scheduleReconnect();
}
}
private scheduleReconnect() {
if (this.wsReconnectTimeout !== null) return;
this.wsReconnectTimeout = window.setTimeout(() => {
this.wsReconnectTimeout = null;
this.connectWebSocket();
}, this.reconnectDelay);
}
private disconnectWebSocket() {
if (this.ws) {
this.ws.close();
this.ws = null;
}
if (this.wsReconnectTimeout !== null) {
clearTimeout(this.wsReconnectTimeout);
this.wsReconnectTimeout = null;
}
}
private startFallbackPolling() {
if (this.fallbackPollInterval !== null) return;
console.log("Starting HTTP fallback polling");
this.fetchLobbiesHTTP();
this.fallbackPollInterval = window.setInterval(() => {
this.fetchLobbiesHTTP();
}, this.pollIntervalMs);
}
private stopFallbackPolling() {
if (this.fallbackPollInterval !== null) {
clearInterval(this.fallbackPollInterval);
this.fallbackPollInterval = null;
}
}
private async fetchLobbiesHTTP() {
try {
const response = await fetch(`/api/public_lobbies`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
this.onLobbiesUpdate(data.lobbies as GameInfo[]);
} catch (error) {
console.error("Error fetching lobbies via HTTP:", error);
}
}
}