mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-23 06:39:38 +00:00
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:
+8
-10
@@ -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 +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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { SAM_CONSTRUCTION_TICKS } from "../core/configuration/DefaultConfig";
|
||||
import { SAM_CONSTRUCTION_TICKS } from "../core/configuration/Config";
|
||||
import {
|
||||
Difficulty,
|
||||
Duos,
|
||||
|
||||
+5
-12
@@ -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 {
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user