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>
This commit is contained in:
Ryan
2026-01-01 17:38:33 +00:00
committed by GitHub
parent 9d5f167446
commit 3dcd38a58d
5 changed files with 378 additions and 64 deletions
+177
View File
@@ -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);
}
}
}
+25 -58
View File
@@ -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<GameID, string> = 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<GameID, number>();
private lobbiesFetchInFlight: Promise<GameInfo[]> | 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<void> {
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<GameInfo[]> {
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;
+62 -5
View File
@@ -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<string> = new Set();
const connectedClients: Set<WebSocket> = 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<number> {
@@ -225,10 +280,12 @@ async function fetchLobbies(): Promise<number> {
}
});
// Update the JSON string
publicLobbiesJsonStr = JSON.stringify({
// Update the lobbies data
publicLobbiesData = {
lobbies: lobbyInfos,
});
};
broadcastLobbies();
return publicLobbyIDs.size;
}
+113
View File
@@ -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();
});
});
+1 -1
View File
@@ -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,