mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 07:50:45 +00:00
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:
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user