From a910ef45c668cce7f6402a6f7a23b23fd48e296a Mon Sep 17 00:00:00 2001 From: Aleksey Orekhovsky Date: Thu, 7 Aug 2025 13:49:58 +0700 Subject: [PATCH 01/17] Pull replay files from api --- src/core/configuration/Config.ts | 1 + src/core/configuration/DefaultConfig.ts | 3 ++ src/server/Archive.ts | 62 +++++++++++++++++++++---- tests/util/TestServerConfig.ts | 3 ++ 4 files changed, 60 insertions(+), 9 deletions(-) diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 35090ad099..f81074ee37 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -62,6 +62,7 @@ export interface ServerConfig { cloudflareCredsPath(): string; stripePublishableKey(): string; allowedFlares(): string[] | undefined; + replayFallbackUrl(gameId: GameID): string; } export interface NukeMagnitude { diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index c85302cf5f..78cef87857 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -205,6 +205,9 @@ export abstract class DefaultServerConfig implements ServerConfig { workerPortByIndex(index: number): number { return 3001 + index; } + replayFallbackUrl(gameId: GameID): string { + return `https://api.openfront.io/game/${gameId}`; + } } export class DefaultConfig implements Config { diff --git a/src/server/Archive.ts b/src/server/Archive.ts index 6b3675d94c..47c8af7fee 100644 --- a/src/server/Archive.ts +++ b/src/server/Archive.ts @@ -140,18 +140,62 @@ export async function readGameRecord( log.error( `${gameId}: Error reading game record from R2. Non-Error type: ${String(error)}`, ); - return null; + } else { + const { message, stack, name } = error; + // Log the error for monitoring purposes + log.error(`${gameId}: Error reading game record from R2: ${error}`, { + message: message, + stack: stack, + name: name, + ...(error && typeof error === "object" ? error : {}), + }); } - const { message, stack, name } = error; - // Log the error for monitoring purposes - log.error(`${gameId}: Error reading game record from R2: ${error}`, { - message: message, - stack: stack, - name: name, - ...(error && typeof error === "object" ? error : {}), + return readGameRecordFallback(gameId); + } +} + +export async function readGameRecordFallback( + gameId: GameID, +): Promise { + try { + const response = await fetch(config.replayFallbackUrl(gameId), { + headers: { + Accept: "application/json", + }, }); - // Return null instead of throwing the error + if (!response.ok) { + throw new Error( + `Http error: non-successful http status ${response.status}`, + ); + } + + const contentType = response.headers.get("Content-Type")?.split(";")?.[0]; + if (contentType !== "application/json") { + throw new Error( + `Http error: unexpected content type "${response.headers.get("Content-Type")}"`, + ); + } + + return await response.json(); + } catch (error: unknown) { + // If the error is not an instance of Error, log it as a string + if (!(error instanceof Error)) { + log.error( + `${gameId}: Error reading game record from public api. Non-Error type: ${String(error)}`, + ); + return null; + } + const { message, stack, name } = error; + log.error( + `${gameId}: Error reading game record from public api: ${error}`, + { + message: message, + stack: stack, + name: name, + ...(error && typeof error === "object" ? error : {}), + }, + ); return null; } } diff --git a/tests/util/TestServerConfig.ts b/tests/util/TestServerConfig.ts index 86a277d65a..3bb6542dcb 100644 --- a/tests/util/TestServerConfig.ts +++ b/tests/util/TestServerConfig.ts @@ -94,4 +94,7 @@ export class TestServerConfig implements ServerConfig { r2SecretKey(): string { throw new Error("Method not implemented."); } + replayFallbackUrl(gameId: GameID): string { + throw new Error("Method not implemented."); + } } From e5de4d50f646488b606c145c3177b23c8768d8cf Mon Sep 17 00:00:00 2001 From: Aleksey Orekhovsky Date: Thu, 7 Aug 2025 14:38:30 +0700 Subject: [PATCH 02/17] Switch log level from error to info --- src/server/Archive.ts | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/server/Archive.ts b/src/server/Archive.ts index 47c8af7fee..912ab4fabc 100644 --- a/src/server/Archive.ts +++ b/src/server/Archive.ts @@ -137,13 +137,13 @@ export async function readGameRecord( } catch (error: unknown) { // If the error is not an instance of Error, log it as a string if (!(error instanceof Error)) { - log.error( + log.info( `${gameId}: Error reading game record from R2. Non-Error type: ${String(error)}`, ); } else { const { message, stack, name } = error; // Log the error for monitoring purposes - log.error(`${gameId}: Error reading game record from R2: ${error}`, { + log.info(`${gameId}: Error reading game record from R2: ${error}`, { message: message, stack: stack, name: name, @@ -181,21 +181,18 @@ export async function readGameRecordFallback( } catch (error: unknown) { // If the error is not an instance of Error, log it as a string if (!(error instanceof Error)) { - log.error( + log.info( `${gameId}: Error reading game record from public api. Non-Error type: ${String(error)}`, ); return null; } const { message, stack, name } = error; - log.error( - `${gameId}: Error reading game record from public api: ${error}`, - { - message: message, - stack: stack, - name: name, - ...(error && typeof error === "object" ? error : {}), - }, - ); + log.info(`${gameId}: Error reading game record from public api: ${error}`, { + message: message, + stack: stack, + name: name, + ...(error && typeof error === "object" ? error : {}), + }); return null; } } From 8e1a1af0311d31d82fa87a0c8783c551b992b089 Mon Sep 17 00:00:00 2001 From: Aleksey Orekhovsky Date: Thu, 7 Aug 2025 14:46:37 +0700 Subject: [PATCH 03/17] Add warning for empty response and fallback to `readGameRecordFallback()` --- src/server/Archive.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/server/Archive.ts b/src/server/Archive.ts index 912ab4fabc..c61801b3f5 100644 --- a/src/server/Archive.ts +++ b/src/server/Archive.ts @@ -131,7 +131,11 @@ export async function readGameRecord( Key: `${gameFolder}/${gameId}`, // Fixed - needed to include gameFolder }); // Parse the response body - if (response.Body === undefined) return null; + if (response.Body === undefined) { + log.warn(`${gameId}: Received empty response from R2`); + return readGameRecordFallback(gameId); + } + const bodyContents = await response.Body.transformToString(); return JSON.parse(bodyContents) as GameRecord; } catch (error: unknown) { From 4a80385d0ebb98e505c65fc1e68824e905e375a0 Mon Sep 17 00:00:00 2001 From: Aleksey Orekhovsky Date: Thu, 7 Aug 2025 15:03:06 +0700 Subject: [PATCH 04/17] Update `replayFallbackUrl()` logic for improved URL construction --- src/core/configuration/DefaultConfig.ts | 4 +++- src/core/configuration/DevConfig.ts | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 78cef87857..9f3c504032 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -206,7 +206,9 @@ export abstract class DefaultServerConfig implements ServerConfig { return 3001 + index; } replayFallbackUrl(gameId: GameID): string { - return `https://api.openfront.io/game/${gameId}`; + const url = new URL(this.jwtIssuer()); + url.pathname = `/game/${gameId}`; + return url.toString(); } } diff --git a/src/core/configuration/DevConfig.ts b/src/core/configuration/DevConfig.ts index 3a14b39bba..9ed538455d 100644 --- a/src/core/configuration/DevConfig.ts +++ b/src/core/configuration/DevConfig.ts @@ -1,6 +1,6 @@ import { UnitInfo, UnitType } from "../game/Game"; import { UserSettings } from "../game/UserSettings"; -import { GameConfig } from "../Schemas"; +import { GameConfig, GameID } from "../Schemas"; import { GameEnv, ServerConfig } from "./Config"; import { DefaultConfig, DefaultServerConfig } from "./DefaultConfig"; @@ -42,6 +42,10 @@ export class DevServerConfig extends DefaultServerConfig { subdomain(): string { return ""; } + + replayFallbackUrl(gameId: GameID): string { + return `https://api.openfront.io/game/${gameId}`; + } } export class DevConfig extends DefaultConfig { From adffbb720841287b2791f5f1ebc857f7503d8ed4 Mon Sep 17 00:00:00 2001 From: Scott Anderson <662325+scottanderson@users.noreply.github.com> Date: Thu, 7 Aug 2025 09:18:13 -0400 Subject: [PATCH 05/17] RedactedGameRecord --- src/core/Schemas.ts | 13 +++++++++ src/server/Archive.ts | 62 +++++++++++++++++++++++++++++++++---------- 2 files changed, 61 insertions(+), 14 deletions(-) diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index e59b06d281..b2f2fd91f2 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -558,3 +558,16 @@ export const GameRecordSchema = AnalyticsRecordSchema.extend({ turns: TurnSchema.array(), }); export type GameRecord = z.infer; + +export const RedactedGameRecordSchema = GameRecordSchema.omit({ + info: true, +}).extend({ + info: GameEndInfoSchema.omit({ + players: true, + }).extend({ + players: PlayerRecordSchema.omit({ + persistentID: true, + }).array(), + }), +}); +export type RedactedGameRecord = z.infer; diff --git a/src/server/Archive.ts b/src/server/Archive.ts index c61801b3f5..d4ad185278 100644 --- a/src/server/Archive.ts +++ b/src/server/Archive.ts @@ -26,14 +26,14 @@ export async function archive(gameRecord: GameRecord) { try { gameRecord.gitCommit = config.gitCommit(); // Archive to R2 - await archiveAnalyticsToR2(gameRecord); + await archiveAnalyticsToR2(stripTurns(gameRecord)); // Archive full game if there are turns if (gameRecord.turns.length > 0) { log.info( `${gameRecord.info.gameID}: game has more than zero turns, attempting to write to full game to R2`, ); - await archiveFullGameToR2(gameRecord); + await archiveFullGameToR2(stripPerisistentIds(gameRecord)); } } catch (error: unknown) { // If the error is not an instance of Error, log it as a string @@ -54,7 +54,7 @@ export async function archive(gameRecord: GameRecord) { } } -async function archiveAnalyticsToR2(gameRecord: GameRecord) { +function stripTurns(gameRecord: GameRecord): AnalyticsRecord { // Create analytics data object const { info, version, gitCommit, subdomain, domain } = gameRecord; const analyticsData: AnalyticsRecord = { @@ -64,7 +64,49 @@ async function archiveAnalyticsToR2(gameRecord: GameRecord) { subdomain, domain, }; + return analyticsData; +} + +function stripPerisistentIds(gameRecord: GameRecord): GameRecord { + // Create replay object + const { + info: { + gameID, + config, + players: privatePlayers, + start, + end, + duration, + num_turns, + winner, + }, + version, + gitCommit, + subdomain, + domain, + turns, + } = gameRecord; + const players = privatePlayers.map( + ({ clientID, persistentID: _, username, pattern, flag }) => ({ + clientID, + username, + pattern, + flag, + }), + ); + const replayData: GameRecord = { + info: { gameID, config, players, start, end, duration, num_turns, winner }, + version, + gitCommit, + subdomain, + domain, + turns, + }; + return replayData; +} +async function archiveAnalyticsToR2(gameRecord: AnalyticsRecord) { + const { info } = gameRecord; try { // Store analytics data using just the game ID as the key const analyticsKey = `${info.gameID}.json`; @@ -72,7 +114,7 @@ async function archiveAnalyticsToR2(gameRecord: GameRecord) { await r2.putObject({ Bucket: bucket, Key: `${analyticsFolder}/${analyticsKey}`, - Body: JSON.stringify(analyticsData, replacer), + Body: JSON.stringify(gameRecord, replacer), ContentType: "application/json", }); @@ -98,19 +140,11 @@ async function archiveAnalyticsToR2(gameRecord: GameRecord) { } async function archiveFullGameToR2(gameRecord: GameRecord) { - // Create a deep copy to avoid modifying the original - const recordCopy = structuredClone(gameRecord); - - // Players may see this so make sure to clear PII - recordCopy.info.players.forEach((p) => { - p.persistentID = "REDACTED"; - }); - try { await r2.putObject({ Bucket: bucket, - Key: `${gameFolder}/${recordCopy.info.gameID}`, - Body: JSON.stringify(recordCopy, replacer), + Key: `${gameFolder}/${gameRecord.info.gameID}`, + Body: JSON.stringify(gameRecord, replacer), ContentType: "application/json", }); } catch (error) { From b990aa23ec5475a40fba117de8c7b96cbf687bf6 Mon Sep 17 00:00:00 2001 From: Aleksey Orekhovsky Date: Thu, 7 Aug 2025 20:31:30 +0700 Subject: [PATCH 06/17] Refactor Archive.ts to use RedactedGameRecord --- src/server/Archive.ts | 40 +++++++++++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/src/server/Archive.ts b/src/server/Archive.ts index d4ad185278..8194842324 100644 --- a/src/server/Archive.ts +++ b/src/server/Archive.ts @@ -1,6 +1,13 @@ import { S3 } from "@aws-sdk/client-s3"; +import z from "zod"; import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; -import { AnalyticsRecord, GameID, GameRecord } from "../core/Schemas"; +import { + AnalyticsRecord, + GameID, + GameRecord, + RedactedGameRecord, + RedactedGameRecordSchema, +} from "../core/Schemas"; import { replacer } from "../core/Util"; import { logger } from "./Logger"; @@ -33,7 +40,7 @@ export async function archive(gameRecord: GameRecord) { log.info( `${gameRecord.info.gameID}: game has more than zero turns, attempting to write to full game to R2`, ); - await archiveFullGameToR2(stripPerisistentIds(gameRecord)); + await archiveFullGameToR2(stripPersistentIds(gameRecord)); } } catch (error: unknown) { // If the error is not an instance of Error, log it as a string @@ -67,7 +74,7 @@ function stripTurns(gameRecord: GameRecord): AnalyticsRecord { return analyticsData; } -function stripPerisistentIds(gameRecord: GameRecord): GameRecord { +function stripPersistentIds(gameRecord: GameRecord): RedactedGameRecord { // Create replay object const { info: { @@ -94,7 +101,7 @@ function stripPerisistentIds(gameRecord: GameRecord): GameRecord { flag, }), ); - const replayData: GameRecord = { + const replayData: RedactedGameRecord = { info: { gameID, config, players, start, end, duration, num_turns, winner }, version, gitCommit, @@ -139,7 +146,7 @@ async function archiveAnalyticsToR2(gameRecord: AnalyticsRecord) { } } -async function archiveFullGameToR2(gameRecord: GameRecord) { +async function archiveFullGameToR2(gameRecord: RedactedGameRecord) { try { await r2.putObject({ Bucket: bucket, @@ -157,7 +164,7 @@ async function archiveFullGameToR2(gameRecord: GameRecord) { export async function readGameRecord( gameId: GameID, -): Promise { +): Promise { try { // Check if file exists and download in one operation const response = await r2.getObject({ @@ -171,7 +178,7 @@ export async function readGameRecord( } const bodyContents = await response.Body.transformToString(); - return JSON.parse(bodyContents) as GameRecord; + return validateRecord(JSON.parse(bodyContents), gameId); } catch (error: unknown) { // If the error is not an instance of Error, log it as a string if (!(error instanceof Error)) { @@ -194,7 +201,7 @@ export async function readGameRecord( export async function readGameRecordFallback( gameId: GameID, -): Promise { +): Promise { try { const response = await fetch(config.replayFallbackUrl(gameId), { headers: { @@ -215,7 +222,7 @@ export async function readGameRecordFallback( ); } - return await response.json(); + return validateRecord(await response.json(), gameId); } catch (error: unknown) { // If the error is not an instance of Error, log it as a string if (!(error instanceof Error)) { @@ -235,6 +242,21 @@ export async function readGameRecordFallback( } } +function validateRecord( + json: unknown, + gameId: GameID, +): RedactedGameRecord | null { + const parsed = RedactedGameRecordSchema.safeParse(json); + + if (!parsed.success) { + const error = z.prettifyError(parsed.error); + log.error(`${gameId}: Error parsing game record: ${error}`); + return null; + } + + return parsed.data; +} + export async function gameRecordExists(gameId: GameID): Promise { try { await r2.headObject({ From 1eec5bf308dab22a1e2eb9fb382138edcb4d5213 Mon Sep 17 00:00:00 2001 From: Aleksey Orekhovsky Date: Thu, 7 Aug 2025 20:40:02 +0700 Subject: [PATCH 07/17] Switch to named import for `zod` in Archive.ts --- src/server/Archive.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/Archive.ts b/src/server/Archive.ts index 8194842324..3ac04a006d 100644 --- a/src/server/Archive.ts +++ b/src/server/Archive.ts @@ -1,5 +1,5 @@ import { S3 } from "@aws-sdk/client-s3"; -import z from "zod"; +import { z } from "zod"; import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; import { AnalyticsRecord, From b9e9eed390c2ca30bee2b70e0f78acea352155d6 Mon Sep 17 00:00:00 2001 From: Aleksey Orekhovsky Date: Fri, 8 Aug 2025 01:39:35 +0700 Subject: [PATCH 08/17] Fix bigint serialization --- src/server/Worker.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 9f7da2eb19..7c19db6f02 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -24,6 +24,7 @@ import { gatekeeper, LimiterType } from "./Gatekeeper"; import { getUserMe, verifyClientToken } from "./jwt"; import { logger } from "./Logger"; +import { replacer } from "../core/Util"; import { PrivilegeRefresher } from "./PrivilegeRefresher"; import { initWorkerMetrics } from "./WorkerMetrics"; @@ -256,11 +257,19 @@ export async function startWorker() { }); } - return res.status(200).json({ - success: true, - exists: true, - gameRecord: gameRecord, - }); + return res + .status(200) + .header("Content-Type", "application/json") + .send( + JSON.stringify( + { + success: true, + exists: true, + gameRecord: gameRecord, + }, + replacer, + ), + ); }), ); From fb3de918500f61fa806cbf742e84f6a4d9a9df8f Mon Sep 17 00:00:00 2001 From: Aleksey Orekhovsky Date: Fri, 8 Aug 2025 01:49:15 +0700 Subject: [PATCH 09/17] BigInt serialization via `json replacer`. --- src/server/Worker.ts | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 7c19db6f02..40f53b7b9d 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -82,6 +82,7 @@ export async function startWorker() { }); app.set("trust proxy", 3); + app.set("json replacer", replacer); // BigInt serialization app.use(express.json()); app.use(express.static(path.join(__dirname, "../../out"))); app.use( @@ -257,19 +258,11 @@ export async function startWorker() { }); } - return res - .status(200) - .header("Content-Type", "application/json") - .send( - JSON.stringify( - { - success: true, - exists: true, - gameRecord: gameRecord, - }, - replacer, - ), - ); + return res.status(200).json({ + success: true, + exists: true, + gameRecord: gameRecord, + }); }), ); From 881a84332f907516f8b548a677ff50106d5b8323 Mon Sep 17 00:00:00 2001 From: Aleksey Orekhovsky Date: Fri, 8 Aug 2025 02:26:41 +0700 Subject: [PATCH 10/17] Update src/core/configuration/DevConfig.ts Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> --- src/core/configuration/DevConfig.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/configuration/DevConfig.ts b/src/core/configuration/DevConfig.ts index 9ed538455d..4167e34ed1 100644 --- a/src/core/configuration/DevConfig.ts +++ b/src/core/configuration/DevConfig.ts @@ -44,7 +44,9 @@ export class DevServerConfig extends DefaultServerConfig { } replayFallbackUrl(gameId: GameID): string { - return `https://api.openfront.io/game/${gameId}`; + const url = new URL("https://api.openfront.io"); + url.pathname = `/game/${gameId}`; + return url.toString(); } } From 7f4fb39422a963e7593f64d6720f45df241cf121 Mon Sep 17 00:00:00 2001 From: Aleksey Orekhovsky Date: Sat, 9 Aug 2025 11:16:35 +0700 Subject: [PATCH 11/17] Handle game record errors with specific messages in JoinPrivateLobbyModal --- resources/lang/en.json | 5 +++++ src/client/JoinPrivateLobbyModal.ts | 9 ++++++++ src/server/Archive.ts | 33 +++++++++++++++++------------ src/server/Worker.ts | 4 ++-- 4 files changed, 36 insertions(+), 15 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 103f0f579b..b0f87a75e8 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -601,5 +601,10 @@ "radial_menu": { "delete_unit_title": "Delete Unit", "delete_unit_description": "Click to delete the nearest unit" + }, + "record": { + "not_found": "Record not found", + "error": "Failed to read record", + "invalid_data": "Failed to parse record data" } } diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts index 1c875a7e74..1249964eae 100644 --- a/src/client/JoinPrivateLobbyModal.ts +++ b/src/client/JoinPrivateLobbyModal.ts @@ -261,6 +261,15 @@ export class JoinPrivateLobbyModal extends LitElement { }), ); + return true; + } else if (archiveData.error === "Game not found") { + this.message = translateText("record.not_found"); + return true; + } else if (archiveData.error === "Failed to read record") { + this.message = translateText("record.error"); + return true; + } else if (archiveData.error === "Failed to parse record data") { + this.message = translateText("record.invalid_data"); return true; } diff --git a/src/server/Archive.ts b/src/server/Archive.ts index 3ac04a006d..52c185c6d4 100644 --- a/src/server/Archive.ts +++ b/src/server/Archive.ts @@ -164,7 +164,7 @@ async function archiveFullGameToR2(gameRecord: RedactedGameRecord) { export async function readGameRecord( gameId: GameID, -): Promise { +): Promise { try { // Check if file exists and download in one operation const response = await r2.getObject({ @@ -201,7 +201,7 @@ export async function readGameRecord( export async function readGameRecordFallback( gameId: GameID, -): Promise { +): Promise { try { const response = await fetch(config.replayFallbackUrl(gameId), { headers: { @@ -210,6 +210,10 @@ export async function readGameRecordFallback( }); if (!response.ok) { + if (response.status === 404) { + return "Game not found"; + } + throw new Error( `Http error: non-successful http status ${response.status}`, ); @@ -229,29 +233,32 @@ export async function readGameRecordFallback( log.info( `${gameId}: Error reading game record from public api. Non-Error type: ${String(error)}`, ); - return null; + } else { + const { message, stack, name } = error; + log.info( + `${gameId}: Error reading game record from public api: ${error}`, + { + message: message, + stack: stack, + name: name, + ...(error && typeof error === "object" ? error : {}), + }, + ); } - const { message, stack, name } = error; - log.info(`${gameId}: Error reading game record from public api: ${error}`, { - message: message, - stack: stack, - name: name, - ...(error && typeof error === "object" ? error : {}), - }); - return null; + return "Failed to read record"; } } function validateRecord( json: unknown, gameId: GameID, -): RedactedGameRecord | null { +): RedactedGameRecord | string { const parsed = RedactedGameRecordSchema.safeParse(json); if (!parsed.success) { const error = z.prettifyError(parsed.error); log.error(`${gameId}: Error parsing game record: ${error}`); - return null; + return "Failed to parse record data"; } return parsed.data; diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 40f53b7b9d..dc15fd6c61 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -232,10 +232,10 @@ export async function startWorker() { gatekeeper.httpHandler(LimiterType.Get, async (req, res) => { const gameRecord = await readGameRecord(req.params.id); - if (!gameRecord) { + if (typeof gameRecord === "string") { return res.status(404).json({ success: false, - error: "Game not found", + error: gameRecord, exists: false, }); } From ff1e0a2178db6aaa1fccab95e4284178bc811fd1 Mon Sep 17 00:00:00 2001 From: Aleksey Orekhovsky Date: Sat, 9 Aug 2025 11:27:03 +0700 Subject: [PATCH 12/17] Add a timeout signal to request in readGameRecordFallback --- src/server/Archive.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/server/Archive.ts b/src/server/Archive.ts index 52c185c6d4..803ad7aa1e 100644 --- a/src/server/Archive.ts +++ b/src/server/Archive.ts @@ -207,6 +207,7 @@ export async function readGameRecordFallback( headers: { Accept: "application/json", }, + signal: AbortSignal.timeout(5000), }); if (!response.ok) { From 3420b1a50aaebc3baceaa8b8daf53d0e7a92d3a9 Mon Sep 17 00:00:00 2001 From: Aleksey Orekhovsky Date: Sat, 9 Aug 2025 11:56:51 +0700 Subject: [PATCH 13/17] Fix --- src/server/Worker.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 8c33dc36cd..a7ea0e0e70 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -10,12 +10,12 @@ import { GameEnv } from "../core/configuration/Config"; import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; import { GameType } from "../core/game/Game"; import { GameRecord, GameRecordSchema, ID } from "../core/Schemas"; +import { replacer } from "../core/Util"; import { CreateGameInputSchema, GameInputSchema } from "../core/WorkerSchemas"; import { archive, readGameRecord } from "./Archive"; import { GameManager } from "./GameManager"; import { gatekeeper, LimiterType } from "./Gatekeeper"; import { logger } from "./Logger"; -import { replacer } from "../core/Util"; import { PrivilegeRefresher } from "./PrivilegeRefresher"; import { preJoinMessageHandler } from "./worker/websocket/handler/message/PreJoinHandler"; import { initWorkerMetrics } from "./WorkerMetrics"; @@ -226,7 +226,6 @@ export async function startWorker() { if (typeof gameRecord === "string") { return res.status(404).json({ - success: false, error: gameRecord, exists: false, success: false, From cded04502485f2436447108529ba04047afdffe7 Mon Sep 17 00:00:00 2001 From: Aleksey Orekhovsky Date: Sat, 9 Aug 2025 14:46:14 +0700 Subject: [PATCH 14/17] Send the translation key over the network --- src/client/JoinPrivateLobbyModal.ts | 10 ++-------- src/server/Archive.ts | 6 +++--- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts index 1249964eae..33f15b63b7 100644 --- a/src/client/JoinPrivateLobbyModal.ts +++ b/src/client/JoinPrivateLobbyModal.ts @@ -262,14 +262,8 @@ export class JoinPrivateLobbyModal extends LitElement { ); return true; - } else if (archiveData.error === "Game not found") { - this.message = translateText("record.not_found"); - return true; - } else if (archiveData.error === "Failed to read record") { - this.message = translateText("record.error"); - return true; - } else if (archiveData.error === "Failed to parse record data") { - this.message = translateText("record.invalid_data"); + } else if (archiveData.error) { + this.message = translateText(archiveData.error); return true; } diff --git a/src/server/Archive.ts b/src/server/Archive.ts index d23f17f5fa..a16a97c44f 100644 --- a/src/server/Archive.ts +++ b/src/server/Archive.ts @@ -214,7 +214,7 @@ export async function readGameRecordFallback( if (!response.ok) { if (response.status === 404) { - return "Game not found"; + return "record.not_found"; } throw new Error( @@ -248,7 +248,7 @@ export async function readGameRecordFallback( }, ); } - return "Failed to read record"; + return "record.error"; } } @@ -261,7 +261,7 @@ function validateRecord( if (!parsed.success) { const error = z.prettifyError(parsed.error); log.error(`${gameId}: Error parsing game record: ${error}`); - return "Failed to parse record data"; + return "record.invalid_data"; } return parsed.data; From aee46a2896effbbdeb06d62661ddd58e87a167b8 Mon Sep 17 00:00:00 2001 From: Aleksey Orekhovsky Date: Sat, 9 Aug 2025 14:48:09 +0700 Subject: [PATCH 15/17] Update translation keys --- resources/lang/en.json | 8 ++++---- src/server/Archive.ts | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index b0f87a75e8..b81ef30a7f 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -602,9 +602,9 @@ "delete_unit_title": "Delete Unit", "delete_unit_description": "Click to delete the nearest unit" }, - "record": { - "not_found": "Record not found", - "error": "Failed to read record", - "invalid_data": "Failed to parse record data" + "replay": { + "not_found": "Replay data not found", + "error": "Failed to read replay data", + "invalid": "Failed to parse replay data" } } diff --git a/src/server/Archive.ts b/src/server/Archive.ts index a16a97c44f..826982a46a 100644 --- a/src/server/Archive.ts +++ b/src/server/Archive.ts @@ -214,7 +214,7 @@ export async function readGameRecordFallback( if (!response.ok) { if (response.status === 404) { - return "record.not_found"; + return "replay.not_found"; } throw new Error( @@ -248,7 +248,7 @@ export async function readGameRecordFallback( }, ); } - return "record.error"; + return "replay.error"; } } @@ -261,7 +261,7 @@ function validateRecord( if (!parsed.success) { const error = z.prettifyError(parsed.error); log.error(`${gameId}: Error parsing game record: ${error}`); - return "record.invalid_data"; + return "replay.invalid"; } return parsed.data; From 57358387522b48ac67a72fc6c3bf5121404ebf5b Mon Sep 17 00:00:00 2001 From: Aleksey Orekhovsky Date: Sat, 9 Aug 2025 14:51:48 +0700 Subject: [PATCH 16/17] Rename replayFallbackUrl to replayUrl --- src/core/configuration/Config.ts | 2 +- src/core/configuration/DefaultConfig.ts | 2 +- src/core/configuration/DevConfig.ts | 2 +- src/server/Archive.ts | 2 +- tests/util/TestServerConfig.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index f81074ee37..cb0436e251 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -62,7 +62,7 @@ export interface ServerConfig { cloudflareCredsPath(): string; stripePublishableKey(): string; allowedFlares(): string[] | undefined; - replayFallbackUrl(gameId: GameID): string; + replayUrl(gameId: GameID): string; } export interface NukeMagnitude { diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index a55ae60643..675f8fb870 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -205,7 +205,7 @@ export abstract class DefaultServerConfig implements ServerConfig { workerPortByIndex(index: number): number { return 3001 + index; } - replayFallbackUrl(gameId: GameID): string { + replayUrl(gameId: GameID): string { const url = new URL(this.jwtIssuer()); url.pathname = `/game/${gameId}`; return url.toString(); diff --git a/src/core/configuration/DevConfig.ts b/src/core/configuration/DevConfig.ts index 4167e34ed1..d531196933 100644 --- a/src/core/configuration/DevConfig.ts +++ b/src/core/configuration/DevConfig.ts @@ -43,7 +43,7 @@ export class DevServerConfig extends DefaultServerConfig { return ""; } - replayFallbackUrl(gameId: GameID): string { + replayUrl(gameId: GameID): string { const url = new URL("https://api.openfront.io"); url.pathname = `/game/${gameId}`; return url.toString(); diff --git a/src/server/Archive.ts b/src/server/Archive.ts index 826982a46a..13138adc63 100644 --- a/src/server/Archive.ts +++ b/src/server/Archive.ts @@ -205,7 +205,7 @@ export async function readGameRecordFallback( gameId: GameID, ): Promise { try { - const response = await fetch(config.replayFallbackUrl(gameId), { + const response = await fetch(config.replayUrl(gameId), { headers: { Accept: "application/json", }, diff --git a/tests/util/TestServerConfig.ts b/tests/util/TestServerConfig.ts index 3bb6542dcb..bb723af5f1 100644 --- a/tests/util/TestServerConfig.ts +++ b/tests/util/TestServerConfig.ts @@ -94,7 +94,7 @@ export class TestServerConfig implements ServerConfig { r2SecretKey(): string { throw new Error("Method not implemented."); } - replayFallbackUrl(gameId: GameID): string { + replayUrl(gameId: GameID): string { throw new Error("Method not implemented."); } } From 6730ab1f3017ee9b062d294c87bc74d5e424864b Mon Sep 17 00:00:00 2001 From: Aleksey Orekhovsky Date: Sat, 9 Aug 2025 15:44:05 +0700 Subject: [PATCH 17/17] Replace global json replacer with explicit JSON.stringify --- src/server/Worker.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 14428dd3c5..a662c942b6 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -78,7 +78,6 @@ export async function startWorker() { }); app.set("trust proxy", 3); - app.set("json replacer", replacer); // BigInt serialization app.use(express.json()); app.use(express.static(path.join(__dirname, "../../out"))); app.use( @@ -254,11 +253,19 @@ export async function startWorker() { }); } - return res.status(200).json({ - exists: true, - gameRecord: gameRecord, - success: true, - }); + return res + .status(200) + .header("Content-Type", "application/json") + .send( + JSON.stringify( + { + exists: true, + gameRecord: gameRecord, + success: true, + }, + replacer, + ), + ); }), );