Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"docs:map-generator": "cd map-generator && go doc -cmd -u -all",
"tunnel": "npm run build-prod && npm run start:server",
"test": "vitest run && vitest run tests/server",
"test:e2e": "cross-env E2E=1 vitest run tests/e2e",
"test:matchmaking": "node tests/matchmaking/contained.mjs",
"test:matchmaking:e2e": "node tests/matchmaking/e2e.mjs",
"perf": "npx tsx tests/perf/run-all.ts",
Expand Down
3 changes: 3 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1504,6 +1504,9 @@
"sam_launcher": "SAM Launcher",
"warship": "Warship"
},
"update_available": {
"message": "A new version of OpenFront is available. The page will now refresh to update."
},
"user_setting": {
"alert_frame_desc": "Toggle the alert frame. When enabled, the frame will be displayed when you are betrayed or attacked over land.",
"alert_frame_label": "Alert Frame",
Expand Down
6 changes: 6 additions & 0 deletions src/client/ClientGameRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,12 @@ export function joinLobby(
}),
);
});
} else if (message.error === "version_mismatch") {
// The server runs a newer build than this bundle (tab left open
// across a deploy). Reload to pick up the new version.
showInGameAlert(translateText("update_available.message")).then(() => {
window.location.reload();
});
} else {
showErrorModal(
message.error,
Expand Down
14 changes: 12 additions & 2 deletions src/client/GameModeSelector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { PublicGameInfo, PublicGames } from "../core/Schemas";
import "./components/IOSAddToHomeScreenBanner";
import { HostLobbyModal } from "./HostLobbyModal";
import { showInGameAlert } from "./InGameModal";
import { JoinLobbyModal } from "./JoinLobbyModal";
import { PublicLobbySocket } from "./LobbySocket";
import { JoinLobbyEvent } from "./Main";
Expand All @@ -37,10 +38,19 @@ export class GameModeSelector extends LitElement {
private serverTimeOffset: number = 0;
private defaultLobbyTime: number = 0;

private lobbySocket = new PublicLobbySocket((lobbies) =>
this.handleLobbiesUpdate(lobbies),
private lobbySocket = new PublicLobbySocket(
(lobbies) => this.handleLobbiesUpdate(lobbies),
// This socket only runs while the player is on the homepage (Main.ts
// stops it when a game is joined), so it's always safe to reload here.
{ onUpdateAvailable: () => this.handleUpdateAvailable() },
);

private handleUpdateAvailable() {
showInGameAlert(translateText("update_available.message")).then(() => {
window.location.reload();
});
}

createRenderRoot() {
return this;
}
Expand Down
18 changes: 18 additions & 0 deletions src/client/LobbySocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ interface LobbySocketOptions {
reconnectDelay?: number;
maxWsAttempts?: number;
pollIntervalMs?: number;
// Fired at most once, when the server advertises a different build commit
// than this bundle — i.e. a new version deployed while this tab was open.
onUpdateAvailable?: () => void;
}

function getRandomWorkerPath(numWorkers: number): string {
Expand All @@ -24,13 +27,16 @@ export class PublicLobbySocket {

private readonly reconnectDelay: number;
private readonly maxWsAttempts: number;
private readonly onUpdateAvailable?: () => void;
private updateAvailableFired = false;

constructor(
private onLobbiesUpdate: (data: PublicGames) => void,
options?: LobbySocketOptions,
) {
this.reconnectDelay = options?.reconnectDelay ?? 3000;
this.maxWsAttempts = options?.maxWsAttempts ?? 3;
this.onUpdateAvailable = options?.onUpdateAvailable;
}

async start() {
Expand Down Expand Up @@ -88,6 +94,7 @@ export class PublicLobbySocket {
JSON.parse(event.data as string),
);
if (message.type === "full") {
this.checkServerCommit(message.gitCommit);
this.lastFull = {
serverTime: message.serverTime,
games: message.games,
Expand Down Expand Up @@ -134,6 +141,17 @@ export class PublicLobbySocket {
}
}

private checkServerCommit(serverCommit: string | undefined) {
if (this.updateAvailableFired || this.onUpdateAvailable === undefined) {
return;
}
if (serverCommit === undefined) return;
const ownCommit = ClientEnv.gitCommit();
if (ownCommit === "DEV" || serverCommit === ownCommit) return;
this.updateAvailableFired = true;
this.onUpdateAvailable();
}

private handleClose() {
if (this.stopped) return;
console.log("WebSocket disconnected, attempting to reconnect...");
Expand Down
2 changes: 2 additions & 0 deletions src/client/Transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,7 @@ export class Transport {
cosmetics: this.lobbyConfig.cosmetics,
turnstileToken: this.lobbyConfig.turnstileToken,
token: await getPlayToken(),
gitCommit: ClientEnv.gitCommit(),
} satisfies ClientJoinMessage);
}

Expand All @@ -437,6 +438,7 @@ export class Transport {
// Note: clientID is not sent - server looks it up from persistentID in token
lastTurn: lastTurn,
token: await getPlayToken(),
gitCommit: ClientEnv.gitCommit(),
} satisfies ClientRejoinMessage);
}

Expand Down
11 changes: 11 additions & 0 deletions src/core/Schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,10 @@ export const PublicLobbyFullSchema = z.object({
type: z.literal("full"),
serverTime: z.number(),
games: z.partialRecord(PublicGameTypeSchema, z.array(PublicGameInfoSchema)),
// Build commit of the serving deployment. Clients on the homepage compare
// it to their own bundle's commit to detect that a new version deployed
// and prompt a refresh. Optional so clients tolerate older servers.
gitCommit: z.string().max(64).optional(),
Comment on lines +253 to +256

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate files =="
fd -a 'Schemas\.ts|Worker\.ts|ClientVersionSchemas\.test\.ts' . | sed 's#^\./##' | sort

echo "== Schemas outline =="
ast-grep outline src/core/Schemas.ts --view compact || true

echo "== Schemas relevant lines =="
sed -n '230,270p' src/core/Schemas.ts
echo "----"
sed -n '880,905p' src/core/Schemas.ts

echo "== Worker relevant lines =="
sed -n '430,475p' src/server/Worker.ts

echo "== tests outline/contents =="
wc -l tests/ClientVersionSchemas.test.ts
sed -n '1,220p' tests/ClientVersionSchemas.test.ts

echo "== string validator usage around version fields =="
rg -n "gitCommit|deployed|commit|min\\(|max\\(" src/core/Schemas.ts tests/ClientVersionSchemas.test.ts

Repository: openfrontio/OpenFrontIO

Length of output: 10894


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Worker gitCommit implementation area =="
rg -n -C 4 "gitCommit\\(|version_mismatch|Version mismatch" src/server/Worker.ts

echo "== deterministic string validator probe =="
node - <<'JS'
const zod = require('zod');
const schema = zod.z.string().max(64).optional();
for (const value of [undefined, '', 'abc', 'a'.repeat(64), 'a'.repeat(65)]) {
  const result = schema.safeParse(value);
  console.log(JSON.stringify({ value, success: result.success, data: result.success ? result.data : undefined }));
}
JS

Repository: openfrontio/OpenFrontIO

Length of output: 1493


Reject empty build identifiers.

z.string().max(64).optional() accepts "", and the worker compares clientMsg.gitCommit directly with ServerEnv.gitCommit(). If the server is misconfigured with an empty commit, a malformed client can bypass the version gate. Keep the field optional, but require a non-empty value when present, and add empty-string tests.

Proposed schema change
-  gitCommit: z.string().max(64).optional(),
+  gitCommit: z.string().min(1).max(64).optional(),

Also applies to: 886-890, 899-900

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/Schemas.ts` around lines 253 - 256, Update the gitCommit schema
fields in the identified schema locations to remain optional while rejecting
empty strings when provided, preserving the existing 64-character maximum. Add
tests covering rejection of an empty gitCommit value and acceptance of omitted
and non-empty values.

});

export const PublicLobbyCountsSchema = z.object({
Expand Down Expand Up @@ -879,6 +883,11 @@ export const ClientJoinMessageSchema = z.object({
// Server replaces the refs with the actual cosmetic data.
cosmetics: PlayerCosmeticRefsSchema.optional(),
turnstileToken: z.string().nullable(),
// Build commit of the client bundle. The sim only stays deterministic when
// every client in a game runs identical code, so the server rejects joins
// whose commit doesn't match its own (missing counts as a mismatch —
// pre-feature bundles are by definition stale).
gitCommit: z.string().max(64).optional(),
});

export const ClientRejoinMessageSchema = z.object({
Expand All @@ -887,6 +896,8 @@ export const ClientRejoinMessageSchema = z.object({
// Note: clientID is NOT sent - server looks it up from persistentID in token
lastTurn: z.number(),
token: TokenSchema,
// See ClientJoinMessageSchema.gitCommit.
gitCommit: z.string().max(64).optional(),
});

export const ClientMessageSchema = z.discriminatedUnion("type", [
Expand Down
20 changes: 20 additions & 0 deletions src/server/Worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,26 @@ export async function startWorker() {
return;
}

// The sim is deterministic only when every client in a game runs
// identical code, so a client built from a different commit (e.g. a
// tab left open across a deploy) would desync the game. Reject it
// with a typed error the client answers by refreshing. A missing
// commit means a pre-feature bundle, which is stale by definition.
if (clientMsg.gitCommit !== ServerEnv.gitCommit()) {
log.info("rejecting version-mismatched client", {
gameID: clientMsg.gameID,
clientCommit: clientMsg.gitCommit,
});
ws.send(
JSON.stringify({
type: "error",
error: "version_mismatch",
} satisfies ServerErrorMessage),
);
ws.close(1002, "Version mismatch");
return;
}
Comment on lines +454 to +472

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== tracked files matching Worker/ClientGameRunner/Transport =="
git ls-files | rg '(^|/)Worker\.ts$|ClientGameRunner\.ts$|Transport\.ts$|ServerErrorMessage' || true

echo
echo "== search version_mismatch and close handling =="
rg -n 'version_mismatch|onclose|on Close|close\(|1002|connection refused|refresh|reload|alert|error.*version' -S src || true

Repository: openfrontio/OpenFrontIO

Length of output: 28139


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Worker.ts relevant section =="
sed -n '430,485p' src/server/Worker.ts | nl -ba -v 430

echo
echo "== ClientGameRunner.ts version_mismatch and socket initialization section =="
sed -n '200,250p' src/client/ClientGameRunner.ts | nl -ba -v 200

echo
echo "== Transport.ts onclose section =="
sed -n '380,415p' src/client/Transport.ts | nl -ba -v 380

echo
echo "== deterministic ordering check from source snippets =="
python3 - <<'PY'
from pathlib import Path
import re

worker = Path("src/server/Worker.ts").read_text()
client = Path("src/client/ClientGameRunner.ts").read_text()
transport = Path("src/client/Transport.ts").read_text()

version_send = "JSON.stringify({\n                type: \"error\",\n                error: \"version_mismatch\""
if version_send in worker:
    line = worker.splitlines().index(version_send.splitlines()[0]) + 1
    print("Worker sends version_mismatch:", line)

error_handler = "message.error === \"version_mismatch\""
if error_handler in client:
    line = client.splitlines().index(error_handler.splitlines()[0]) + 1
    nearby = client.splitlines()[max(0,line+12-1):min(len(client.splitlines()),line+18)]
    print("ClientGameRunner handles version_mismatch:", line)
    print("  nearby:", nearby)

onclose = re.search(r"this\.socket\.onclose\s*=\s*\(\w+:\s*CloseEvent\)\s*=>\s*\{", transport)
if onclose:
    print("Transport attaches socket onclose:", onclose.start())
    onclose_line = transport[:onclose.start()].count("\n")+1
    print("Transport onclose handler starts:", onclose_line)
    # find next return or function after onclose assignment
PY

Repository: openfrontio/OpenFrontIO

Length of output: 234


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Worker.ts relevant section =="
sed -n '430,485p' src/server/Worker.ts

echo
echo "== ClientGameRunner.ts version_mismatch handler =="
sed -n '200,255p' src/client/ClientGameRunner.ts

echo
echo "== Transport.ts onclose handler =="
sed -n '380,415p' src/client/Transport.ts

echo
echo "== Read-only source ordering probe =="
python3 - <<'PY'
from pathlib import Path

worker = Path("src/server/Worker.ts").read_text()
client = Path("src/client/ClientGameRunner.ts").read_text()
transport = Path("src/client/Transport.ts").read_text()

print({
    "version_mismatch_text_in_worker": "error: \"version_mismatch\"" in worker,
    "version_mismatch_handler_in_client": "message.error === \"version_mismatch\"" in client,
    "socket_onclose_handler_in_transport": "this.socket.onclose" in transport and "event.code === 1002" in transport,
})
print("transport first 1000 bytes includes onclose setup:", "this.socket.onclose" in transport[:1000])
PY

Repository: openfrontio/OpenFrontIO

Length of output: 5318


Handle version mismatches in the message path, not the 1002 close path.

The worker sends version_mismatch and still closes the socket with 1002; Transport.ts maps 1002 to connection_refused, while ClientGameRunner handles version_mismatch separately. Use a client-only 1002 reason for the typed error or branch so version mismatches do not also show the generic refused-connection alert.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/Worker.ts` around lines 454 - 472, The version-mismatch branch in
the worker currently sends the typed error but closes with a code that Transport
maps to a generic connection-refused alert. Update the handling around
ServerEnv.gitCommit() and the clientMsg version check so the client-only 1002
close reason is recognized as the version-mismatch path, preventing
connection_refused handling while preserving ClientGameRunner’s version_mismatch
behavior.


// Verify token signature
const result = await verifyClientToken(clientMsg.token);
if (result.type === "error") {
Expand Down
3 changes: 3 additions & 0 deletions src/server/WorkerLobbyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
WorkerReady,
} from "./IPCBridgeSchema";
import { logger } from "./Logger";
import { ServerEnv } from "./ServerEnv";

// The game config advertised for a listed private lobby: everything the
// host configured minus host-only fields. The server already rejects
Expand Down Expand Up @@ -256,6 +257,7 @@ export class WorkerLobbyService {
type: "full",
serverTime: this.lastPublicGames.serverTime,
games: this.sanitizeGames(this.lastPublicGames.games),
gitCommit: ServerEnv.gitCommit(),
} satisfies PublicLobbyMessage);
ws.send(fullJson);
}
Expand Down Expand Up @@ -307,6 +309,7 @@ export class WorkerLobbyService {
type: "full",
serverTime: publicGames.serverTime,
games: sanitizedGames,
gitCommit: ServerEnv.gitCommit(),
};
this.lastFullGameIds = fingerprint;
} else {
Expand Down
71 changes: 71 additions & 0 deletions tests/ClientVersionSchemas.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {
ClientJoinMessageSchema,
ClientRejoinMessageSchema,
PublicLobbyMessageSchema,
} from "../src/core/Schemas";

const COMMIT = "a".repeat(40);

const baseJoin = {
type: "join",
token: "123e4567-e89b-12d3-a456-426614174000",
gameID: "abcd1234",
username: "TestPlayer",
clanTag: null,
turnstileToken: null,
};

const baseRejoin = {
type: "rejoin",
gameID: "abcd1234",
lastTurn: 10,
token: "123e4567-e89b-12d3-a456-426614174000",
};

describe("gitCommit on join/rejoin messages", () => {
test("join parses with gitCommit", () => {
const result = ClientJoinMessageSchema.safeParse({
...baseJoin,
gitCommit: COMMIT,
});
expect(result.success).toBe(true);
expect(result.data?.gitCommit).toBe(COMMIT);
});

test("join parses without gitCommit (pre-feature clients)", () => {
const result = ClientJoinMessageSchema.safeParse(baseJoin);
expect(result.success).toBe(true);
expect(result.data?.gitCommit).toBeUndefined();
});

test("join rejects an oversized gitCommit", () => {
const result = ClientJoinMessageSchema.safeParse({
...baseJoin,
gitCommit: "a".repeat(65),
});
expect(result.success).toBe(false);
});

test("rejoin parses with and without gitCommit", () => {
expect(
ClientRejoinMessageSchema.safeParse({ ...baseRejoin, gitCommit: COMMIT })
.success,
).toBe(true);
expect(ClientRejoinMessageSchema.safeParse(baseRejoin).success).toBe(true);
});
});

describe("gitCommit on the public lobby feed", () => {
test("full message parses with and without gitCommit", () => {
const full = { type: "full", serverTime: 123, games: {} };
expect(PublicLobbyMessageSchema.safeParse(full).success).toBe(true);
const withCommit = PublicLobbyMessageSchema.safeParse({
...full,
gitCommit: COMMIT,
});
expect(withCommit.success).toBe(true);
if (withCommit.success && withCommit.data.type === "full") {
expect(withCommit.data.gitCommit).toBe(COMMIT);
}
});
});
Loading
Loading