/* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { ApiEnvResponse, ApiPublicLobbiesResponse } from "../core/ExpressSchemas"; import { GameInfo, ID } from "../core/Schemas"; import { LimiterType, gatekeeper } from "./Gatekeeper"; import { MapPlaylist } from "./MapPlaylist"; import cluster from "cluster"; import express from "express"; import { fileURLToPath } from "url"; import { generateID } from "../core/Util"; import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; import http from "http"; import { logger } from "./Logger"; import path from "path"; import rateLimit from "express-rate-limit"; const config = getServerConfigFromServer(); const playlist = new MapPlaylist(); const readyWorkers = new Set(); const app = express(); const server = http.createServer(app); const log = logger.child({ comp: "m" }); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); app.use(express.json()); app.use( express.static(path.join(__dirname, "../../static"), { maxAge: "1y", // Set max-age to 1 year for all static assets setHeaders: (res, path) => { // You can conditionally set different cache times based on file types if (path.endsWith(".html")) { // Set HTML files to no-cache to ensure Express doesn't send 304s res.setHeader( "Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate", ); res.setHeader("Pragma", "no-cache"); res.setHeader("Expires", "0"); // Prevent conditional requests res.setHeader("ETag", ""); } else if (path.match(/\.(js|css|svg)$/)) { // JS, CSS, SVG get long cache with immutable res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); } else if (path.match(/\.(bin|dat|exe|dll|so|dylib)$/)) { // Binary files also get long cache with immutable res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); } // Other file types use the default maxAge setting }, }), ); app.use(express.json()); app.set("trust proxy", 3); app.use( rateLimit({ max: 20, // 20 requests per IP per second windowMs: 1000, // 1 second }), ); let publicLobbiesJsonStr = JSON.stringify({ lobbies: [], } satisfies ApiPublicLobbiesResponse); const publicLobbyIDs: Set = new Set(); // Start the master process export async function startMaster() { if (!cluster.isPrimary) { throw new Error( "startMaster() should only be called in the primary process", ); } log.info(`Primary ${process.pid} is running`); log.info(`Setting up ${config.numWorkers()} workers...`); // Fork workers for (let i = 0; i < config.numWorkers(); i++) { const worker = cluster.fork({ WORKER_ID: i, }); log.info(`Started worker ${i} (PID: ${worker.process.pid})`); } cluster.on("message", (worker, message) => { if (message.type === "WORKER_READY") { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const { workerId } = message; readyWorkers.add(workerId); log.info( `Worker ${workerId} is ready. (${readyWorkers.size}/${config.numWorkers()} ready)`, ); // Start scheduling when all workers are ready if (readyWorkers.size === config.numWorkers()) { log.info("All workers ready, starting game scheduling"); const scheduleLobbies = () => { schedulePublicGame(playlist).catch((error) => { log.error("Error scheduling public game:", error); }); }; setInterval( () => fetchLobbies().then((lobbies) => { if (lobbies === 0) { scheduleLobbies(); } }), 100, ); } } }); // Handle worker crashes cluster.on("exit", (worker, code, signal) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment const workerId = (worker as any).process?.env?.WORKER_ID; if (!workerId) { log.error("worker crashed could not find id"); return; } log.warn( `Worker ${workerId} (PID: ${worker.process.pid}) died with code: ${code} and signal: ${signal}`, ); log.info(`Restarting worker ${workerId}...`); // Restart the worker with the same ID const newWorker = cluster.fork({ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment WORKER_ID: workerId, }); log.info( `Restarted worker ${workerId} (New PID: ${newWorker.process.pid})`, ); }); const PORT = 3000; server.listen(PORT, () => { log.info(`Master HTTP server listening on port ${PORT}`); }); } app.get( "/api/env", gatekeeper.httpHandler(LimiterType.Get, async (req, res) => { const envConfig: ApiEnvResponse = { game_env: process.env.GAME_ENV ?? "", }; if (!envConfig.game_env) return res.sendStatus(500); res.json(envConfig); }), ); // Add lobbies endpoint to list public games for this worker app.get( "/api/public_lobbies", gatekeeper.httpHandler(LimiterType.Get, async (req, res) => { res.send(publicLobbiesJsonStr); }), ); app.post( "/api/kick_player/:gameID/:clientID", gatekeeper.httpHandler(LimiterType.Post, async (req, res) => { if (req.headers[config.adminHeader()] !== config.adminToken()) { res.status(401).send("Unauthorized"); return; } const { gameID, clientID } = req.params; if (!ID.safeParse(gameID).success || !ID.safeParse(clientID).success) { res.sendStatus(400); return; } try { const response = await fetch( `http://localhost:${config.workerPort(gameID)}/api/kick_player/${gameID}/${clientID}`, { headers: { [config.adminHeader()]: config.adminToken(), }, method: "POST", }, ); if (!response.ok) { throw new Error(`Failed to kick player: ${response.statusText}`); } res.status(200).send("Player kicked successfully"); } catch (error) { log.error(`Error kicking player from game ${gameID}:`, error); res.status(500).send("Failed to kick player"); } }), ); async function fetchLobbies(): Promise { const fetchPromises: Promise[] = []; for (const gameID of new Set(publicLobbyIDs)) { const controller = new AbortController(); setTimeout(() => controller.abort(), 5000); // 5 second timeout const port = config.workerPort(gameID); const promise = fetch(`http://localhost:${port}/api/game/${gameID}`, { headers: { [config.adminHeader()]: config.adminToken() }, signal: controller.signal, }) .then((resp) => resp.json()) .then((json) => { return json as GameInfo; }) .catch((error) => { log.error(`Error fetching game ${gameID}:`, error); // Return null or a placeholder if fetch fails publicLobbyIDs.delete(gameID); return null; }); fetchPromises.push(promise); } // Wait for all promises to resolve const results = await Promise.all(fetchPromises); // Filter out any null results from failed fetches const lobbyInfos: GameInfo[] = results .filter((result) => result !== null) .map((gi: GameInfo) => { return { gameConfig: gi.gameConfig, gameID: gi.gameID, msUntilStart: (gi.msUntilStart ?? Date.now()) - Date.now(), numClients: gi?.clients?.length ?? 0, } as GameInfo; }); lobbyInfos.forEach((l) => { if ( "msUntilStart" in l && l.msUntilStart !== undefined && l.msUntilStart <= 250 ) { publicLobbyIDs.delete(l.gameID); return; } if ( "gameConfig" in l && l.gameConfig !== undefined && "maxPlayers" in l.gameConfig && l.gameConfig.maxPlayers !== undefined && "numClients" in l && l.numClients !== undefined && l.gameConfig.maxPlayers <= l.numClients ) { publicLobbyIDs.delete(l.gameID); return; } }); // Update the JSON string publicLobbiesJsonStr = JSON.stringify({ lobbies: lobbyInfos, } satisfies ApiPublicLobbiesResponse); return publicLobbyIDs.size; } // Function to schedule a new public game async function schedulePublicGame(playlist: MapPlaylist) { const gameID = generateID(); publicLobbyIDs.add(gameID); const workerPath = config.workerPath(gameID); // Send request to the worker to start the game try { const response = await fetch( `http://localhost:${config.workerPort(gameID)}/api/create_game/${gameID}`, { body: JSON.stringify(playlist.gameConfig()), headers: { "Content-Type": "application/json", [config.adminHeader()]: config.adminToken(), }, method: "POST", }, ); if (!response.ok) { throw new Error(`Failed to schedule public game: ${response.statusText}`); } const data = await response.json(); } catch (error) { log.error(`Failed to schedule public game on worker ${workerPath}:`, error); throw error; } } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } // SPA fallback route app.get("*", function (req, res) { res.sendFile(path.join(__dirname, "../../static/index.html")); });