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
+10 -9
View File
@@ -70,16 +70,14 @@ export function buildAssetUrl(
declare global {
var __ASSET_MANIFEST__: AssetManifest | undefined;
var __CDN_BASE__: string | undefined;
interface Window {
ASSET_MANIFEST?: AssetManifest;
CDN_BASE?: string;
}
}
export function getAssetManifest(): AssetManifest {
if (typeof window !== "undefined" && window.ASSET_MANIFEST !== undefined) {
return window.ASSET_MANIFEST;
if (
typeof window !== "undefined" &&
window.BOOTSTRAP_CONFIG?.assetManifest !== undefined
) {
return window.BOOTSTRAP_CONFIG.assetManifest;
}
return globalThis.__ASSET_MANIFEST__ ?? {};
}
@@ -89,8 +87,11 @@ export function getAssetManifest(): AssetManifest {
// Without this fallback, asset fetches inside workers (e.g. map binaries)
// would silently bypass the CDN.
export function getCdnBase(): string {
if (typeof window !== "undefined" && window.CDN_BASE !== undefined) {
return window.CDN_BASE;
if (
typeof window !== "undefined" &&
window.BOOTSTRAP_CONFIG?.cdnBase !== undefined
) {
return window.BOOTSTRAP_CONFIG.cdnBase;
}
return globalThis.__CDN_BASE__ ?? "";
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { placeName } from "../client/graphics/NameBoxCalculator";
import { getGameLogicConfig } from "./configuration/ConfigLoader";
import { Config } from "./configuration/Config";
import { Executor } from "./execution/ExecutionManager";
import { RecomputeRailClusterExecution } from "./execution/RecomputeRailClusterExecution";
import { SpawnTimerExecution } from "./execution/SpawnTimerExecution";
@@ -37,7 +37,7 @@ export async function createGameRunner(
mapLoader: GameMapLoader,
callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
): Promise<GameRunner> {
const config = await getGameLogicConfig(gameStart.config, null);
const config = new Config(gameStart.config, null, false);
const gameMap = await loadGameMap(
gameStart.config.gameMap,
gameStart.config.gameMapSize,
File diff suppressed because it is too large Load Diff
-119
View File
@@ -1,119 +0,0 @@
import { UserSettings } from "../game/UserSettings";
import { GameConfig } from "../Schemas";
import { Config, ServerConfig } from "./Config";
import { DefaultConfig } from "./DefaultConfig";
import { DevConfig, DevServerConfig } from "./DevConfig";
import { Env } from "./Env";
import { preprodConfig } from "./PreprodConfig";
import { prodConfig } from "./ProdConfig";
export enum GameLogicEnv {
Dev = "dev",
Default = "default",
}
export let cachedRuntimeClientServerConfig: ServerConfig | null = null;
declare global {
interface Window {
BOOTSTRAP_CONFIG?: {
gameEnv?: string;
};
}
}
export async function getGameLogicConfig(
gameConfig: GameConfig,
userSettings: UserSettings | null,
isReplay: boolean = false,
): Promise<Config> {
const gameLogicEnv = getBuildTimeGameLogicEnv();
const serverConfig = getServerConfigForGameLogicEnv(gameLogicEnv);
switch (gameLogicEnv) {
case GameLogicEnv.Dev:
return new DevConfig(serverConfig, gameConfig, userSettings, isReplay);
case GameLogicEnv.Default:
return new DefaultConfig(
serverConfig,
gameConfig,
userSettings,
isReplay,
);
default:
throw Error(`unsupported game logic environment: ${gameLogicEnv}`);
}
}
export function getBuildTimeGameLogicEnv(): GameLogicEnv {
const bundledGameEnv = process.env.GAME_ENV;
switch (bundledGameEnv) {
case "dev":
return GameLogicEnv.Dev;
case "staging":
case "prod":
return GameLogicEnv.Default;
case undefined:
throw new Error("Missing bundled game logic env");
default:
throw Error(`unsupported bundled game logic env: ${bundledGameEnv}`);
}
}
export function getServerConfigForGameLogicEnv(
gameLogicEnv: GameLogicEnv,
): ServerConfig {
switch (gameLogicEnv) {
case GameLogicEnv.Dev:
return new DevServerConfig();
case GameLogicEnv.Default:
console.log("using default game logic config");
return prodConfig;
default:
throw Error(`unsupported game logic environment: ${gameLogicEnv}`);
}
}
export async function getRuntimeClientServerConfig(): Promise<ServerConfig> {
if (cachedRuntimeClientServerConfig) {
return cachedRuntimeClientServerConfig;
}
if (typeof window === "undefined") {
throw new Error(
"Runtime client server config is only available on the browser main thread",
);
}
const runtimeClientEnv = window.BOOTSTRAP_CONFIG?.gameEnv;
if (!runtimeClientEnv) {
throw new Error("Missing runtime client server config");
}
cachedRuntimeClientServerConfig = getServerConfig(runtimeClientEnv);
return cachedRuntimeClientServerConfig;
}
export function getServerConfigFromServer(): ServerConfig {
const gameEnv = Env.GAME_ENV;
return getServerConfig(gameEnv);
}
export function getServerConfig(gameEnv: string) {
switch (gameEnv) {
case "dev":
console.log("using dev server config");
return new DevServerConfig();
case "staging":
console.log("using preprod server config");
return preprodConfig;
case "prod":
console.log("using prod server config");
return prodConfig;
default:
throw Error(`unsupported server configuration: ${gameEnv}`);
}
}
export function clearCachedRuntimeClientServerConfig(): void {
cachedRuntimeClientServerConfig = null;
}
File diff suppressed because it is too large Load Diff
-55
View File
@@ -1,55 +0,0 @@
import { UserSettings } from "../game/UserSettings";
import { GameConfig } from "../Schemas";
import { GameEnv, ServerConfig } from "./Config";
import { DefaultConfig, DefaultServerConfig } from "./DefaultConfig";
export class DevServerConfig extends DefaultServerConfig {
turnstileSiteKey(): string {
return "1x00000000000000000000AA";
}
adminToken(): string {
return "WARNING_DEV_ADMIN_KEY_DO_NOT_USE_IN_PRODUCTION";
}
apiKey(): string {
return "WARNING_DEV_API_KEY_DO_NOT_USE_IN_PRODUCTION";
}
env(): GameEnv {
return GameEnv.Dev;
}
gameCreationRate(): number {
return 5 * 1000;
}
numWorkers(): number {
return 2;
}
jwtAudience(): string {
return "localhost";
}
gitCommit(): string {
return "DEV";
}
domain(): string {
return "localhost";
}
subdomain(): string {
return "";
}
}
export class DevConfig extends DefaultConfig {
constructor(
sc: ServerConfig,
gc: GameConfig,
us: UserSettings | null,
isReplay: boolean,
) {
super(sc, gc, us, isReplay);
}
}
-89
View File
@@ -1,89 +0,0 @@
/**
* Safely access environment variables in both Node.js and Vite environments.
* - In Vite (Browser), it uses `import.meta.env`.
* - In Node.js (Server), it uses `process.env`.
*/
declare global {
interface ImportMetaEnv {
[key: string]: string | boolean | undefined;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
}
function getEnv(key: string, viteKey?: string): string | undefined {
const vKey = viteKey ?? key;
// Try import.meta.env (Vite/Browser)
// We use a try-catch block or check existence to avoid ReferenceErrors
try {
if (typeof import.meta !== "undefined" && import.meta.env) {
const val = import.meta.env[vKey] ?? import.meta.env[key];
if (val !== undefined) {
return String(val);
}
}
} catch {
// Ignore errors accessing import.meta
}
// Try process.env (Node.js)
try {
if (typeof process !== "undefined" && process.env) {
const val = process.env[key];
if (val !== undefined) {
return val;
}
}
} catch {
// Ignore errors accessing process
}
return undefined;
}
export const Env = {
get GAME_ENV(): string {
// Check MODE for Vite, GAME_ENV for Node
try {
if (
typeof import.meta !== "undefined" &&
import.meta.env &&
import.meta.env.MODE
) {
return import.meta.env.MODE;
}
} catch {
// Ignore errors accessing import.meta
}
return getEnv("GAME_ENV") ?? "dev";
},
get STRIPE_PUBLISHABLE_KEY() {
return getEnv("STRIPE_PUBLISHABLE_KEY");
},
get DOMAIN() {
return getEnv("DOMAIN");
},
get SUBDOMAIN() {
return getEnv("SUBDOMAIN");
},
get OTEL_EXPORTER_OTLP_ENDPOINT() {
return getEnv("OTEL_EXPORTER_OTLP_ENDPOINT");
},
get OTEL_AUTH_HEADER() {
return getEnv("OTEL_AUTH_HEADER");
},
get GIT_COMMIT() {
return getEnv("GIT_COMMIT");
},
get API_KEY() {
return getEnv("API_KEY");
},
get ADMIN_TOKEN() {
return getEnv("ADMIN_TOKEN");
},
};
+1 -1
View File
@@ -5,7 +5,7 @@ import { GameMap, TileRef } from "../game/GameMap";
import { PlayerView } from "../game/GameView";
import { ColorAllocator } from "./ColorAllocator";
import { botColors, fallbackColors, humanColors, nationColors } from "./Colors";
import { Theme } from "./Config";
import { Theme } from "./Theme";
export class PastelTheme implements Theme {
private rand = new PseudoRandom(123);
-26
View File
@@ -1,26 +0,0 @@
import { GameEnv } from "./Config";
import { DefaultServerConfig } from "./DefaultConfig";
export const preprodConfig = new (class extends DefaultServerConfig {
env(): GameEnv {
return GameEnv.Preprod;
}
numWorkers(): number {
return 2;
}
turnstileSiteKey(): string {
return "0x4AAAAAAB7QetxHwRCKw-aP";
}
jwtAudience(): string {
return "openfront.dev";
}
allowedFlares(): string[] | undefined {
return undefined;
// TODO: Uncomment this after testing.
// Allow access without login for now to test
// the new login flow.
// return [
// // "access:openfront.dev"
// ];
}
})();
-17
View File
@@ -1,17 +0,0 @@
import { GameEnv } from "./Config";
import { DefaultServerConfig } from "./DefaultConfig";
export const prodConfig = new (class extends DefaultServerConfig {
numWorkers(): number {
return 20;
}
env(): GameEnv {
return GameEnv.Prod;
}
jwtAudience(): string {
return "openfront.io";
}
turnstileSiteKey(): string {
return "0x4AAAAAACFLkaecN39lS8sk";
}
})();
+31
View File
@@ -0,0 +1,31 @@
import { Colord } from "colord";
import { Team } from "../game/Game";
import { GameMap, TileRef } from "../game/GameMap";
import { PlayerView } from "../game/GameView";
export interface Theme {
teamColor(team: Team): Colord;
// Don't call directly, use PlayerView
territoryColor(playerInfo: PlayerView): Colord;
// Don't call directly, use PlayerView
structureColors(territoryColor: Colord): { light: Colord; dark: Colord };
// Don't call directly, use PlayerView
borderColor(territoryColor: Colord): Colord;
// Don't call directly, use PlayerView
defendedBorderColors(territoryColor: Colord): { light: Colord; dark: Colord };
focusedBorderColor(): Colord;
terrainColor(gm: GameMap, tile: TileRef): Colord;
backgroundColor(): Colord;
falloutColor(): Colord;
font(): string;
textColor(playerInfo: PlayerView): string;
// unit color for alternate view
selfColor(): Colord;
allyColor(): Colord;
neutralColor(): Colord;
enemyColor(): Colord;
spawnHighlightColor(): Colord;
spawnHighlightSelfColor(): Colord;
spawnHighlightTeamColor(): Colord;
spawnHighlightEnemyColor(): Colord;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { assetUrl } from "../AssetUrls";
import { createGameRunner, GameRunner } from "../GameRunner";
import { FetchGameMapLoader } from "../game/FetchGameMapLoader";
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
import { createGameRunner, GameRunner } from "../GameRunner";
import {
AttackClusteredPositionsResultMessage,
InitializedMessage,