From 3dcd38a58dd59368b7435df3940bd37e0a021bfe Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 1 Jan 2026 17:38:33 +0000 Subject: [PATCH] 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 --- src/client/LobbySocket.ts | 177 +++++++++++++++++++++++++++++++ src/client/PublicLobby.ts | 83 +++++---------- src/server/Master.ts | 67 +++++++++++- tests/client/LobbySocket.test.ts | 113 ++++++++++++++++++++ vite.config.ts | 2 +- 5 files changed, 378 insertions(+), 64 deletions(-) create mode 100644 src/client/LobbySocket.ts create mode 100644 tests/client/LobbySocket.test.ts diff --git a/src/client/LobbySocket.ts b/src/client/LobbySocket.ts new file mode 100644 index 000000000..f398da054 --- /dev/null +++ b/src/client/LobbySocket.ts @@ -0,0 +1,177 @@ +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); + } + } +} diff --git a/src/client/PublicLobby.ts b/src/client/PublicLobby.ts index a2c08996c..33f3e8d33 100644 --- a/src/client/PublicLobby.ts +++ b/src/client/PublicLobby.ts @@ -11,6 +11,7 @@ import { } from "../core/game/Game"; import { GameID, GameInfo } from "../core/Schemas"; import { generateID } from "../core/Util"; +import { PublicLobbySocket } from "./LobbySocket"; import { JoinLobbyEvent } from "./Main"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; @@ -22,12 +23,13 @@ export class PublicLobby extends LitElement { @state() private mapImages: Map = new Map(); @state() private joiningDotIndex: number = 0; - private lobbiesInterval: number | null = null; private joiningInterval: number | null = null; private currLobby: GameInfo | null = null; private debounceDelay: number = 750; private lobbyIDToStart = new Map(); - private lobbiesFetchInFlight: Promise | null = null; + private lobbySocket = new PublicLobbySocket((lobbies) => + this.handleLobbiesUpdate(lobbies), + ); createRenderRoot() { return this; @@ -35,38 +37,28 @@ export class PublicLobby extends LitElement { connectedCallback() { super.connectedCallback(); - this.fetchAndUpdateLobbies(); - this.lobbiesInterval = window.setInterval( - () => this.fetchAndUpdateLobbies(), - 1000, - ); + this.lobbySocket.start(); } disconnectedCallback() { super.disconnectedCallback(); - if (this.lobbiesInterval !== null) { - clearInterval(this.lobbiesInterval); - this.lobbiesInterval = null; - } + this.lobbySocket.stop(); this.stopJoiningAnimation(); } - private async fetchAndUpdateLobbies(): Promise { - try { - this.lobbies = await this.fetchLobbies(); - this.lobbies.forEach((l) => { - if (!this.lobbyIDToStart.has(l.gameID)) { - const msUntilStart = l.msUntilStart ?? 0; - this.lobbyIDToStart.set(l.gameID, msUntilStart + Date.now()); - } + private handleLobbiesUpdate(lobbies: GameInfo[]) { + this.lobbies = lobbies; + this.lobbies.forEach((l) => { + if (!this.lobbyIDToStart.has(l.gameID)) { + const msUntilStart = l.msUntilStart ?? 0; + this.lobbyIDToStart.set(l.gameID, msUntilStart + Date.now()); + } - if (l.gameConfig && !this.mapImages.has(l.gameID)) { - this.loadMapImage(l.gameID, l.gameConfig.gameMap); - } - }); - } catch (error) { - console.error("Error fetching lobbies:", error); - } + if (l.gameConfig && !this.mapImages.has(l.gameID)) { + this.loadMapImage(l.gameID, l.gameConfig.gameMap); + } + }); + this.requestUpdate(); } private async loadMapImage(gameID: GameID, gameMap: string) { @@ -80,38 +72,6 @@ export class PublicLobby extends LitElement { } } - async fetchLobbies(): Promise { - if (this.lobbiesFetchInFlight) { - return this.lobbiesFetchInFlight; - } - - this.lobbiesFetchInFlight = (async () => { - try { - const response = await fetch(`/api/public_lobbies`); - if (!response.ok) - throw new Error(`HTTP error! status: ${response.status}`); - const data = await response.json(); - return data.lobbies as GameInfo[]; - } catch (error) { - console.error("Error fetching lobbies:", error); - throw error; - } finally { - this.lobbiesFetchInFlight = null; - } - })(); - - return this.lobbiesFetchInFlight; - } - - public stop() { - if (this.lobbiesInterval !== null) { - this.isLobbyHighlighted = false; - this.stopJoiningAnimation(); - clearInterval(this.lobbiesInterval); - this.lobbiesInterval = null; - } - } - render() { if (this.lobbies.length === 0) return html``; @@ -217,6 +177,13 @@ export class PublicLobby extends LitElement { this.stopJoiningAnimation(); } + public stop() { + this.lobbySocket.stop(); + this.isLobbyHighlighted = false; + this.currLobby = null; + this.stopJoiningAnimation(); + } + private startJoiningAnimation() { if (this.joiningInterval !== null) return; diff --git a/src/server/Master.ts b/src/server/Master.ts index a4becf432..0e2f8317f 100644 --- a/src/server/Master.ts +++ b/src/server/Master.ts @@ -5,6 +5,7 @@ import rateLimit from "express-rate-limit"; import http from "http"; import path from "path"; import { fileURLToPath } from "url"; +import { WebSocket, WebSocketServer } from "ws"; import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; import { GameInfo } from "../core/Schemas"; import { generateID } from "../core/Util"; @@ -59,9 +60,32 @@ app.use( }), ); -let publicLobbiesJsonStr = ""; +let publicLobbiesData: { lobbies: GameInfo[] } = { lobbies: [] }; const publicLobbyIDs: Set = new Set(); +const connectedClients: Set = new Set(); + +// Broadcast lobbies to all connected clients +function broadcastLobbies() { + const message = JSON.stringify({ + type: "lobbies_update", + data: publicLobbiesData, + }); + + const clientsToRemove: WebSocket[] = []; + + connectedClients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(message); + } else { + clientsToRemove.push(client); + } + }); + + clientsToRemove.forEach((client) => { + connectedClients.delete(client); + }); +} // Start the master process export async function startMaster() { @@ -74,6 +98,37 @@ export async function startMaster() { log.info(`Primary ${process.pid} is running`); log.info(`Setting up ${config.numWorkers()} workers...`); + // Setup WebSocket server for clients + const wss = new WebSocketServer({ server, path: "/lobbies" }); + + wss.on("connection", (ws: WebSocket) => { + connectedClients.add(ws); + + // Send current lobbies immediately (always send, even if empty) + ws.send( + JSON.stringify({ type: "lobbies_update", data: publicLobbiesData }), + ); + + ws.on("close", () => { + connectedClients.delete(ws); + }); + + ws.on("error", (error) => { + log.error(`WebSocket error:`, error); + connectedClients.delete(ws); + try { + if ( + ws.readyState === WebSocket.OPEN || + ws.readyState === WebSocket.CONNECTING + ) { + ws.close(1011, "WebSocket internal error"); + } + } catch (closeError) { + log.error("Error while closing WebSocket after error:", closeError); + } + }); + }); + // Generate admin token for worker authentication const ADMIN_TOKEN = crypto.randomBytes(16).toString("hex"); process.env.ADMIN_TOKEN = ADMIN_TOKEN; @@ -158,7 +213,7 @@ app.get("/api/env", async (req, res) => { // Add lobbies endpoint to list public games for this worker app.get("/api/public_lobbies", async (req, res) => { - res.send(publicLobbiesJsonStr); + res.json(publicLobbiesData); }); async function fetchLobbies(): Promise { @@ -225,10 +280,12 @@ async function fetchLobbies(): Promise { } }); - // Update the JSON string - publicLobbiesJsonStr = JSON.stringify({ + // Update the lobbies data + publicLobbiesData = { lobbies: lobbyInfos, - }); + }; + + broadcastLobbies(); return publicLobbyIDs.size; } diff --git a/tests/client/LobbySocket.test.ts b/tests/client/LobbySocket.test.ts new file mode 100644 index 000000000..3e6e8d901 --- /dev/null +++ b/tests/client/LobbySocket.test.ts @@ -0,0 +1,113 @@ +import { PublicLobbySocket } from "../../src/client/LobbySocket"; + +class MockWebSocket extends EventTarget { + static instances: MockWebSocket[] = []; + static readonly OPEN = 1; + static readonly CLOSED = 3; + + readonly url: string; + readyState = MockWebSocket.OPEN; + + constructor(url: string) { + super(); + this.url = url; + MockWebSocket.instances.push(this); + } + + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ): void { + super.addEventListener(type, listener, options); + } + + close(code?: number, reason?: string) { + this.readyState = MockWebSocket.CLOSED; + this.dispatchEvent(new CloseEvent("close", { code, reason })); + } + + send(_data: unknown) {} +} + +describe("PublicLobbySocket", () => { + const originalWebSocket = globalThis.WebSocket; + const originalFetch = globalThis.fetch; + + beforeEach(() => { + MockWebSocket.instances = []; + // @ts-expect-error assign test mock + globalThis.WebSocket = MockWebSocket; + }); + + afterEach(() => { + globalThis.WebSocket = originalWebSocket; + globalThis.fetch = originalFetch; + vi.useRealTimers(); + }); + + it("delivers lobby updates from websocket messages", () => { + const updates: unknown[][] = []; + const socket = new PublicLobbySocket((lobbies) => updates.push(lobbies)); + + socket.start(); + const ws = MockWebSocket.instances.at(-1); + expect(ws?.url).toContain("/lobbies"); + + ws?.dispatchEvent( + new MessageEvent("message", { + data: JSON.stringify({ + type: "lobbies_update", + data: { + lobbies: [ + { + gameID: "g1", + numClients: 1, + gameConfig: { + maxPlayers: 2, + gameMode: 0, + gameMap: "Earth", + }, + }, + ], + }, + }), + }), + ); + + expect(updates).toHaveLength(1); + expect((updates[0][0] as { gameID: string }).gameID).toBe("g1"); + + socket.stop(); + }); + + it("falls back to HTTP polling after max websocket attempts", async () => { + vi.useFakeTimers(); + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ lobbies: [] }), + }); + + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const socket = new PublicLobbySocket(() => {}, { + maxWsAttempts: 1, + reconnectDelay: 0, + pollIntervalMs: 50, + }); + + socket.start(); + const ws = MockWebSocket.instances.at(-1); + ws?.dispatchEvent(new CloseEvent("close")); + + await Promise.resolve(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(60); + await Promise.resolve(); + expect(fetchMock).toHaveBeenCalledTimes(2); + + socket.stop(); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 357d68a53..e02a03522 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -100,7 +100,7 @@ export default defineConfig(({ mode }) => { // Automatically open the browser when the server starts open: process.env.SKIP_BROWSER_OPEN !== "true", proxy: { - "/socket": { + "/lobbies": { target: "ws://localhost:3000", ws: true, changeOrigin: true,