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
+8 -10
View File
@@ -1,5 +1,4 @@
import z from "zod";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { GameType } from "../core/game/Game";
import {
GameID,
@@ -10,8 +9,7 @@ import {
} from "../core/Schemas";
import { replacer } from "../core/Util";
import { logger } from "./Logger";
const config = getServerConfigFromServer();
import { ServerEnv } from "./ServerEnv";
const log = logger.child({ component: "Archive" });
@@ -31,13 +29,13 @@ export async function archive(
});
return;
}
const url = `${config.jwtIssuer()}/game/${gameRecord.info.gameID}`;
const url = `${ServerEnv.jwtIssuer()}/game/${gameRecord.info.gameID}`;
const response = await fetch(url, {
method: "POST",
body: JSON.stringify(gameRecord, replacer),
headers: {
"Content-Type": "application/json",
"x-api-key": config.apiKey(),
"x-api-key": ServerEnv.apiKey(),
},
});
if (!response.ok) {
@@ -62,12 +60,12 @@ export async function readGameRecord(
log.error(`invalid game ID: ${gameId}`);
return null;
}
const url = `${config.jwtIssuer()}/game/${gameId}`;
const url = `${ServerEnv.jwtIssuer()}/game/${gameId}`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
"x-api-key": config.apiKey(),
"x-api-key": ServerEnv.apiKey(),
},
});
const record = await response.json();
@@ -91,9 +89,9 @@ export function finalizeGameRecord(
): GameRecord {
return {
...clientRecord,
gitCommit: config.gitCommit(),
subdomain: config.subdomain(),
domain: config.domain(),
gitCommit: ServerEnv.gitCommit(),
subdomain: ServerEnv.subdomain(),
domain: ServerEnv.domain(),
};
}
+1 -6
View File
@@ -1,6 +1,5 @@
import { Logger } from "winston";
import WebSocket from "ws";
import { ServerConfig } from "../core/configuration/Config";
import {
Difficulty,
GameMapSize,
@@ -15,10 +14,7 @@ import { GamePhase, GameServer } from "./GameServer";
export class GameManager {
private games: Map<GameID, GameServer> = new Map();
constructor(
private config: ServerConfig,
private log: Logger,
) {
constructor(private log: Logger) {
setInterval(() => this.tick(), 1000);
}
@@ -69,7 +65,6 @@ export class GameManager {
id,
this.log,
Date.now(),
this.config,
{
donateGold: false,
donateTroops: false,
+2 -1
View File
@@ -4,6 +4,7 @@ import { ClanTagSchema, GameInfo, UsernameSchema } from "../core/Schemas";
import { formatPlayerDisplayName } from "../core/Util";
import { GameMode } from "../core/game/Game";
import { getRuntimeAssetManifest } from "./RuntimeAssetManifest";
import { ServerEnv } from "./ServerEnv";
export const PlayerInfoSchema = z.object({
clientID: z.string().optional(),
@@ -141,7 +142,7 @@ export async function buildPreview(
publicInfo: ExternalGameInfo | null,
): Promise<PreviewMeta> {
const assetManifest = await getRuntimeAssetManifest();
const cdnBase = process.env.CDN_BASE ?? "";
const cdnBase = ServerEnv.cdnBase();
const buildAbsoluteAssetUrl = (path: string) =>
new URL(buildAssetUrl(path, assetManifest, cdnBase), origin).toString();
const isFinished = !!publicInfo?.info?.end;
+10 -10
View File
@@ -4,7 +4,6 @@ import { parse } from "node-html-parser";
import path from "path";
import type { Logger } from "winston";
import { z } from "zod";
import type { ServerConfig } from "../core/configuration/Config";
import { GAME_ID_REGEX, GameInfo } from "../core/Schemas";
import { replacer } from "../core/Util";
import type { GameManager } from "./GameManager";
@@ -16,17 +15,19 @@ import {
} from "./GamePreviewBuilder";
import { setNoStoreHeaders } from "./NoStoreHeaders";
import { getAppShellContent, setHtmlNoCacheHeaders } from "./RenderHtml";
import { ServerEnv } from "./ServerEnv";
const requestOrigin = (req: Request, config: ServerConfig): string => {
const requestOrigin = (req: Request): string => {
const protoHeader = (req.headers["x-forwarded-proto"] as string) ?? "";
const proto = protoHeader.split(",")[0]?.trim() || req.protocol || "https";
const host = req.get("host") ?? `${config.subdomain()}.${config.domain()}`;
const host =
req.get("host") ?? `${ServerEnv.subdomain()}.${ServerEnv.domain()}`;
// Force https only for the configured public domain (and its subdomains).
// This avoids hardcoding hostnames while ensuring we don't force https on
// localhost or arbitrary custom hosts.
const hostname = host.split(":")[0].toLowerCase();
const domain = config.domain().toLowerCase();
const domain = ServerEnv.domain().toLowerCase();
const forceHttps = hostname === domain || hostname.endsWith(`.${domain}`);
return `${forceHttps ? "https" : proto}://${host}`;
@@ -35,12 +36,11 @@ const requestOrigin = (req: Request, config: ServerConfig): string => {
export function registerGamePreviewRoute(opts: {
app: Express;
gm: GameManager;
config: ServerConfig;
workerId: number;
log: Logger;
baseDir: string;
}) {
const { app, gm, config, log, baseDir } = opts;
const { app, gm, log, baseDir } = opts;
const gameIDSchema = z.string().regex(GAME_ID_REGEX);
@@ -52,11 +52,11 @@ export function registerGamePreviewRoute(opts: {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1500);
try {
const apiDomain = config.jwtIssuer();
const apiDomain = ServerEnv.jwtIssuer();
const encodedID = encodeURIComponent(gameID);
const response = await fetch(`${apiDomain}/game/${encodedID}`, {
headers: {
"x-api-key": config.apiKey(),
"x-api-key": ServerEnv.apiKey(),
},
signal: controller.signal,
});
@@ -99,11 +99,11 @@ export function registerGamePreviewRoute(opts: {
return res.redirect(302, "/");
}
const origin = requestOrigin(req, config);
const origin = requestOrigin(req);
const meta = await buildPreview(
gameID,
origin,
config.workerPath(gameID),
ServerEnv.workerPath(gameID),
lobby,
publicInfo,
);
+4 -4
View File
@@ -3,7 +3,7 @@ import { Logger } from "winston";
import WebSocket from "ws";
import { z } from "zod";
import { isAdminRole } from "../core/ApiSchemas";
import { GameEnv, ServerConfig } from "../core/configuration/Config";
import { GameEnv } from "../core/configuration/Config";
import { GameType } from "../core/game/Game";
import {
ClientID,
@@ -28,6 +28,7 @@ import { createPartialGameRecord } from "../core/Util";
import { archive, finalizeGameRecord } from "./Archive";
import { Client } from "./Client";
import { ClientMsgRateLimiter } from "./ClientMsgRateLimiter";
import { ServerEnv } from "./ServerEnv";
export enum GamePhase {
Lobby = "LOBBY",
Active = "ACTIVE",
@@ -96,7 +97,6 @@ export class GameServer {
public readonly id: string,
readonly log_: Logger,
public readonly createdAt: number,
private config: ServerConfig,
public gameConfig: GameConfig,
private creatorPersistentID?: string,
private startsAt?: number,
@@ -236,7 +236,7 @@ export class GameServer {
return "rejected";
}
if (this.config.env() === GameEnv.Prod) {
if (ServerEnv.env() === GameEnv.Prod) {
// Prevent multiple clients from using the same account in prod
const conflicting = this.activeClients.find(
(c) =>
@@ -751,7 +751,7 @@ export class GameServer {
this.endTurnIntervalID = setInterval(
() => this.endTurn(),
this.config.turnIntervalMs(),
ServerEnv.turnIntervalMs(),
);
this.activeClients.forEach((c) => {
this.log.info("sending start message", {
+5 -7
View File
@@ -7,22 +7,20 @@ import {
import { OpenTelemetryTransportV3 } from "@opentelemetry/winston-transport";
import * as dotenv from "dotenv";
import winston from "winston";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { getOtelResource } from "./OtelResource";
import { ServerEnv } from "./ServerEnv";
dotenv.config();
const config = getServerConfigFromServer();
const resource = getOtelResource();
if (config.otelEnabled()) {
if (ServerEnv.otelEnabled()) {
console.log("OTEL enabled");
// Configure OpenTelemetry endpoint with basic auth (if provided)
const headers: Record<string, string> = {};
headers["Authorization"] = "Basic " + config.otelAuthHeader();
headers["Authorization"] = "Basic " + ServerEnv.otelAuthHeader();
// Add OTLP exporter for logs
const logExporter = new OTLPLogExporter({
url: `${config.otelEndpoint()}/v1/logs`,
url: `${ServerEnv.otelEndpoint()}/v1/logs`,
headers,
});
@@ -58,7 +56,7 @@ const logger = winston.createLogger({
),
defaultMeta: {
service: "openfront",
environment: process.env.GAME_ENV ?? "prod",
environment: ServerEnv.gameEnvName(),
},
transports: [
new winston.transports.Console(),
+1 -1
View File
@@ -1,4 +1,4 @@
import { SAM_CONSTRUCTION_TICKS } from "../core/configuration/DefaultConfig";
import { SAM_CONSTRUCTION_TICKS } from "../core/configuration/Config";
import {
Difficulty,
Duos,
+5 -12
View File
@@ -6,15 +6,14 @@ import http from "http";
import path from "path";
import { fileURLToPath } from "url";
import { GameEnv } from "../core/configuration/Config";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
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 config = getServerConfigFromServer();
const playlist = new MapPlaylist();
let lobbyService: MasterLobbyService;
@@ -79,16 +78,16 @@ export async function startMaster() {
}
log.info(`Primary ${process.pid} is running`);
log.info(`Setting up ${config.numWorkers()} workers...`);
log.info(`Setting up ${ServerEnv.numWorkers()} workers...`);
lobbyService = new MasterLobbyService(config, playlist, log);
lobbyService = new MasterLobbyService(playlist, log);
// Generate admin token for worker authentication
const ADMIN_TOKEN = crypto.randomBytes(16).toString("hex");
process.env.ADMIN_TOKEN = ADMIN_TOKEN;
const INSTANCE_ID =
config.env() === GameEnv.Dev
ServerEnv.env() === GameEnv.Dev
? "DEV_ID"
: crypto.randomBytes(4).toString("hex");
process.env.INSTANCE_ID = INSTANCE_ID;
@@ -96,7 +95,7 @@ export async function startMaster() {
log.info(`Instance ID: ${INSTANCE_ID}`);
// Fork workers
for (let i = 0; i < config.numWorkers(); i++) {
for (let i = 0; i < ServerEnv.numWorkers(); i++) {
const worker = cluster.fork({
WORKER_ID: i,
ADMIN_TOKEN,
@@ -151,12 +150,6 @@ app.get("/api/health", (_req, res) => {
}
});
app.get("/api/instance", (_req, res) => {
res.json({
instanceId: process.env.INSTANCE_ID ?? "undefined",
});
});
// SPA fallback route
app.get("/{*splat}", async function (_req, res) {
try {
+6 -8
View File
@@ -1,6 +1,5 @@
import { Worker } from "cluster";
import winston from "winston";
import { ServerConfig } from "../core/configuration/Config";
import { PublicGameInfo, PublicGameType } from "../core/Schemas";
import { generateID } from "../core/Util";
import {
@@ -12,9 +11,9 @@ import {
import { logger } from "./Logger";
import { MapPlaylist } from "./MapPlaylist";
import { startPolling } from "./PollingLoop";
import { ServerEnv } from "./ServerEnv";
export interface MasterLobbyServiceOptions {
config: ServerConfig;
playlist: MapPlaylist;
log: typeof logger;
}
@@ -27,7 +26,6 @@ export class MasterLobbyService {
private started = false;
constructor(
private config: ServerConfig,
private playlist: MapPlaylist,
private log: winston.Logger,
) {}
@@ -63,16 +61,16 @@ export class MasterLobbyService {
isHealthy(): boolean {
// We consider the lobby service healthy if at least half of the workers are ready.
// This allows for some leeway if a worker crashes.
const minWorkers = Math.max(this.config.numWorkers() / 2, 1);
const minWorkers = Math.max(ServerEnv.numWorkers() / 2, 1);
return this.started && this.readyWorkers.size >= minWorkers;
}
private handleWorkerReady(workerId: number) {
this.readyWorkers.add(workerId);
this.log.info(
`Worker ${workerId} is ready. (${this.readyWorkers.size}/${this.config.numWorkers()} ready)`,
`Worker ${workerId} is ready. (${this.readyWorkers.size}/${ServerEnv.numWorkers()} ready)`,
);
if (this.readyWorkers.size === this.config.numWorkers() && !this.started) {
if (this.readyWorkers.size === ServerEnv.numWorkers() && !this.started) {
this.started = true;
this.log.info("All workers ready, starting game scheduling");
startPolling(async () => this.broadcastLobbies(), 500);
@@ -145,7 +143,7 @@ export class MasterLobbyService {
this.sendMessageToWorker({
type: "updateLobby",
gameID: nextLobby.gameID,
startsAt: Date.now() + this.config.gameCreationRate(),
startsAt: Date.now() + ServerEnv.gameCreationRate(),
});
}
@@ -163,7 +161,7 @@ export class MasterLobbyService {
}
private sendMessageToWorker(msg: MasterCreateGame | MasterUpdateGame): void {
const workerId = this.config.workerIndex(msg.gameID);
const workerId = ServerEnv.workerIndex(msg.gameID);
const worker = this.workers.get(workerId);
if (!worker) {
this.log.error(`Worker ${workerId} not found`);
+9 -11
View File
@@ -3,9 +3,7 @@ import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
const config = getServerConfigFromServer();
import { ServerEnv } from "./ServerEnv";
export function getOtelResource() {
return resourceFromAttributes({
@@ -16,14 +14,14 @@ export function getOtelResource() {
}
export function getPromLabels() {
const workerId = ServerEnv.workerId();
return {
"service.instance.id": process.env.HOSTNAME,
"openfront.environment": config.env(),
"openfront.host": process.env.HOST,
"openfront.domain": process.env.DOMAIN,
"openfront.subdomain": process.env.SUBDOMAIN,
"openfront.component": process.env.WORKER_ID
? "Worker " + process.env.WORKER_ID
: "Master",
"service.instance.id": ServerEnv.hostname(),
"openfront.environment": ServerEnv.env(),
"openfront.host": ServerEnv.host(),
"openfront.domain": ServerEnv.domain(),
"openfront.subdomain": ServerEnv.subdomain(),
"openfront.component":
workerId !== undefined ? "Worker " + workerId : "Master",
};
}
+8 -3
View File
@@ -4,6 +4,7 @@ import fs from "fs/promises";
import { buildAssetUrl } from "../core/AssetUrls";
import { setNoStoreHeaders } from "./NoStoreHeaders";
import { getRuntimeAssetManifest } from "./RuntimeAssetManifest";
import { ServerEnv } from "./ServerEnv";
const APP_SHELL_CACHE_CONTROL =
"public, max-age=0, s-maxage=300, stale-while-revalidate=86400";
@@ -13,9 +14,9 @@ const appShellContentCache = new Map<string, Promise<string>>();
export async function renderHtmlContent(htmlPath: string): Promise<string> {
const htmlContent = await fs.readFile(htmlPath, "utf-8");
const assetManifest = await getRuntimeAssetManifest();
const cdnBase = process.env.CDN_BASE ?? "";
const cdnBase = ServerEnv.cdnBase();
return ejs.render(htmlContent, {
gitCommit: JSON.stringify(process.env.GIT_COMMIT ?? "undefined"),
gitCommit: JSON.stringify(ServerEnv.gitCommit()),
assetManifest: JSON.stringify(assetManifest),
cdnBase: JSON.stringify(cdnBase),
// Raw (unquoted) value for use as a URL prefix in the index.html template,
@@ -23,7 +24,11 @@ export async function renderHtmlContent(htmlPath: string): Promise<string> {
// build plugin inject-cdn-base-template rewrites Vite's emitted /assets/
// refs to use this placeholder.
cdnBaseRaw: cdnBase,
gameEnv: JSON.stringify(process.env.GAME_ENV ?? "dev"),
gameEnv: JSON.stringify(ServerEnv.gameEnvName()),
numWorkers: JSON.stringify(ServerEnv.numWorkers()),
turnstileSiteKey: JSON.stringify(ServerEnv.turnstileSiteKey()),
jwtAudience: JSON.stringify(ServerEnv.jwtAudience()),
instanceId: JSON.stringify(ServerEnv.instanceId()),
manifestHref: buildAssetUrl("manifest.json", assetManifest, cdnBase),
faviconHref: buildAssetUrl("images/Favicon.svg", assetManifest, cdnBase),
gameplayScreenshotUrl: buildAssetUrl(
+177
View File
@@ -0,0 +1,177 @@
import { JWK } from "jose";
import { z } from "zod";
import { GameEnv, parseGameEnv } from "../core/configuration/Config";
import { GameID } from "../core/Schemas";
import { simpleHash } from "../core/Util";
const JwksSchema = z.object({
keys: z
.object({
alg: z.literal("EdDSA"),
crv: z.literal("Ed25519"),
kty: z.literal("OKP"),
x: z.string(),
})
.array()
.min(1),
});
export class ServerEnv {
private static readonly gameEnv: GameEnv = parseGameEnv(process.env.GAME_ENV);
private static publicKey: JWK | null = null;
// Values that also flow to the client via index.html, but on the server
// are read from process.env directly. Server code never reaches into
// ClientEnv — that's reserved for the browser/worker hydrated path.
//
// TODO: the following methods are duplicated on ClientEnv. The two classes
// read from different sources (process.env vs window.BOOTSTRAP_CONFIG) but
// the derived logic is identical. Consolidate into a shared helper that
// takes a source so we don't have to keep them in sync by hand.
static env(): GameEnv {
return ServerEnv.gameEnv;
}
static gameEnvName(): string {
switch (ServerEnv.gameEnv) {
case GameEnv.Dev:
return "dev";
case GameEnv.Preprod:
return "staging";
case GameEnv.Prod:
return "prod";
}
}
static numWorkers(): number {
const raw = process.env.NUM_WORKERS;
if (!raw) {
throw new Error("NUM_WORKERS not set");
}
const n = parseInt(raw, 10);
if (!Number.isFinite(n) || n <= 0) {
throw new Error(`Invalid NUM_WORKERS: ${raw}`);
}
return n;
}
static turnstileSiteKey(): string {
const v = process.env.TURNSTILE_SITE_KEY;
if (!v) {
throw new Error("TURNSTILE_SITE_KEY not set");
}
return v;
}
static jwtAudience(): string {
const v = process.env.DOMAIN;
if (!v) {
throw new Error("DOMAIN not set");
}
return v;
}
static instanceId(): string {
return process.env.INSTANCE_ID ?? "";
}
static workerId(): number | undefined {
const raw = process.env.WORKER_ID;
if (raw === undefined) return undefined;
return parseInt(raw, 10);
}
static hostname(): string {
return process.env.HOSTNAME ?? "";
}
static host(): string {
return process.env.HOST ?? "";
}
static cdnBase(): string {
return process.env.CDN_BASE ?? "";
}
static jwtIssuer(): string {
const audience = ServerEnv.jwtAudience();
return audience === "localhost"
? "http://localhost:8787"
: `https://api.${audience}`;
}
static async jwkPublicKey(): Promise<JWK> {
if (ServerEnv.publicKey) return ServerEnv.publicKey;
const jwksUrl = ServerEnv.jwtIssuer() + "/.well-known/jwks.json";
console.log(`Fetching JWKS from ${jwksUrl}`);
const response = await fetch(jwksUrl);
if (!response.ok) {
const body = await response.text();
throw new Error(`JWKS fetch failed: ${response.status} ${body}`);
}
const result = JwksSchema.safeParse(await response.json());
if (!result.success) {
const error = z.prettifyError(result.error);
console.error("Error parsing JWKS", error);
throw new Error("Invalid JWKS");
}
ServerEnv.publicKey = result.data.keys[0];
return ServerEnv.publicKey;
}
static turnIntervalMs(): number {
return 100;
}
static gameCreationRate(): number {
return ServerEnv.gameEnv === GameEnv.Dev ? 5 * 1000 : 2 * 60 * 1000;
}
static workerIndex(gameID: GameID): number {
return simpleHash(gameID) % ServerEnv.numWorkers();
}
static workerPath(gameID: GameID): string {
return `w${ServerEnv.workerIndex(gameID)}`;
}
static workerPort(gameID: GameID): number {
return ServerEnv.workerPortByIndex(ServerEnv.workerIndex(gameID));
}
static workerPortByIndex(index: number): number {
return 3001 + index;
}
// Server-only env values
static domain(): string {
return process.env.DOMAIN ?? "";
}
static subdomain(): string {
return process.env.SUBDOMAIN ?? "";
}
static otelEnabled(): boolean {
return (
ServerEnv.gameEnv !== GameEnv.Dev &&
Boolean(ServerEnv.otelEndpoint()) &&
Boolean(ServerEnv.otelAuthHeader())
);
}
static otelEndpoint(): string {
return process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "";
}
static otelAuthHeader(): string {
return process.env.OTEL_AUTH_HEADER ?? "";
}
static gitCommit(): string {
const v = process.env.GIT_COMMIT;
if (!v) {
throw new Error("GIT_COMMIT not set");
}
return v;
}
static apiKey(): string {
return process.env.API_KEY ?? "";
}
static adminHeader(): string {
return "x-admin-key";
}
static adminToken(): string {
const token = process.env.ADMIN_TOKEN;
if (!token) {
throw new Error("ADMIN_TOKEN not set");
}
return token;
}
static allowedFlares(): string[] | undefined {
const raw = process.env.ALLOWED_FLARES;
if (!raw) return undefined;
return raw
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
}
+3 -4
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { ServerConfig } from "../core/configuration/Config";
import { ServerEnv } from "./ServerEnv";
const TurnstileVerdictSchema = z.discriminatedUnion("status", [
z.object({ status: z.literal("approved") }),
@@ -15,7 +15,6 @@ export type TurnstileResponse =
export async function verifyTurnstileToken(
ip: string,
turnstileToken: string | null,
config: ServerConfig,
): Promise<TurnstileResponse> {
if (!turnstileToken) {
return { status: "rejected", reason: "No turnstile token provided" };
@@ -25,11 +24,11 @@ export async function verifyTurnstileToken(
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
const response = await fetch(`${config.jwtIssuer()}/turnstile`, {
const response = await fetch(`${ServerEnv.jwtIssuer()}/turnstile`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": config.apiKey(),
"x-api-key": ServerEnv.apiKey(),
},
body: JSON.stringify({ ip, token: turnstileToken }),
signal: controller.signal,
+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--;
+4 -7
View File
@@ -4,28 +4,25 @@ import {
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import * as dotenv from "dotenv";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { GameManager } from "./GameManager";
import { getOtelResource, getPromLabels } from "./OtelResource";
import { ServerEnv } from "./ServerEnv";
dotenv.config();
export function initWorkerMetrics(gameManager: GameManager): void {
// Get server configuration
const config = getServerConfigFromServer();
// Create resource with worker information
const resource = getOtelResource();
// Configure auth headers
const headers: Record<string, string> = {};
if (config.otelEnabled()) {
headers["Authorization"] = "Basic " + config.otelAuthHeader();
if (ServerEnv.otelEnabled()) {
headers["Authorization"] = "Basic " + ServerEnv.otelAuthHeader();
}
// Create metrics exporter
const metricExporter = new OTLPMetricExporter({
url: `${config.otelEndpoint()}/v1/metrics`,
url: `${ServerEnv.otelEndpoint()}/v1/metrics`,
headers,
});
+8 -9
View File
@@ -6,8 +6,9 @@ import {
UserMeResponse,
UserMeResponseSchema,
} from "../core/ApiSchemas";
import { GameEnv, ServerConfig } from "../core/configuration/Config";
import { GameEnv } from "../core/configuration/Config";
import { PersistentIdSchema } from "../core/Schemas";
import { ServerEnv } from "./ServerEnv";
type TokenVerificationResult =
| {
@@ -19,10 +20,9 @@ type TokenVerificationResult =
export async function verifyClientToken(
token: string,
config: ServerConfig,
): Promise<TokenVerificationResult> {
if (PersistentIdSchema.safeParse(token).success) {
if (config.env() === GameEnv.Dev) {
if (ServerEnv.env() === GameEnv.Dev) {
return { type: "success", persistentId: token, claims: null };
} else {
return {
@@ -32,9 +32,9 @@ export async function verifyClientToken(
}
}
try {
const issuer = config.jwtIssuer();
const audience = config.jwtAudience();
const key = await config.jwkPublicKey();
const issuer = ServerEnv.jwtIssuer();
const audience = ServerEnv.jwtAudience();
const key = await ServerEnv.jwkPublicKey();
const { payload } = await jwtVerify(token, key, {
algorithms: ["EdDSA"],
issuer,
@@ -64,17 +64,16 @@ export async function verifyClientToken(
export async function getUserMe(
token: string,
config: ServerConfig,
): Promise<
| { type: "success"; response: UserMeResponse }
| { type: "error"; message: string }
> {
try {
// Get the user object
const response = await fetch(config.jwtIssuer() + "/users/@me", {
const response = await fetch(ServerEnv.jwtIssuer() + "/users/@me", {
headers: {
authorization: `Bearer ${token}`,
"x-api-key": config.apiKey(),
"x-api-key": ServerEnv.apiKey(),
},
});
if (response.status !== 200) {