mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-01 17:23:33 +00:00
c55ea6bb5a
## What Game creation no longer requires the caller to pick the `gameID` or compute its owning worker. The client POSTs to a prefix-less `/api/create_game`; **nginx (prod) and the vite dev proxy randomly route it to a worker**, which **mints an id that hashes back to itself** and returns it along with its `workerIndex`. ## Why it stays correct The minted id still hashes to the creating worker (via the existing `generateGameIdForWorker`), so everything downstream that derives the worker from the gameID — websocket connect, share URL, join flow — keeps working unchanged. The only thing that moved is *who picks the id and worker*. ## Changes - **`src/server/Worker.ts`** — factor create into a shared `createGameForId`; add `POST /api/create_game` (no id) that mints a self-owned id and returns `gameInfo` + `workerIndex`/`workerPath`. The existing `POST /api/create_game/:id` stays. - **`nginx.conf`** — `location = /api/create_game` proxies to a `random` worker upstream. - **`generate-nginx-upstream.sh` + `Dockerfile`** — the entrypoint generates that upstream from `NUM_WORKERS` at container **start** time. `NUM_WORKERS` isn't known at image build time (the image is built once and deployed with different env), so it can't be baked into `nginx.conf` — hence runtime generation of exactly the live worker ports (no dead-server padding). - **`vite.config.ts`** — dev-only middleware forwards `POST /api/create_game` to a random worker. Vite's `http-proxy` can't pick a per-request random target, so this is a small middleware plugin (same pattern as the existing `serveProprietaryDir`), registered before the `/api` proxy. - **`src/client/HostLobbyModal.ts`** — stop generating the id client-side; use the server's. ## Behavior change to note The host's share link used to be copied **instantly** from a client-generated id. Now the id comes from the server, so the copy waits one create round-trip — I moved the URL build/copy into the create `.then` (and kept the failure path that clears the clipboard). Brief empty-link state in the modal until create resolves. ## Verification - tsc + eslint clean; full suite green (1543 tests). - nginx additions validated with `nginx -t` in isolation (the full file references container-only paths like `/etc/nginx/mime.types`); upstream + `proxy_pass` resolve. - `generate-nginx-upstream.sh` tested with `NUM_WORKERS` set and unset (defaults to 1). Not yet exercised live end-to-end (needs a dev-server restart — `vite.config.ts` + `Worker.ts` changes aren't hot-reloaded). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
157 lines
4.0 KiB
TypeScript
157 lines
4.0 KiB
TypeScript
import cluster from "cluster";
|
|
import crypto from "crypto";
|
|
import express from "express";
|
|
import rateLimit from "express-rate-limit";
|
|
import http from "http";
|
|
import path from "path";
|
|
import { fileURLToPath } from "url";
|
|
import { GameEnv } from "../core/configuration/Config";
|
|
import { logger } from "./Logger";
|
|
import { MapPlaylist } from "./MapPlaylist";
|
|
import { MasterLobbyService } from "./MasterLobbyService";
|
|
import { setNoStoreHeaders } from "./NoStoreHeaders";
|
|
import { renderAppShell } from "./RenderHtml";
|
|
import { ServerEnv } from "./ServerEnv";
|
|
import { applyStaticAssetCacheControl } from "./StaticAssetCache";
|
|
|
|
const playlist = new MapPlaylist();
|
|
let lobbyService: MasterLobbyService;
|
|
|
|
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());
|
|
|
|
// Serve the shared app shell for the root document.
|
|
app.use(async (req, res, next) => {
|
|
if (req.path === "/") {
|
|
try {
|
|
await renderAppShell(
|
|
res,
|
|
path.join(__dirname, "../../static/index.html"),
|
|
);
|
|
} catch (error) {
|
|
log.error("Error rendering index.html:", error);
|
|
res.status(500).send("Internal Server Error");
|
|
}
|
|
} else {
|
|
next();
|
|
}
|
|
});
|
|
|
|
app.use(
|
|
express.static(path.join(__dirname, "../../static"), {
|
|
maxAge: "1y", // Set max-age to 1 year for all static assets
|
|
setHeaders: (res) => {
|
|
applyStaticAssetCacheControl(
|
|
res.setHeader.bind(res),
|
|
res.req.originalUrl,
|
|
);
|
|
},
|
|
}),
|
|
);
|
|
|
|
app.set("trust proxy", 3);
|
|
app.use(
|
|
rateLimit({
|
|
windowMs: 1000, // 1 second
|
|
max: 20, // 20 requests per IP per second
|
|
}),
|
|
);
|
|
|
|
app.use("/api", (_req, res, next) => {
|
|
setNoStoreHeaders(res);
|
|
next();
|
|
});
|
|
|
|
// 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 ${ServerEnv.numWorkers()} workers...`);
|
|
|
|
lobbyService = new MasterLobbyService(playlist, log);
|
|
|
|
const INSTANCE_ID =
|
|
ServerEnv.env() === GameEnv.Dev
|
|
? "DEV_ID"
|
|
: crypto.randomBytes(4).toString("hex");
|
|
process.env.INSTANCE_ID = INSTANCE_ID;
|
|
|
|
log.info(`Instance ID: ${INSTANCE_ID}`);
|
|
|
|
// Fork workers
|
|
for (let i = 0; i < ServerEnv.numWorkers(); i++) {
|
|
const worker = cluster.fork({
|
|
WORKER_ID: i,
|
|
INSTANCE_ID,
|
|
});
|
|
|
|
lobbyService.registerWorker(i, worker);
|
|
log.info(`Started worker ${i} (PID: ${worker.process.pid})`);
|
|
}
|
|
|
|
// Handle worker crashes
|
|
cluster.on("exit", (worker, code, signal) => {
|
|
const workerId = (worker as any).process?.env?.WORKER_ID;
|
|
if (workerId === undefined) {
|
|
log.error(`worker crashed could not find id`);
|
|
return;
|
|
}
|
|
|
|
const workerIdNum = parseInt(workerId);
|
|
lobbyService.removeWorker(workerIdNum);
|
|
|
|
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({
|
|
WORKER_ID: workerId,
|
|
INSTANCE_ID,
|
|
});
|
|
|
|
lobbyService.registerWorker(workerIdNum, newWorker);
|
|
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/health", (_req, res) => {
|
|
const ready = lobbyService?.isHealthy() ?? false;
|
|
if (ready) {
|
|
res.json({ status: "ok" });
|
|
} else {
|
|
res.status(503).json({ status: "unavailable" });
|
|
}
|
|
});
|
|
|
|
// SPA fallback route
|
|
app.get("/{*splat}", async function (_req, res) {
|
|
try {
|
|
const htmlPath = path.join(__dirname, "../../static/index.html");
|
|
await renderAppShell(res, htmlPath);
|
|
} catch (error) {
|
|
log.error("Error rendering SPA fallback:", error);
|
|
res.status(500).send("Internal Server Error");
|
|
}
|
|
});
|