Inject server env vars into index.html, including instance id (#2888)

## Description:

Should fix the broken 1v1 on staging. The issue was that we had multiple
staging environments, and the matchmaker would often route a player to a
game on a different staging server, so the client couldn't find the
game.

So now each deployment has a unique id, and the matchmaker only connects
players & servers that have the same instance id.

## 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:

evan
This commit is contained in:
Evan
2026-01-13 13:03:58 -08:00
committed by GitHub
parent 85def73bd9
commit a77c6c3d9d
10 changed files with 97 additions and 70 deletions
+5 -23
View File
@@ -524,22 +524,12 @@ export class JoinPrivateLobbyModal extends BaseModal {
private async checkArchivedGame(
lobbyId: string,
): Promise<"success" | "not_found" | "version_mismatch" | "error"> {
const archivePromise = fetch(`${getApiBase()}/game/${lobbyId}`, {
const archiveResponse = await fetch(`${getApiBase()}/game/${lobbyId}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const gitCommitPromise = fetch(`/commit.txt`, {
method: "GET",
headers: { "Content-Type": "application/json" },
cache: "no-cache",
});
const [archiveResponse, gitCommitResponse] = await Promise.all([
archivePromise,
gitCommitPromise,
]);
if (archiveResponse.status === 404) {
return "not_found";
@@ -554,19 +544,11 @@ export class JoinPrivateLobbyModal extends BaseModal {
return "version_mismatch";
}
let myGitCommit = "";
if (gitCommitResponse.status === 404) {
// commit.txt is not found when running locally
myGitCommit = "DEV";
} else if (gitCommitResponse.status === 200) {
myGitCommit = (await gitCommitResponse.text()).trim();
} else {
console.error("Error getting git commit:", gitCommitResponse.status);
return "error";
}
// Allow DEV to join games created with a different version for debugging.
if (myGitCommit !== "DEV" && parsed.data.gitCommit !== myGitCommit) {
if (
window.GIT_COMMIT !== "DEV" &&
parsed.data.gitCommit !== window.GIT_COMMIT
) {
const safeLobbyId = this.sanitizeForLog(lobbyId);
console.warn(
`Git commit hash mismatch for game ${safeLobbyId}`,
+2
View File
@@ -160,6 +160,8 @@ function updateAccountNavButton(userMeResponse: UserMeResponse | false) {
declare global {
interface Window {
GIT_COMMIT: string;
INSTANCE_ID: string;
turnstile: any;
enableAds: boolean;
PageOS: {
+3 -1
View File
@@ -120,7 +120,9 @@ export class MatchmakingModal extends BaseModal {
private async connect() {
const config = await getServerConfigFromClient();
this.socket = new WebSocket(`${config.jwtIssuer()}/matchmaking/join`);
this.socket = new WebSocket(
`${config.jwtIssuer()}/matchmaking/join?instance_id=${window.INSTANCE_ID}`,
);
this.socket.onopen = async () => {
console.log("Connected to matchmaking server");
setTimeout(() => {
+60 -14
View File
@@ -1,11 +1,14 @@
import cluster from "cluster";
import crypto from "crypto";
import ejs from "ejs";
import express from "express";
import rateLimit from "express-rate-limit";
import fs from "fs/promises";
import http from "http";
import path from "path";
import { fileURLToPath } from "url";
import { WebSocket, WebSocketServer } from "ws";
import { GameEnv } from "../core/configuration/Config";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { GameInfo } from "../core/Schemas";
import { generateID } from "../core/Util";
@@ -23,23 +26,29 @@ const log = logger.child({ comp: "m" });
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
app.use(express.json());
// Middleware to handle HTML files with EJS templating
app.use(async (req, res, next) => {
if (req.path === "/") {
try {
await renderHtml(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, 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)$/)) {
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)$/)) {
@@ -50,7 +59,6 @@ app.use(
},
}),
);
app.use(express.json());
app.set("trust proxy", 3);
app.use(
@@ -133,11 +141,20 @@ export async function startMaster() {
const ADMIN_TOKEN = crypto.randomBytes(16).toString("hex");
process.env.ADMIN_TOKEN = ADMIN_TOKEN;
const INSTANCE_ID =
config.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 < config.numWorkers(); i++) {
const worker = cluster.fork({
WORKER_ID: i,
ADMIN_TOKEN,
INSTANCE_ID,
});
log.info(`Started worker ${i} (PID: ${worker.process.pid})`);
@@ -190,6 +207,7 @@ export async function startMaster() {
const newWorker = cluster.fork({
WORKER_ID: workerId,
ADMIN_TOKEN,
INSTANCE_ID,
});
log.info(
@@ -321,6 +339,34 @@ async function schedulePublicGame(playlist: MapPlaylist) {
}
// SPA fallback route
app.get("*", function (req, res) {
res.sendFile(path.join(__dirname, "../../static/index.html"));
app.get("*", async function (_req, res) {
try {
const htmlPath = path.join(__dirname, "../../static/index.html");
await renderHtml(res, htmlPath);
} catch (error) {
log.error("Error rendering SPA fallback:", error);
res.status(500).send("Internal Server Error");
}
});
// Helper function to render HTML with EJS templating
async function renderHtml(
res: express.Response,
htmlPath: string,
): Promise<void> {
const htmlContent = await fs.readFile(htmlPath, "utf-8");
const rendered = ejs.render(htmlContent, {
gitCommit: JSON.stringify(process.env.GIT_COMMIT ?? "undefined"),
instanceId: JSON.stringify(process.env.INSTANCE_ID ?? "undefined"),
});
res.setHeader(
"Cache-Control",
"no-store, no-cache, must-revalidate, proxy-revalidate",
);
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
res.setHeader("ETag", "");
res.setHeader("Content-Type", "text/html");
res.send(rendered);
}
+1
View File
@@ -479,6 +479,7 @@ async function pollLobby(gm: GameManager) {
id: workerId,
gameId: gameId,
ccu: gm.activeClients(),
instanceId: process.env.INSTANCE_ID,
}),
signal: controller.signal,
});