Improve cacheability with content-hashed public assets and a cacheable app shell (#3494)

## 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
This commit is contained in:
scamiv
2026-03-23 19:36:52 +01:00
committed by GitHub
parent e3a14671ab
commit 05e2bc9f0a
65 changed files with 1214 additions and 331 deletions
+86
View File
@@ -0,0 +1,86 @@
export type AssetManifest = Record<string, string>;
function safeDecodeAssetSegment(segment: string): string {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
}
function assertSafeAssetSegment(segment: string): string {
const decodedSegment = safeDecodeAssetSegment(segment);
if (
segment === "." ||
segment === ".." ||
decodedSegment === "." ||
decodedSegment === ".."
) {
throw new Error(`Invalid asset path segment: ${segment}`);
}
return decodedSegment;
}
export function encodeAssetPath(path: string): string {
return normalizeAssetPath(path)
.split("/")
.filter((segment) => segment.length > 0)
.map((segment) => encodeURIComponent(segment))
.join("/");
}
export function normalizeAssetPath(path: string): string {
const normalizedPath = path
.replace(/^\/+/, "")
.split("/")
.filter((segment) => segment.length > 0)
.map((segment) => assertSafeAssetSegment(segment))
.join("/");
if (normalizedPath.length === 0) {
throw new Error("Asset path must not be empty");
}
return normalizedPath;
}
export function buildAssetUrl(
path: string,
assetManifest: AssetManifest = {},
): string {
const normalizedPath = normalizeAssetPath(path);
const directUrl = assetManifest[normalizedPath];
if (directUrl) {
return directUrl;
}
const directoryPrefix = `${normalizedPath}/`;
const hasNestedAssets = Object.keys(assetManifest).some((manifestPath) =>
manifestPath.startsWith(directoryPrefix),
);
if (hasNestedAssets) {
return `/_assets/${encodeAssetPath(normalizedPath)}`;
}
return `/${encodeAssetPath(normalizedPath)}`;
}
declare global {
var __ASSET_MANIFEST__: AssetManifest | undefined;
interface Window {
ASSET_MANIFEST?: AssetManifest;
}
}
export function getAssetManifest(): AssetManifest {
if (typeof window !== "undefined" && window.ASSET_MANIFEST !== undefined) {
return window.ASSET_MANIFEST;
}
return globalThis.__ASSET_MANIFEST__ ?? {};
}
export function assetUrl(path: string): string {
return buildAssetUrl(path, getAssetManifest());
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { assetUrl } from "./AssetUrls";
import { Cosmetics } from "./CosmeticSchemas";
const ANIMATION_DURATIONS: Record<string, number> = {
@@ -39,7 +40,7 @@ export function renderPlayerFlag(
for (const { layerKey, colorKey } of layers) {
const layerName = cosmetics?.flag?.layers[layerKey]?.name ?? layerKey;
const mask = `/flags/custom/${layerName}.svg`;
const mask = assetUrl(`flags/custom/${layerName}.svg`);
if (!mask) continue;
const layer = document.createElement("div");
+16 -9
View File
@@ -9,6 +9,14 @@ import { prodConfig } from "./ProdConfig";
export let cachedSC: ServerConfig | null = null;
declare global {
interface Window {
BOOTSTRAP_CONFIG?: {
gameEnv?: string;
};
}
}
export async function getConfig(
gameConfig: GameConfig,
userSettings: UserSettings | null,
@@ -30,18 +38,13 @@ export async function getServerConfigFromClient(): Promise<ServerConfig> {
if (cachedSC) {
return cachedSC;
}
const response = await fetch("/api/env");
if (!response.ok) {
throw new Error(
`Failed to fetch server config: ${response.status} ${response.statusText}`,
);
const bootstrapGameEnv = window.BOOTSTRAP_CONFIG?.gameEnv;
if (!bootstrapGameEnv) {
throw new Error("Missing bootstrap server config");
}
const config = await response.json();
// Log the retrieved configuration
console.log("Server config loaded:", config);
cachedSC = getServerConfig(config.game_env);
cachedSC = getServerConfig(bootstrapGameEnv);
return cachedSC;
}
export function getServerConfigFromServer(): ServerConfig {
@@ -63,3 +66,7 @@ export function getServerConfig(gameEnv: string) {
throw Error(`unsupported server configuration: ${gameEnv}`);
}
}
export function clearCachedServerConfig(): void {
cachedSC = null;
}
+8 -7
View File
@@ -1,3 +1,4 @@
import { assetUrl } from "../AssetUrls";
import { GameMapType } from "./Game";
import { GameMapLoader, MapData } from "./GameMapLoader";
import { MapManifest } from "./TerrainMapLoader";
@@ -36,25 +37,25 @@ export class BinaryLoaderGameMapLoader implements GameMapLoader {
})
.then((buf) => new Uint8Array(buf));
const mapBasePath = `/maps/${fileName}`;
const mapAssetUrl = (path: string) => assetUrl(`maps/${fileName}/${path}`);
const mapData = {
mapBin: this.createLazyLoader(() => loadBinary(`${mapBasePath}/map.bin`)),
mapBin: this.createLazyLoader(() => loadBinary(mapAssetUrl("map.bin"))),
map4xBin: this.createLazyLoader(() =>
loadBinary(`${mapBasePath}/map4x.bin`),
loadBinary(mapAssetUrl("map4x.bin")),
),
map16xBin: this.createLazyLoader(() =>
loadBinary(`${mapBasePath}/map16x.bin`),
loadBinary(mapAssetUrl("map16x.bin")),
),
manifest: this.createLazyLoader(() =>
fetch(`${mapBasePath}/manifest.json`).then((res) => {
fetch(mapAssetUrl("manifest.json")).then((res) => {
if (!res.ok) {
throw new Error(`Failed to load ${mapBasePath}/manifest.json`);
throw new Error(`Failed to load ${mapAssetUrl("manifest.json")}`);
}
return res.json() as Promise<MapManifest>;
}),
),
webpPath: `${mapBasePath}/thumbnail.webp`,
webpPath: mapAssetUrl("thumbnail.webp"),
} satisfies MapData;
this.maps.set(map, mapData);
+8 -10
View File
@@ -5,8 +5,7 @@ export class FetchGameMapLoader implements GameMapLoader {
private maps: Map<GameMapType, MapData>;
public constructor(
private readonly prefix: string,
private readonly cacheBuster?: string,
private readonly pathResolver: string | ((path: string) => string),
) {
this.maps = new Map<GameMapType, MapData>();
}
@@ -38,16 +37,15 @@ export class FetchGameMapLoader implements GameMapLoader {
return mapData;
}
private url(map: string, path: string) {
let url = `${this.prefix}/${map}/${path}`;
if (this.cacheBuster) {
url += `${url.includes("?") ? "&" : "?"}v=${encodeURIComponent(
this.cacheBuster.trim(),
)}`;
private resolveUrl(path: string): string {
if (typeof this.pathResolver === "function") {
return this.pathResolver(path);
}
return `${this.pathResolver}/${path}`;
}
return url;
private url(map: string, path: string) {
return this.resolveUrl(`${map}/${path}`);
}
private async loadBinaryFromUrl(url: string) {
+3 -2
View File
@@ -1,4 +1,4 @@
import version from "resources/version.txt?raw";
import { assetUrl } from "../AssetUrls";
import { createGameRunner, GameRunner } from "../GameRunner";
import { FetchGameMapLoader } from "../game/FetchGameMapLoader";
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
@@ -15,8 +15,9 @@ import {
} from "./WorkerMessages";
const ctx: Worker = self as any;
globalThis.__ASSET_MANIFEST__ = __ASSET_MANIFEST__;
let gameRunner: Promise<GameRunner> | null = null;
const mapLoader = new FetchGameMapLoader(`/maps`, version);
const mapLoader = new FetchGameMapLoader((path) => assetUrl(`maps/${path}`));
// Yield threshold; not a backlog cap. Used to avoid monopolizing the worker task
// and flooding the main thread with messages during catch-up.
const MAX_TICKS_BEFORE_YIELD = 4;