mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-22 18:16:38 +00:00
05e2bc9f0a
## Description: This reworks asset delivery and cacheability across the app and moves non-bundled public resources onto immutable, content-hashed URLs. Vite bundle outputs continue to live under `/assets/**` and remain content-hashed by Vite. Public resources that were previously fetched from stable paths in `resources/` now go through a custom hashed namespace under `/_assets/**`, backed by a generated asset manifest that is available to the server, browser, and worker runtime. In parallel, the root app shell is now cacheable shared HTML instead of request-time `no-store` HTML. Dynamic and live routes remain explicitly uncached. ## Why - Improve browser and Cloudflare cacheability for static assets. - Remove query-string and release-version cache busting for runtime-fetched assets. - Allow unchanged public assets to keep the same URL across releases. - Reduce avoidable work on `/` by serving a shared app shell instead of rendering HTML on every request. - Make cache behavior explicit instead of relying on mixed framework defaults and file-extension heuristics. ## What Changed ### 1. Content-hashed public asset pipeline - Added a build-time public asset manifest and hashing pipeline for non-Vite resources. - Production now emits hashed public assets under `/_assets/**`. - Added runtime manifest loading for Node so server-rendered paths resolve against built hashed files instead of rebuilding from source at runtime. - Emitted the runtime asset manifest as an ESM module for server consumption. Result: - `/assets/**` = Vite-managed hashed bundle outputs - `/_assets/**` = custom content-hashed public resources ### 2. Runtime asset URL migration - Added a shared `assetUrl(...)` resolution path. - Migrated runtime references away from query-string versioning and stable source paths. - Updated browser, worker, and server-side rendering paths to resolve through the asset manifest. - Moved map manifests, map binaries, thumbnails, sprites, sounds, fonts, flags, icons, screenshots, and other runtime-fetched resources onto hashed URLs. ### 3. Map and preview fixes - Fixed directory and per-file map asset resolution so map manifest and binary fetches resolve to the correct hashed URLs. - Updated preview metadata and map thumbnail paths to use the hashed asset namespace. - Fixed runtime manifest loading in prod after deployment. ### 4. Explicit cache policies - Added explicit immutable cache headers for: - `/assets/**` - `/_assets/**` - worker-prefixed equivalents under `/wN/...` - Added explicit `no-store` headers for live and dynamic APIs. - Removed the old `/api/env` bootstrap request and baked `gameEnv` into the HTML bootstrap instead. ### 5. Cacheable root app shell - Refactored the root HTML path to serve a shared app shell with: - `Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400` - `/` and the SPA fallback now serve shared cacheable HTML instead of request-time `no-store` rendering. - `/game/:id` remains dynamic and `no-store`, but now reuses the shared shell before injecting preview tags. ### 6. Matchmaking instance handling - Because the app shell is now cacheable, `INSTANCE_ID` was removed from shared HTML. - Added `/api/instance` as a temporary `no-store` runtime lookup used only by matchmaking. - This preserves correctness with the current random-per-boot `INSTANCE_ID` model while keeping `/` cacheable, but it is not the intended long-term design. ## Behavior Changes ### Asset URL contract Production URLs for non-Vite public resources now change from stable paths such as: - `/maps/...` - `/images/...` - `/manifest.json` to content-hashed paths under: - `/_assets/...` Examples: - `/_assets/maps/<map>/manifest.<hash>.json` - `/_assets/images/Favicon.<hash>.svg` ### Bootstrap/config - `/api/env` is removed. - `gameEnv` is now bootstrapped from HTML. ### HTML caching - `/` and the SPA fallback are now cacheable shared HTML. - `/game/:id` remains dynamic. ## Cache Matrix After This Branch - `/_assets/**`: `public, max-age=31536000, immutable` - `/assets/**`: `public, max-age=31536000, immutable` - live `/api/**`: explicit `no-store` - `/api/health`: explicit `no-store` - `/api/instance`: explicit `no-store` - `/game/:id`: explicit `no-store` - `/` and SPA fallback: `public, max-age=0, s-maxage=300, stale-while-revalidate=86400` ## Notes / Tradeoffs - `/api/instance` is a temporary compromise. It exists because `INSTANCE_ID` is currently random per boot, which is not safe to embed into cacheable shared HTML. - The current matchmaking flow still asks the client to provide `instance_id` during `matchmaking/join`. That is functional, but it is the wrong ownership boundary: instance selection should be handled by the matchmaking service, not by the browser. - The cleaner end-state would be: - make `matchmaking/join` stop requiring `instance_id` from the client, and let the matchmaking service select a healthy instance from worker check-ins - This branch makes the origin behavior edge-cache-friendly, but Cloudflare still needs matching cache rules if HTML itself should be cached at the edge. ## Validation Verified during development with: - `npx tsc --noEmit` - `node node_modules\\vite\\bin\\vite.js build` - `node node_modules\\vitest\\vitest.mjs run tests/server/RenderHtml.test.ts tests/server/NoStoreHeaders.test.ts tests/server/StaticAssetCache.test.ts tests/core/configuration/ConfigLoader.test.ts` Additional targeted tests added: - `tests/AssetUrls.test.ts` - `tests/core/game/FetchGameMapLoader.test.ts` - `tests/core/configuration/ConfigLoader.test.ts` - `tests/server/NoStoreHeaders.test.ts` - `tests/server/StaticAssetCache.test.ts` - `tests/server/RenderHtml.test.ts` ## Known Existing Warnings The production build still reports pre-existing warnings that are not addressed by this branch: - inconsistent JSON import attributes for `resources/countries.json` - inconsistent JSON import attributes for `resources/QuickChat.json` - large chunk warnings from Vite ## Rollout Notes - Cache rules should treat `/_assets/**` and `/assets/**` as immutable. - Cloudflare will still classify HTML as dynamic after deploy unless matching edge cache rules are configured for it. ## Follow-ups - Remove `/api/instance` by changing `matchmaking/join` so the server selects the target instance, or by making `INSTANCE_ID` deploy-stable if the current contract must remain. ## Please complete the following: - [ ] I have added screenshots for all UI updates - [ ] I process any text displayed to the user through translateText() and I've added it to the en.json file - [ ] I have added relevant tests to the test directory - [ ] 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: DISCORD_USERNAME
282 lines
8.8 KiB
TypeScript
282 lines
8.8 KiB
TypeScript
import { z } from "zod";
|
|
import { buildAssetUrl } from "../core/AssetUrls";
|
|
import { ClanTagSchema, GameInfo, UsernameSchema } from "../core/Schemas";
|
|
import { formatPlayerDisplayName } from "../core/Util";
|
|
import { GameMode } from "../core/game/Game";
|
|
import { getRuntimeAssetManifest } from "./RuntimeAssetManifest";
|
|
|
|
export const PlayerInfoSchema = z.object({
|
|
clientID: z.string().optional(),
|
|
username: UsernameSchema.optional(),
|
|
clanTag: ClanTagSchema,
|
|
stats: z.unknown().optional(),
|
|
});
|
|
|
|
export type PlayerInfo = z.infer<typeof PlayerInfoSchema>;
|
|
|
|
export const ExternalGameInfoSchema = z.object({
|
|
info: z
|
|
.object({
|
|
config: z
|
|
.object({
|
|
gameMap: z.string().optional(),
|
|
gameMode: z.string().optional(),
|
|
gameType: z.string().optional(),
|
|
maxPlayers: z.number().optional(),
|
|
playerTeams: z.union([z.number(), z.string()]).optional(),
|
|
})
|
|
.optional(),
|
|
players: z.array(PlayerInfoSchema).optional(),
|
|
winner: z.array(z.string()).optional(),
|
|
duration: z.number().optional(),
|
|
start: z.number().optional(),
|
|
end: z.number().optional(),
|
|
lobbyCreatedAt: z.number().optional(),
|
|
})
|
|
.optional(),
|
|
});
|
|
|
|
export type ExternalGameInfo = z.infer<typeof ExternalGameInfoSchema>;
|
|
|
|
export type PreviewMeta = {
|
|
title: string;
|
|
description: string;
|
|
image: string;
|
|
joinUrl: string;
|
|
};
|
|
|
|
function formatDuration(seconds: number): string {
|
|
if (!Number.isFinite(seconds) || seconds < 0) return "Unknown";
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = seconds % 60;
|
|
const hours = Math.floor(mins / 60);
|
|
const minutes = mins % 60;
|
|
if (hours) return `${hours}h ${minutes}m ${secs}s`;
|
|
if (minutes) return `${minutes}m ${secs}s`;
|
|
return `${secs}s`;
|
|
}
|
|
|
|
function normalizeTimestamp(timestamp: number): number {
|
|
return timestamp < 1e12 ? timestamp * 1000 : timestamp;
|
|
}
|
|
|
|
function formatDateTimeParts(timestamp: number): {
|
|
date: string;
|
|
time: string;
|
|
} {
|
|
const date = new Date(normalizeTimestamp(timestamp));
|
|
const dateLabel = new Intl.DateTimeFormat("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
timeZone: "UTC",
|
|
}).format(date);
|
|
const timeLabel = new Intl.DateTimeFormat("en-US", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
hour12: false,
|
|
timeZone: "UTC",
|
|
}).format(date);
|
|
return { date: dateLabel, time: `${timeLabel} UTC` };
|
|
}
|
|
|
|
type WinnerInfo = { names: string; count: number };
|
|
|
|
function parseWinner(
|
|
winnerArray: string[] | undefined,
|
|
players: PlayerInfo[] | undefined,
|
|
): WinnerInfo | undefined {
|
|
if (!winnerArray || winnerArray.length < 2) return undefined;
|
|
|
|
const idToName = new Map(
|
|
(players ?? []).map((p) => [
|
|
p.clientID,
|
|
p.username ? formatPlayerDisplayName(p.username, p.clanTag) : undefined,
|
|
]),
|
|
);
|
|
|
|
if (winnerArray[0] === "team" && winnerArray.length >= 3) {
|
|
const playerIds = winnerArray.slice(2);
|
|
const names = playerIds.map((id) => idToName.get(id) ?? id).filter(Boolean);
|
|
return names.length > 0
|
|
? { names: names.join(", "), count: names.length }
|
|
: undefined;
|
|
}
|
|
|
|
if (winnerArray[0] === "player" && winnerArray.length >= 2) {
|
|
const clientId = winnerArray[1];
|
|
const name = idToName.get(clientId) ?? clientId;
|
|
return { names: name, count: 1 };
|
|
}
|
|
|
|
// Unknown winner format - don't display confusing output
|
|
return undefined;
|
|
}
|
|
|
|
function countActivePlayers(players: PlayerInfo[] | undefined): number {
|
|
return (players ?? []).filter((p) => {
|
|
if (!p || p.stats === null || p.stats === undefined) return false;
|
|
// Count only when `stats` has at least one property.
|
|
if (typeof p.stats === "object") {
|
|
return Object.keys(p.stats as Record<string, unknown>).length > 0;
|
|
}
|
|
return false;
|
|
}).length;
|
|
}
|
|
|
|
export function escapeHtml(value: string): string {
|
|
return value
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
export async function buildPreview(
|
|
gameID: string,
|
|
origin: string,
|
|
workerPath: string,
|
|
lobby: GameInfo | null,
|
|
publicInfo: ExternalGameInfo | null,
|
|
): Promise<PreviewMeta> {
|
|
const assetManifest = await getRuntimeAssetManifest();
|
|
const buildAbsoluteAssetUrl = (path: string) =>
|
|
new URL(buildAssetUrl(path, assetManifest), origin).toString();
|
|
const isFinished = !!publicInfo?.info?.end;
|
|
const isPrivate = lobby?.gameConfig?.gameType === "Private";
|
|
|
|
// route directly to the correct worker.
|
|
const joinUrl = `${origin}/${workerPath}/game/${gameID}`;
|
|
|
|
const config = publicInfo?.info?.config ?? {};
|
|
const players = publicInfo?.info?.players ?? [];
|
|
|
|
let activePlayers: number;
|
|
if (isFinished) {
|
|
activePlayers = countActivePlayers(players);
|
|
} else {
|
|
activePlayers =
|
|
countActivePlayers(players) || (lobby?.clients?.length ?? 0);
|
|
}
|
|
const map = lobby?.gameConfig?.gameMap ?? config.gameMap;
|
|
let mode = lobby?.gameConfig?.gameMode ?? config.gameMode ?? GameMode.FFA;
|
|
const playerTeams = lobby?.gameConfig?.playerTeams ?? config.playerTeams;
|
|
const numericTeamCount =
|
|
typeof playerTeams === "number" && playerTeams > 0
|
|
? playerTeams
|
|
: undefined;
|
|
|
|
// For finished games, show "x teams of y". For lobbies, just show "x teams"
|
|
const teamBreakdownLabel = numericTeamCount
|
|
? isFinished
|
|
? `${numericTeamCount} teams of ${Math.max(
|
|
1,
|
|
Math.ceil(activePlayers / numericTeamCount),
|
|
)}`
|
|
: `${numericTeamCount} teams`
|
|
: undefined;
|
|
|
|
// Format team mode display
|
|
if (mode === "Team" && playerTeams) {
|
|
if (typeof playerTeams === "string") {
|
|
mode = playerTeams; // e.g., "Quads"
|
|
} else if (typeof playerTeams === "number") {
|
|
mode = teamBreakdownLabel ?? `${playerTeams} Teams`;
|
|
}
|
|
}
|
|
|
|
const winner = parseWinner(publicInfo?.info?.winner, players);
|
|
const duration = publicInfo?.info?.duration;
|
|
|
|
// Normalize map name to match filesystem (lowercase, no spaces or special chars)
|
|
const normalizedMap = map ? map.toLowerCase().replace(/[\s.()]+/g, "") : null;
|
|
|
|
const mapThumbnail = normalizedMap
|
|
? buildAbsoluteAssetUrl(
|
|
`maps/${encodeURIComponent(normalizedMap)}/thumbnail.webp`,
|
|
)
|
|
: null;
|
|
const image =
|
|
mapThumbnail ?? buildAbsoluteAssetUrl("images/GameplayScreenshot.png");
|
|
|
|
const gameType = lobby?.gameConfig?.gameType ?? config.gameType;
|
|
const gameTypeLabel = gameType ? ` (${gameType})` : "";
|
|
|
|
const title = isFinished
|
|
? `${mode ?? "Game"} on ${map ?? "Unknown Map"}${gameTypeLabel}`
|
|
: mode && map
|
|
? `${mode} on ${map}${gameTypeLabel}`
|
|
: "OpenFront Game";
|
|
|
|
let description = "";
|
|
if (isFinished) {
|
|
const parts: string[] = [];
|
|
if (winner) {
|
|
parts.push(`${winner.count > 1 ? "Winners" : "Winner"}: ${winner.names}`);
|
|
parts.push(""); // Extra line break after winner
|
|
}
|
|
const matchTimestamp =
|
|
publicInfo?.info?.start ??
|
|
publicInfo?.info?.end ??
|
|
publicInfo?.info?.lobbyCreatedAt;
|
|
const detailParts: string[] = [];
|
|
const playerCountLabel = `${activePlayers} ${activePlayers === 1 ? "player" : "players"}`;
|
|
detailParts.push(playerCountLabel);
|
|
if (duration !== undefined) detailParts.push(`${formatDuration(duration)}`);
|
|
if (matchTimestamp !== undefined) {
|
|
const dateTime = formatDateTimeParts(matchTimestamp);
|
|
detailParts.push(`${dateTime.date}`);
|
|
detailParts.push(`${dateTime.time}`);
|
|
}
|
|
parts.push(detailParts.join(" • "));
|
|
description = parts.join("\n");
|
|
} else if (lobby) {
|
|
const gc = lobby.gameConfig;
|
|
|
|
if (isPrivate) {
|
|
// Private lobby: show detailed game settings
|
|
const sections: string[] = [];
|
|
|
|
// Show host
|
|
const hostClient = lobby.clients?.[0];
|
|
if (hostClient?.username) {
|
|
sections.push(
|
|
`Host: ${formatPlayerDisplayName(hostClient.username, hostClient.clanTag)}`,
|
|
);
|
|
}
|
|
|
|
const gameOptions: string[] = [];
|
|
|
|
if (gc?.gameMapSize && gc.gameMapSize !== "Normal") {
|
|
gameOptions.push(`${gc.gameMapSize} Map`);
|
|
}
|
|
if (gc?.infiniteGold) gameOptions.push("Infinite Gold");
|
|
if (gc?.infiniteTroops) gameOptions.push("Infinite Troops");
|
|
if (gc?.instantBuild) gameOptions.push("Instant Build");
|
|
if (gc?.randomSpawn) gameOptions.push("Random Spawn");
|
|
if (gc?.nations === "disabled") gameOptions.push("Nations Disabled");
|
|
if (gc?.donateTroops) gameOptions.push("Troop Donations Enabled");
|
|
|
|
if (gameOptions.length > 0) {
|
|
sections.push(`Game Options: ${gameOptions.join(" | ")}`);
|
|
}
|
|
|
|
if (Array.isArray(gc?.disabledUnits) && gc.disabledUnits.length > 0) {
|
|
sections.push(
|
|
`Disabled Units: ${gc.disabledUnits.map(String).join(" | ")}`,
|
|
);
|
|
}
|
|
|
|
description = sections.join("\n\n");
|
|
} else {
|
|
// Public lobby: basic info
|
|
description = "";
|
|
}
|
|
} else {
|
|
description = `Game ${gameID}`;
|
|
}
|
|
|
|
return { title, description, image, joinUrl };
|
|
}
|