mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 20:26:44 +00:00
7bb319fcad
## Description: Enable the `prefer-destructuring` eslint rule. ## 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
321 lines
9.2 KiB
TypeScript
321 lines
9.2 KiB
TypeScript
/* 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<string> = 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<number> {
|
|
const fetchPromises: Promise<GameInfo | null>[] = [];
|
|
|
|
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<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
// SPA fallback route
|
|
app.get("*", function (req, res) {
|
|
res.sendFile(path.join(__dirname, "../../static/index.html"));
|
|
});
|