refactor: collapse per-env Configs into ClientEnv + ServerEnv (#3906)

## Description:

This is a refactor to simplify config handling.

Replaces the per-environment DevConfig/PreprodConfig/ProdConfig class
hierarchy with two static classes: ClientEnv (browser main thread, reads
from window.BOOTSTRAP_CONFIG) and ServerEnv (Node server, reads from
process.env). The four config classes are deleted, the abstract
DefaultServerConfig is gone, and DefaultConfig is renamed to Config.

The values that flow server → client (gameEnv, numWorkers,
turnstileSiteKey, jwtAudience, instanceId) used to be baked into the
hardcoded per-env classes. They're now real env vars on the server,
embedded into a single window.BOOTSTRAP_CONFIG object in index.html at
request time (alongside the existing gitCommit/assetManifest/cdnBase
globals, which moved into the same object), and read back by ClientEnv
on the client. The dev defaults previously hidden inside DevServerConfig
are now explicit in start:server-dev (NUM_WORKERS=2,
TURNSTILE_SITE_KEY=1x..., JWT_AUDIENCE=localhost, etc.) and in
vite.config.ts's html plugin inject.data. Production deploys plumb
NUM_WORKERS and TURNSTILE_SITE_KEY through deploy.yml (GitHub vars) into
the remote env file; JWT_AUDIENCE is derived from DOMAIN in deploy.sh.
The dynamic /api/instance endpoint is gone — INSTANCE_ID rides along in
BOOTSTRAP_CONFIG now.

ServerEnv is the only thing server code touches; ClientEnv is
browser-only. The two classes have intentional overlap (env, numWorkers,
jwtIssuer, gameCreationRate, workerIndex, etc.) since they derive
identical logic from different sources — there's a TODO in each to
consolidate via a shared helper later. The game-logic Config no longer
stores a ServerConfig/ClientEnv reference and its serverConfig() getter
is gone; the one caller (MultiTabModal) now reads ClientEnv.env()
directly. Worker init no longer carries server-config values since
nothing in the worker actually reads them.

## 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-05-11 19:24:01 -07:00
committed by GitHub
parent a597262af9
commit 275fd0dccc
74 changed files with 1627 additions and 1956 deletions
+21 -25
View File
@@ -7,7 +7,7 @@ import path from "path";
import { fileURLToPath } from "url";
import { WebSocket, WebSocketServer } from "ws";
import { z } from "zod";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { GameEnv } from "../core/configuration/Config";
import { GameType } from "../core/game/Game";
import {
ClientMessageSchema,
@@ -24,19 +24,17 @@ import { registerGamePreviewRoute } from "./GamePreviewRoute";
import { getUserMe, verifyClientToken } from "./jwt";
import { logger } from "./Logger";
import { GameEnv } from "../core/configuration/Config";
import { MapPlaylist } from "./MapPlaylist";
import { setNoStoreHeaders } from "./NoStoreHeaders";
import { startPolling } from "./PollingLoop";
import { PrivilegeRefresher } from "./PrivilegeRefresher";
import { ServerEnv } from "./ServerEnv";
import { applyStaticAssetCacheControl } from "./StaticAssetCache";
import { verifyTurnstileToken } from "./Turnstile";
import { WorkerLobbyService } from "./WorkerLobbyService";
import { initWorkerMetrics } from "./WorkerMetrics";
const config = getServerConfigFromServer();
const workerId = parseInt(process.env.WORKER_ID ?? "0");
const workerId = ServerEnv.workerId() ?? 0;
const log = logger.child({ comp: `w_${workerId}` });
const playlist = new MapPlaylist();
@@ -55,7 +53,7 @@ export async function startWorker() {
maxPayload: 1024 * 1024, // 1MB
});
const gm = new GameManager(config, log);
const gm = new GameManager(log);
// Initialize lobby service (handles WebSocket upgrade routing)
const lobbyService = new WorkerLobbyService(server, wss, gm, log);
@@ -67,14 +65,14 @@ export async function startWorker() {
1000 + Math.random() * 2000,
);
if (config.otelEnabled()) {
if (ServerEnv.otelEnabled()) {
initWorkerMetrics(gm);
}
const privilegeRefresher = new PrivilegeRefresher(
config.jwtIssuer() + "/cosmetics.json",
config.jwtIssuer() + "/profane_words_game_server",
config.apiKey(),
ServerEnv.jwtIssuer() + "/cosmetics.json",
ServerEnv.jwtIssuer() + "/profane_words_game_server",
ServerEnv.apiKey(),
log,
);
privilegeRefresher.start();
@@ -150,7 +148,7 @@ export async function startWorker() {
const authHeader = req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) {
const token = authHeader.substring("Bearer ".length);
const result = await verifyClientToken(token, config);
const result = await verifyClientToken(token);
if (result.type === "success") {
creatorPersistentID = result.persistentId;
} else {
@@ -158,7 +156,7 @@ export async function startWorker() {
return res.status(401).json({ error: "Invalid creator token" });
}
} else if (
!req.headers[config.adminHeader()] // Public games use admin token instead
!req.headers[ServerEnv.adminHeader()] // Public games use admin token instead
) {
return res
.status(400)
@@ -180,7 +178,7 @@ export async function startWorker() {
const gc = result.data;
if (
gc?.gameType === GameType.Public &&
req.headers[config.adminHeader()] !== config.adminToken()
req.headers[ServerEnv.adminHeader()] !== ServerEnv.adminToken()
) {
log.warn(
`cannot create public game ${id}, ip ${ipAnonymize(clientIP)} incorrect admin token`,
@@ -189,7 +187,7 @@ export async function startWorker() {
}
// Double-check this worker should host this game
const expectedWorkerId = config.workerIndex(id);
const expectedWorkerId = ServerEnv.workerIndex(id);
if (expectedWorkerId !== workerId) {
log.warn(
`This game ${id} should be on worker ${expectedWorkerId}, but this is worker ${workerId}`,
@@ -229,7 +227,6 @@ export async function startWorker() {
registerGamePreviewRoute({
app,
gm,
config,
workerId,
log,
baseDir: __dirname,
@@ -316,7 +313,7 @@ export async function startWorker() {
}
// Verify this worker should handle this game
const expectedWorkerId = config.workerIndex(clientMsg.gameID);
const expectedWorkerId = ServerEnv.workerIndex(clientMsg.gameID);
if (expectedWorkerId !== workerId) {
log.warn(
`Worker mismatch: Game ${clientMsg.gameID} should be on worker ${expectedWorkerId}, but this is worker ${workerId}`,
@@ -325,7 +322,7 @@ export async function startWorker() {
}
// Verify token signature
const result = await verifyClientToken(clientMsg.token, config);
const result = await verifyClientToken(clientMsg.token);
if (result.type === "error") {
log.warn(`Invalid token: ${result.message}`, {
gameID: clientMsg.gameID,
@@ -381,7 +378,7 @@ export async function startWorker() {
let flares: string[] | undefined;
const allowedFlares = config.allowedFlares();
const allowedFlares = ServerEnv.allowedFlares();
if (claims === null) {
if (allowedFlares !== undefined) {
log.warn("Unauthorized: Anonymous user attempted to join game");
@@ -390,7 +387,7 @@ export async function startWorker() {
}
} else {
// Verify token and get player permissions
const result = await getUserMe(clientMsg.token, config);
const result = await getUserMe(clientMsg.token);
if (result.type === "error") {
log.warn(`Unauthorized: ${result.message}`, {
persistentID: persistentId,
@@ -428,11 +425,10 @@ export async function startWorker() {
return;
}
if (config.env() !== GameEnv.Dev) {
if (ServerEnv.env() !== GameEnv.Dev) {
const turnstileResult = await verifyTurnstileToken(
ip,
clientMsg.turnstileToken,
config,
);
switch (turnstileResult.status) {
case "approved":
@@ -511,7 +507,7 @@ export async function startWorker() {
});
// The load balancer will handle routing to this server based on path
const PORT = config.workerPortByIndex(workerId);
const PORT = ServerEnv.workerPortByIndex(workerId);
server.listen(PORT, () => {
log.info(`running on http://localhost:${PORT}`);
log.info(`Handling requests with path prefix /w${workerId}/`);
@@ -540,7 +536,7 @@ async function startMatchmakingPolling(gm: GameManager) {
startPolling(
async () => {
try {
const url = `${config.jwtIssuer() + "/matchmaking/checkin"}`;
const url = `${ServerEnv.jwtIssuer() + "/matchmaking/checkin"}`;
const gameId = generateGameIdForWorker();
if (gameId === null) {
log.warn(`Failed to generate game ID for worker ${workerId}`);
@@ -553,7 +549,7 @@ async function startMatchmakingPolling(gm: GameManager) {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": config.apiKey(),
"x-api-key": ServerEnv.apiKey(),
},
body: JSON.stringify({
id: workerId,
@@ -605,7 +601,7 @@ function generateGameIdForWorker(): GameID | null {
let attempts = 1000;
while (attempts > 0) {
const gameId = generateID();
if (workerId === config.workerIndex(gameId)) {
if (workerId === ServerEnv.workerIndex(gameId)) {
return gameId;
}
attempts--;