mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-22 17:16:38 +00:00
Replace versioned asset prefix with content-hash manifest
This commit is contained in:
+1
-1
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user