Replace versioned asset prefix with content-hash manifest

This commit is contained in:
scamiv
2026-03-22 02:29:22 +01:00
parent 6b42a31eaa
commit 137c3ef501
7 changed files with 239 additions and 90 deletions
+1 -1
View File
@@ -57,7 +57,7 @@
<script>
window.GIT_COMMIT = <%- gitCommit %>;
window.INSTANCE_ID = <%- instanceId %>;
window.ASSET_BASE_PATH = <%- assetBasePath %>;
window.ASSET_MANIFEST = <%- assetManifest %>;
</script>
<!-- CrazyGames SDK -->
+32 -25
View File
@@ -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<string, string>;
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());
}
+1
View File
@@ -15,6 +15,7 @@ import {
} from "./WorkerMessages";
const ctx: Worker = self as any;
globalThis.__ASSET_MANIFEST__ = __ASSET_MANIFEST__;
let gameRunner: Promise<GameRunner> | null = null;
const mapLoader = new FetchGameMapLoader(() => assetUrl("maps"));
// Yield threshold; not a backlog cap. Used to avoid monopolizing the worker task
+10 -3
View File
@@ -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";
+151
View File
@@ -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<string, AssetManifest>();
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<string>();
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`);
}
+16 -9
View File
@@ -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<string> {
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),
});
}
+28 -52
View File
@@ -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",
),