From eec3b0e2bba1a224f353f25264b21cb3fe133cf1 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Thu, 11 Sep 2025 14:07:07 -0700 Subject: [PATCH] Fetch archived games from api, allow development against production & staging (#2045) ## Description: Instead of going through the game server to fetch archived games, have the client fetch from api directly. Also loosen up cors restrictions & domain checks so localhost:9000 can talk to staging or production servers related to #1571 ## 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 --- README.md | 16 +++++ package.json | 2 + resources/lang/en.json | 5 +- src/client/JoinPrivateLobbyModal.ts | 105 ++++++++++++++++++---------- src/client/jwt.ts | 16 +++-- src/server/Worker.ts | 39 +---------- webpack.config.js | 1 + 7 files changed, 103 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 99cbc6686..761bffc2a 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,22 @@ To run just the server with development settings: npm run start:server-dev ``` +### Connecting to staging or production backends + +Sometimes it's useful to connect to production servers when replaying a game, testing user profiles, purchases, or login flow. + +To connect to staging api servers: + +```bash +npm run dev:staging +``` + +To connect to production api servers: + +```bash +npm run dev:prod +``` + ## 🛠️ Development Tools - **Format code**: diff --git a/package.json b/package.json index ff51e1e23..89e61ed3a 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "start:server": "node --loader ts-node/esm --experimental-specifier-resolution=node src/server/Server.ts", "start:server-dev": "cross-env GAME_ENV=dev node --loader ts-node/esm --experimental-specifier-resolution=node src/server/Server.ts", "dev": "cross-env GAME_ENV=dev concurrently \"npm run start:client\" \"npm run start:server-dev\"", + "dev:staging": "cross-env GAME_ENV=dev API_DOMAIN=api.openfront.dev concurrently \"npm run start:client\" \"npm run start:server-dev\"", + "dev:prod": "cross-env GAME_ENV=dev API_DOMAIN=api.openfront.io concurrently \"npm run start:client\" \"npm run start:server-dev\"", "tunnel": "npm run build-prod && npm run start:server", "test": "jest", "perf": "npx tsx tests/perf/*.ts", diff --git a/resources/lang/en.json b/resources/lang/en.json index 1b5accb22..6ecacb4df 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -193,8 +193,9 @@ "join_lobby": "Join Lobby", "checking": "Checking lobby...", "not_found": "Lobby not found. Please check the ID and try again.", - "error": "An error occurred. Please try again.", - "joined_waiting": "Joined successfully! Waiting for game to start..." + "error": "An error occurred. Please try again or contact support.", + "joined_waiting": "Joined successfully! Waiting for game to start...", + "version_mismatch": "This game was created with a different version. Cannot join." }, "public_lobby": { "join": "Join next Game", diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts index 1c875a7e7..965b268f1 100644 --- a/src/client/JoinPrivateLobbyModal.ts +++ b/src/client/JoinPrivateLobbyModal.ts @@ -1,12 +1,13 @@ import { LitElement, html } from "lit"; import { customElement, query, state } from "lit/decorators.js"; import { translateText } from "../client/Utils"; -import { GameInfo, GameRecord } from "../core/Schemas"; +import { GameInfo, GameRecordSchema } from "../core/Schemas"; import { generateID } from "../core/Util"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { JoinLobbyEvent } from "./Main"; import "./components/baseComponents/Button"; import "./components/baseComponents/Modal"; +import { getApiBase } from "./jwt"; @customElement("join-private-lobby-modal") export class JoinPrivateLobbyModal extends LitElement { @query("o-modal") private modalEl!: HTMLElement & { @@ -179,10 +180,19 @@ export class JoinPrivateLobbyModal extends LitElement { if (gameExists) return; // If not active, check archived games - const archivedGame = await this.checkArchivedGame(lobbyId); - if (archivedGame) return; - - this.message = `${translateText("private_lobby.not_found")}`; + switch (await this.checkArchivedGame(lobbyId)) { + case "success": + return; + case "not_found": + this.message = `${translateText("private_lobby.not_found")}`; + return; + case "version_mismatch": + this.message = `${translateText("private_lobby.version_mismatch")}`; + return; + case "error": + this.message = `${translateText("private_lobby.error")}`; + return; + } } catch (error) { console.error("Error checking lobby existence:", error); this.message = `${translateText("private_lobby.error")}`; @@ -222,49 +232,70 @@ export class JoinPrivateLobbyModal extends LitElement { return false; } - private async checkArchivedGame(lobbyId: string): Promise { - const config = await getServerConfigFromClient(); - const archiveUrl = `/${config.workerPath(lobbyId)}/api/archived_game/${lobbyId}`; - - const archiveResponse = await fetch(archiveUrl, { + private async checkArchivedGame( + lobbyId: string, + ): Promise<"success" | "not_found" | "version_mismatch" | "error"> { + const archivePromise = fetch(`${getApiBase()}/game/${lobbyId}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + const gitCommitPromise = fetch(`/commit.txt`, { method: "GET", headers: { "Content-Type": "application/json" }, }); - const archiveData = await archiveResponse.json(); + const [archiveResponse, gitCommitResponse] = await Promise.all([ + archivePromise, + gitCommitPromise, + ]); - if ( - archiveData.success === false && - archiveData.error === "Version mismatch" - ) { + if (archiveResponse.status === 404) { + return "not_found"; + } + if (archiveResponse.status !== 200) { + return "error"; + } + + const archiveData = await archiveResponse.json(); + const parsed = GameRecordSchema.safeParse(archiveData); + if (!parsed.success) { + return "version_mismatch"; + } + + let myGitCommit = ""; + if (gitCommitResponse.status === 404) { + // commit.txt is not found when running locally + myGitCommit = "DEV"; + } else if (gitCommitResponse.status === 200) { + myGitCommit = await gitCommitResponse.text(); + } else { + console.error("Error getting git commit:", gitCommitResponse.status); + return "error"; + } + + // Allow DEV to join games created with a different version for debugging. + if (myGitCommit !== "DEV" && parsed.data.gitCommit !== myGitCommit) { console.warn( `Git commit hash mismatch for game ${lobbyId}`, archiveData.details, ); - this.message = - "This game was created with a different version. Cannot join."; - return true; + return "version_mismatch"; } - if (archiveData.exists) { - const gameRecord = archiveData.gameRecord as GameRecord; - - this.dispatchEvent( - new CustomEvent("join-lobby", { - detail: { - gameID: lobbyId, - gameRecord: gameRecord, - clientID: generateID(), - } as JoinLobbyEvent, - bubbles: true, - composed: true, - }), - ); - - return true; - } - - return false; + this.dispatchEvent( + new CustomEvent("join-lobby", { + detail: { + gameID: lobbyId, + gameRecord: parsed.data, + clientID: generateID(), + } as JoinLobbyEvent, + bubbles: true, + composed: true, + }), + ); + return "success"; } private async pollPlayers() { diff --git a/src/client/jwt.ts b/src/client/jwt.ts index 6ada4c9fb..bb531761d 100644 --- a/src/client/jwt.ts +++ b/src/client/jwt.ts @@ -17,9 +17,16 @@ function getAudience() { export function getApiBase() { const domainname = getAudience(); - return domainname === "localhost" - ? (localStorage.getItem("apiHost") ?? "http://localhost:8787") - : `https://api.${domainname}`; + + if (domainname === "localhost") { + const apiDomain = process?.env?.API_DOMAIN; + if (apiDomain) { + return `https://${apiDomain}`; + } + return localStorage.getItem("apiHost") ?? "http://localhost:8787"; + } + + return `https://api.${domainname}`; } function getToken(): string | null { @@ -159,7 +166,8 @@ function _isLoggedIn(): IsLoggedInResponse { logOut(); return false; } - if (aud !== getAudience()) { + const myAud = getAudience(); + if (myAud !== "localhost" && aud !== myAud) { // JWT was not issued for this website console.error( 'unexpected "aud" claim value', diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 8187ccbf2..24d6cb2cf 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -7,7 +7,6 @@ import path from "path"; import { fileURLToPath } from "url"; import { WebSocket, WebSocketServer } from "ws"; import { z } from "zod"; -import { GameEnv } from "../core/configuration/Config"; import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; import { GameType } from "../core/game/Game"; import { @@ -18,7 +17,7 @@ import { } from "../core/Schemas"; import { replacer } from "../core/Util"; import { CreateGameInputSchema, GameInputSchema } from "../core/WorkerSchemas"; -import { archive, finalizeGameRecord, readGameRecord } from "./Archive"; +import { archive, finalizeGameRecord } from "./Archive"; import { Client } from "./Client"; import { GameManager } from "./GameManager"; import { getUserMe, verifyClientToken } from "./jwt"; @@ -212,42 +211,6 @@ export async function startWorker() { res.json(game.gameInfo()); }); - app.get("/api/archived_game/:id", async (req, res) => { - const gameRecord = await readGameRecord(req.params.id); - - if (!gameRecord) { - return res.status(404).json({ - success: false, - error: "Game not found", - exists: false, - }); - } - - if ( - config.env() !== GameEnv.Dev && - gameRecord.gitCommit !== config.gitCommit() - ) { - log.warn( - `git commit mismatch for game ${req.params.id}, expected ${config.gitCommit()}, got ${gameRecord.gitCommit}`, - ); - return res.status(409).json({ - success: false, - error: "Version mismatch", - exists: true, - details: { - expectedCommit: config.gitCommit(), - actualCommit: gameRecord.gitCommit, - }, - }); - } - - return res.status(200).json({ - success: true, - exists: true, - gameRecord: gameRecord, - }); - }); - app.post("/api/archive_singleplayer_game", async (req, res) => { try { const record = req.body; diff --git a/webpack.config.js b/webpack.config.js index 0820e8cd0..bf929b3fa 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -129,6 +129,7 @@ export default async (env, argv) => { "process.env.STRIPE_PUBLISHABLE_KEY": JSON.stringify( process.env.STRIPE_PUBLISHABLE_KEY, ), + "process.env.API_DOMAIN": JSON.stringify(process.env.API_DOMAIN), }), new CopyPlugin({ patterns: [