From 137c3ef5010336ea5466277065b362297b0d7d4e Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Sun, 22 Mar 2026 02:29:22 +0100 Subject: [PATCH] Replace versioned asset prefix with content-hash manifest --- index.html | 2 +- src/core/AssetUrls.ts | 57 ++++++----- src/core/worker/Worker.worker.ts | 1 + src/server/GamePreviewBuilder.ts | 13 ++- src/server/PublicAssetManifest.ts | 151 ++++++++++++++++++++++++++++++ src/server/RenderHtml.ts | 25 +++-- vite.config.ts | 80 ++++++---------- 7 files changed, 239 insertions(+), 90 deletions(-) create mode 100644 src/server/PublicAssetManifest.ts 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", ),