diff --git a/index.html b/index.html
index 26f163226..afe87163f 100644
--- a/index.html
+++ b/index.html
@@ -57,7 +57,7 @@
diff --git a/src/core/AssetUrls.ts b/src/core/AssetUrls.ts
index f0a04532f..ae43a8caf 100644
--- a/src/core/AssetUrls.ts
+++ b/src/core/AssetUrls.ts
@@ -1,46 +1,53 @@
-export function normalizeAssetVersion(
- version: string | null | undefined,
-): string | null {
- const trimmed = version?.trim();
- if (!trimmed || trimmed === "DEV" || trimmed === "undefined") {
- return null;
+export type AssetManifest = Record;
+
+function safeDecodeAssetSegment(segment: string): string {
+ try {
+ return decodeURIComponent(segment);
+ } catch {
+ return segment;
}
- return trimmed;
}
-export function buildVersionedAssetBasePath(
- version: string | null | undefined,
-): string {
- const normalized = normalizeAssetVersion(version);
- return normalized ? `/_assets/${encodeURIComponent(normalized)}` : "";
+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 {
+ return path
+ .replace(/^\/+/, "")
+ .split("/")
+ .filter((segment) => segment.length > 0)
+ .map((segment) => safeDecodeAssetSegment(segment))
+ .join("/");
}
export function buildAssetUrl(
path: string,
- assetBasePath: string = "",
+ assetManifest: AssetManifest = {},
): string {
- const normalizedPath = path.replace(/^\/+/, "");
- if (!assetBasePath) {
- return `/${normalizedPath}`;
- }
- return `${assetBasePath}/${normalizedPath}`;
+ const normalizedPath = normalizeAssetPath(path);
+ return assetManifest[normalizedPath] ?? `/${encodeAssetPath(normalizedPath)}`;
}
declare global {
- var __ASSET_BASE_PATH__: string | undefined;
+ var __ASSET_MANIFEST__: AssetManifest | undefined;
interface Window {
- ASSET_BASE_PATH?: string;
+ ASSET_MANIFEST?: AssetManifest;
}
}
-export function getAssetBasePath(): string {
- if (typeof window !== "undefined" && window.ASSET_BASE_PATH !== undefined) {
- return window.ASSET_BASE_PATH;
+export function getAssetManifest(): AssetManifest {
+ if (typeof window !== "undefined" && window.ASSET_MANIFEST !== undefined) {
+ return window.ASSET_MANIFEST;
}
- return globalThis.__ASSET_BASE_PATH__ ?? "";
+ return globalThis.__ASSET_MANIFEST__ ?? {};
}
export function assetUrl(path: string): string {
- return buildAssetUrl(path, getAssetBasePath());
+ return buildAssetUrl(path, getAssetManifest());
}
diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts
index 445ed9a25..3f5b13b54 100644
--- a/src/core/worker/Worker.worker.ts
+++ b/src/core/worker/Worker.worker.ts
@@ -15,6 +15,7 @@ import {
} from "./WorkerMessages";
const ctx: Worker = self as any;
+globalThis.__ASSET_MANIFEST__ = __ASSET_MANIFEST__;
let gameRunner: Promise | null = null;
const mapLoader = new FetchGameMapLoader(() => assetUrl("maps"));
// Yield threshold; not a backlog cap. Used to avoid monopolizing the worker task
diff --git a/src/server/GamePreviewBuilder.ts b/src/server/GamePreviewBuilder.ts
index 2c4dfa131..33d4be3fb 100644
--- a/src/server/GamePreviewBuilder.ts
+++ b/src/server/GamePreviewBuilder.ts
@@ -1,8 +1,12 @@
import { z } from "zod";
-import { buildAssetUrl, buildVersionedAssetBasePath } from "../core/AssetUrls";
+import { buildAssetUrl } from "../core/AssetUrls";
import { ClanTagSchema, GameInfo, UsernameSchema } from "../core/Schemas";
import { formatPlayerDisplayName } from "../core/Util";
import { GameMode } from "../core/game/Game";
+import {
+ buildPublicAssetManifest,
+ getResourcesDir,
+} from "./PublicAssetManifest";
export const PlayerInfoSchema = z.object({
clientID: z.string().optional(),
@@ -139,9 +143,12 @@ export function buildPreview(
lobby: GameInfo | null,
publicInfo: ExternalGameInfo | null,
): PreviewMeta {
- const assetBasePath = buildVersionedAssetBasePath(process.env.GIT_COMMIT);
+ const assetManifest =
+ process.env.GAME_ENV === "prod"
+ ? buildPublicAssetManifest(getResourcesDir())
+ : {};
const buildAbsoluteAssetUrl = (path: string) =>
- new URL(buildAssetUrl(path, assetBasePath), origin).toString();
+ new URL(buildAssetUrl(path, assetManifest), origin).toString();
const isFinished = !!publicInfo?.info?.end;
const isPrivate = lobby?.gameConfig?.gameType === "Private";
diff --git a/src/server/PublicAssetManifest.ts b/src/server/PublicAssetManifest.ts
new file mode 100644
index 000000000..9ddb065f0
--- /dev/null
+++ b/src/server/PublicAssetManifest.ts
@@ -0,0 +1,151 @@
+import { createHash } from "crypto";
+import fs from "fs";
+import { globSync } from "glob";
+import path from "path";
+import {
+ type AssetManifest,
+ buildAssetUrl,
+ encodeAssetPath,
+ normalizeAssetPath,
+} from "../core/AssetUrls";
+
+const HASHED_PUBLIC_ASSET_GLOBS = [
+ "changelog.md",
+ "manifest.json",
+ "cosmetics/**/*",
+ "flags/**/*",
+ "icons/**/*",
+ "images/**/*",
+ "lang/**/*",
+ "maps/**/*",
+] as const;
+
+const ROOT_PUBLIC_FILES = new Set([
+ "LICENSE",
+ "ads.txt",
+ "privacy-policy.html",
+ "robots.txt",
+ "terms-of-service.html",
+ "version.txt",
+]);
+
+const manifestCache = new Map();
+
+function toPosixPath(filePath: string): string {
+ return filePath.split(path.sep).join(path.posix.sep);
+}
+
+function createContentHash(filePath: string): string {
+ const content = fs.readFileSync(filePath);
+ return createHash("sha256").update(content).digest("hex").slice(0, 12);
+}
+
+export function getResourcesDir(rootDir: string = process.cwd()): string {
+ return path.join(rootDir, "resources");
+}
+
+export function shouldKeepRootPublicFile(relativePath: string): boolean {
+ return ROOT_PUBLIC_FILES.has(normalizeAssetPath(relativePath));
+}
+
+export function listHashedPublicAssetPaths(resourcesDir: string): string[] {
+ const files = new Set();
+ for (const pattern of HASHED_PUBLIC_ASSET_GLOBS) {
+ for (const file of globSync(pattern, {
+ cwd: resourcesDir,
+ nodir: true,
+ dot: false,
+ posix: true,
+ })) {
+ files.add(normalizeAssetPath(file));
+ }
+ }
+ return [...files].sort();
+}
+
+export function listRootPublicFiles(resourcesDir: string): string[] {
+ return globSync("**/*", {
+ cwd: resourcesDir,
+ nodir: true,
+ dot: false,
+ posix: true,
+ })
+ .map((file) => normalizeAssetPath(file))
+ .filter((file) => shouldKeepRootPublicFile(file))
+ .sort();
+}
+
+export function buildPublicAssetManifest(resourcesDir: string): AssetManifest {
+ const cached = manifestCache.get(resourcesDir);
+ if (cached) {
+ return cached;
+ }
+
+ const manifest: AssetManifest = {};
+ for (const relativePath of listHashedPublicAssetPaths(resourcesDir)) {
+ const absolutePath = path.join(resourcesDir, relativePath);
+ const parsed = path.posix.parse(toPosixPath(relativePath));
+ const hash = createContentHash(absolutePath);
+ const hashedFileName = `${parsed.name}.${hash}${parsed.ext}`;
+ const hashedRelativePath = path.posix.join(
+ "_assets",
+ parsed.dir,
+ hashedFileName,
+ );
+ manifest[relativePath] = `/${encodeAssetPath(hashedRelativePath)}`;
+ }
+
+ manifestCache.set(resourcesDir, manifest);
+ return manifest;
+}
+
+export function clearPublicAssetManifestCache(): void {
+ manifestCache.clear();
+}
+
+export function createHashedPublicAssetFiles(
+ resourcesDir: string,
+ outDir: string,
+ assetManifest: AssetManifest,
+): void {
+ for (const [relativePath, hashedUrl] of Object.entries(assetManifest)) {
+ const sourcePath = path.join(resourcesDir, relativePath);
+ const outputPath = path.join(outDir, normalizeAssetPath(hashedUrl));
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
+
+ if (relativePath === "manifest.json") {
+ const manifest = JSON.parse(fs.readFileSync(sourcePath, "utf8")) as {
+ icons?: Array<{ src?: string }>;
+ };
+ manifest.icons = manifest.icons?.map((icon) => ({
+ ...icon,
+ src: buildAssetUrl(icon.src ?? "", assetManifest),
+ }));
+ fs.writeFileSync(outputPath, `${JSON.stringify(manifest, null, 2)}\n`);
+ continue;
+ }
+
+ fs.copyFileSync(sourcePath, outputPath);
+ }
+}
+
+export function copyRootPublicFiles(
+ resourcesDir: string,
+ outDir: string,
+): void {
+ for (const relativePath of listRootPublicFiles(resourcesDir)) {
+ const sourcePath = path.join(resourcesDir, relativePath);
+ const outputPath = path.join(outDir, relativePath);
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
+ fs.copyFileSync(sourcePath, outputPath);
+ }
+}
+
+export function writePublicAssetManifestFile(
+ outDir: string,
+ assetManifest: AssetManifest,
+): void {
+ const manifestPath = path.join(outDir, "_assets", "asset-manifest.json");
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
+ fs.writeFileSync(manifestPath, `${JSON.stringify(assetManifest, null, 2)}\n`);
+}
diff --git a/src/server/RenderHtml.ts b/src/server/RenderHtml.ts
index b7dc5b262..e3a939ce6 100644
--- a/src/server/RenderHtml.ts
+++ b/src/server/RenderHtml.ts
@@ -1,24 +1,31 @@
import ejs from "ejs";
import type { Response } from "express";
import fs from "fs/promises";
-import { buildAssetUrl, buildVersionedAssetBasePath } from "../core/AssetUrls";
+import { buildAssetUrl } from "../core/AssetUrls";
+import {
+ buildPublicAssetManifest,
+ getResourcesDir,
+} from "./PublicAssetManifest";
export async function renderHtmlContent(htmlPath: string): Promise {
const htmlContent = await fs.readFile(htmlPath, "utf-8");
- const assetBasePath = buildVersionedAssetBasePath(process.env.GIT_COMMIT);
+ const assetManifest =
+ process.env.GAME_ENV === "prod"
+ ? buildPublicAssetManifest(getResourcesDir())
+ : {};
return ejs.render(htmlContent, {
gitCommit: JSON.stringify(process.env.GIT_COMMIT ?? "undefined"),
instanceId: JSON.stringify(process.env.INSTANCE_ID ?? "undefined"),
- assetBasePath: JSON.stringify(assetBasePath),
- manifestHref: buildAssetUrl("manifest.json", assetBasePath),
- faviconHref: buildAssetUrl("images/Favicon.svg", assetBasePath),
+ assetManifest: JSON.stringify(assetManifest),
+ manifestHref: buildAssetUrl("manifest.json", assetManifest),
+ faviconHref: buildAssetUrl("images/Favicon.svg", assetManifest),
gameplayScreenshotUrl: buildAssetUrl(
"images/GameplayScreenshot.png",
- assetBasePath,
+ assetManifest,
),
- backgroundImageUrl: buildAssetUrl("images/background.webp", assetBasePath),
- desktopLogoImageUrl: buildAssetUrl("images/OpenFront.webp", assetBasePath),
- mobileLogoImageUrl: buildAssetUrl("images/OF.webp", assetBasePath),
+ backgroundImageUrl: buildAssetUrl("images/background.webp", assetManifest),
+ desktopLogoImageUrl: buildAssetUrl("images/OpenFront.webp", assetManifest),
+ mobileLogoImageUrl: buildAssetUrl("images/OF.webp", assetManifest),
});
}
diff --git a/vite.config.ts b/vite.config.ts
index d2585dd99..695a89aae 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,16 +1,18 @@
import tailwindcss from "@tailwindcss/vite";
-import fs from "fs/promises";
import path from "path";
import { fileURLToPath } from "url";
import { defineConfig, loadEnv } from "vite";
import { createHtmlPlugin } from "vite-plugin-html";
import { viteStaticCopy } from "vite-plugin-static-copy";
import tsconfigPaths from "vite-tsconfig-paths";
+import { type AssetManifest, buildAssetUrl } from "./src/core/AssetUrls";
import {
- buildAssetUrl,
- buildVersionedAssetBasePath,
- normalizeAssetVersion,
-} from "./src/core/AssetUrls";
+ buildPublicAssetManifest,
+ copyRootPublicFiles,
+ createHashedPublicAssetFiles,
+ getResourcesDir,
+ writePublicAssetManifestFile,
+} from "./src/server/PublicAssetManifest";
// Vite already handles these, but its good practice to define them explicitly
const __filename = fileURLToPath(import.meta.url);
@@ -19,49 +21,31 @@ const __dirname = path.dirname(__filename);
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const isProduction = mode === "production";
- const assetVersion = normalizeAssetVersion(
- env.GIT_COMMIT ?? process.env.GIT_COMMIT,
- );
- const assetBasePath = buildVersionedAssetBasePath(assetVersion);
+ const resourcesDir = getResourcesDir(__dirname);
+ const assetManifest: AssetManifest = isProduction
+ ? buildPublicAssetManifest(resourcesDir)
+ : {};
const htmlAssetData = {
- assetBasePath: JSON.stringify(assetBasePath),
- manifestHref: buildAssetUrl("manifest.json", assetBasePath),
- faviconHref: buildAssetUrl("images/Favicon.svg", assetBasePath),
+ assetManifest: JSON.stringify(assetManifest),
+ manifestHref: buildAssetUrl("manifest.json", assetManifest),
+ faviconHref: buildAssetUrl("images/Favicon.svg", assetManifest),
gameplayScreenshotUrl: buildAssetUrl(
"images/GameplayScreenshot.png",
- assetBasePath,
+ assetManifest,
),
- backgroundImageUrl: buildAssetUrl("images/background.webp", assetBasePath),
- desktopLogoImageUrl: buildAssetUrl("images/OpenFront.webp", assetBasePath),
- mobileLogoImageUrl: buildAssetUrl("images/OF.webp", assetBasePath),
+ backgroundImageUrl: buildAssetUrl("images/background.webp", assetManifest),
+ desktopLogoImageUrl: buildAssetUrl("images/OpenFront.webp", assetManifest),
+ mobileLogoImageUrl: buildAssetUrl("images/OF.webp", assetManifest),
};
- const rewriteVersionedManifest = () => ({
- name: "rewrite-versioned-manifest",
+ const syncHashedPublicAssets = () => ({
+ name: "sync-hashed-public-assets",
apply: "build" as const,
- async closeBundle() {
- if (!assetVersion) {
- return;
- }
-
- const manifestPath = path.join(
- __dirname,
- "static",
- "_assets",
- assetVersion,
- "manifest.json",
- );
- const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")) as {
- icons?: Array<{ src?: string }>;
- };
- manifest.icons = manifest.icons?.map((icon) => ({
- ...icon,
- src: buildAssetUrl(icon.src ?? "", assetBasePath),
- }));
- await fs.writeFile(
- manifestPath,
- `${JSON.stringify(manifest, null, 2)}\n`,
- );
+ closeBundle() {
+ const outDir = path.join(__dirname, "static");
+ copyRootPublicFiles(resourcesDir, outDir);
+ writePublicAssetManifestFile(outDir, assetManifest);
+ createHashedPublicAssetFiles(resourcesDir, outDir, assetManifest);
},
});
@@ -92,7 +76,7 @@ export default defineConfig(({ mode }) => {
},
root: "./",
base: "/",
- publicDir: "resources", // Access static assets via import or explicit copy
+ publicDir: isProduction ? false : "resources",
resolve: {
alias: {
@@ -124,26 +108,18 @@ export default defineConfig(({ mode }) => {
]),
viteStaticCopy({
targets: [
- ...(assetVersion
- ? [
- {
- src: "resources/**/*",
- dest: `_assets/${assetVersion}`,
- },
- ]
- : []),
{
src: "proprietary/*",
dest: ".",
},
],
}),
- ...(isProduction ? [rewriteVersionedManifest()] : []),
+ ...(isProduction ? [syncHashedPublicAssets()] : []),
tailwindcss(),
],
define: {
- __ASSET_BASE_PATH__: JSON.stringify(assetBasePath),
+ __ASSET_MANIFEST__: JSON.stringify(assetManifest),
"process.env.WEBSOCKET_URL": JSON.stringify(
isProduction ? "" : "localhost:3000",
),