Serve hashed assets from R2 via CDN_BASE (#3773)

## Description:

Add an optional CDN_BASE env var that prefixes hashed asset URLs from
asset-manifest.json, so the app can serve static assets from R2/CDN
instead of the app origin. The value is determined at runtime via the
EJS template (window.CDN_BASE) — empty string means "same origin,"
matching today's behavior.

A hack to load the worker bundle:

A same-origin Blob script that dynamic-import()s the cross-origin worker
module and buffers early postMessage calls until the imported module's
handler attaches, sidestepping the browser's refusal to construct a
Worker directly from a cross-origin URL.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] 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:

evan
This commit is contained in:
Evan
2026-04-27 11:27:54 -06:00
committed by GitHub
parent 4aeece4aef
commit 4aa726cfd8
14 changed files with 296 additions and 24 deletions
+1
View File
@@ -135,6 +135,7 @@ jobs:
GHCR_USERNAME: ${{ vars.GHCR_USERNAME }} GHCR_USERNAME: ${{ vars.GHCR_USERNAME }}
ENV: ${{ inputs.target_domain == 'openfront.io' && 'prod' || 'staging' }} ENV: ${{ inputs.target_domain == 'openfront.io' && 'prod' || 'staging' }}
HOST: ${{ github.event_name == 'workflow_dispatch' && inputs.target_host || 'staging' }} # schedule and push both use staging HOST: ${{ github.event_name == 'workflow_dispatch' && inputs.target_host || 'staging' }} # schedule and push both use staging
CDN_BASE: ${{ vars.CDN_BASE }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }} OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }} OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }}
TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }} TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }}
+4
View File
@@ -67,6 +67,7 @@ jobs:
GHCR_REPO: openfront-prod GHCR_REPO: openfront-prod
GHCR_USERNAME: ${{ vars.GHCR_USERNAME }} GHCR_USERNAME: ${{ vars.GHCR_USERNAME }}
DOMAIN: ${{ vars.DOMAIN }} DOMAIN: ${{ vars.DOMAIN }}
CDN_BASE: ${{ vars.CDN_BASE }}
IMAGE_ID: ${{ needs.build.outputs.IMAGE_ID }} IMAGE_ID: ${{ needs.build.outputs.IMAGE_ID }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }} OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }} OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }}
@@ -117,6 +118,7 @@ jobs:
GHCR_REPO: ${{ vars.GHCR_REPO }} GHCR_REPO: ${{ vars.GHCR_REPO }}
GHCR_USERNAME: ${{ vars.GHCR_USERNAME }} GHCR_USERNAME: ${{ vars.GHCR_USERNAME }}
DOMAIN: ${{ vars.DOMAIN }} DOMAIN: ${{ vars.DOMAIN }}
CDN_BASE: ${{ vars.CDN_BASE }}
IMAGE_ID: ${{ needs.build.outputs.IMAGE_ID }} IMAGE_ID: ${{ needs.build.outputs.IMAGE_ID }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }} OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }} OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }}
@@ -167,6 +169,7 @@ jobs:
GHCR_REPO: ${{ vars.GHCR_REPO }} GHCR_REPO: ${{ vars.GHCR_REPO }}
GHCR_USERNAME: ${{ vars.GHCR_USERNAME }} GHCR_USERNAME: ${{ vars.GHCR_USERNAME }}
DOMAIN: ${{ vars.DOMAIN }} DOMAIN: ${{ vars.DOMAIN }}
CDN_BASE: ${{ vars.CDN_BASE }}
IMAGE_ID: ${{ needs.build.outputs.IMAGE_ID }} IMAGE_ID: ${{ needs.build.outputs.IMAGE_ID }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }} OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }} OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }}
@@ -217,6 +220,7 @@ jobs:
GHCR_REPO: ${{ vars.GHCR_REPO }} GHCR_REPO: ${{ vars.GHCR_REPO }}
GHCR_USERNAME: ${{ vars.GHCR_USERNAME }} GHCR_USERNAME: ${{ vars.GHCR_USERNAME }}
DOMAIN: ${{ vars.DOMAIN }} DOMAIN: ${{ vars.DOMAIN }}
CDN_BASE: ${{ vars.CDN_BASE }}
IMAGE_ID: ${{ needs.build.outputs.IMAGE_ID }} IMAGE_ID: ${{ needs.build.outputs.IMAGE_ID }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }} OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }} OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }}
+1
View File
@@ -138,6 +138,7 @@ TURNSTILE_SECRET_KEY=$TURNSTILE_SECRET_KEY
API_KEY=$API_KEY API_KEY=$API_KEY
DOMAIN=$DOMAIN DOMAIN=$DOMAIN
SUBDOMAIN=$SUBDOMAIN SUBDOMAIN=$SUBDOMAIN
CDN_BASE=$CDN_BASE
OTEL_EXPORTER_OTLP_ENDPOINT=$OTEL_EXPORTER_OTLP_ENDPOINT OTEL_EXPORTER_OTLP_ENDPOINT=$OTEL_EXPORTER_OTLP_ENDPOINT
OTEL_AUTH_HEADER=$OTEL_AUTH_HEADER OTEL_AUTH_HEADER=$OTEL_AUTH_HEADER
EOL EOL
+10
View File
@@ -28,3 +28,13 @@ When a user performs an action, it creates an "Intent" which is sent to the serv
6. All executions run 6. All executions run
7. At the end of the tick core sends updates to client 7. At the end of the tick core sends updates to client
8. Client renders the updates 8. Client renders the updates
## Static Assets / CDN
The game server only renders `index.html` and serves the websocket. Every other asset (the Vite JS/CSS bundle, images, map binaries, the worker module) is served from a CDN bucket. Setting `CDN_BASE` to an empty string falls back to same-origin and is the dev default.
### `CDN_BASE` format
- Full origin, no path, no trailing slash: `https://cdn.example.com`
- Set as a build-time variable in `vite.config.ts` (so the manifest is built with absolute URLs) and as a runtime env var on the server (so `RenderHtml.ts` can prefix Vite's emitted `/assets/...` refs at request time).
- Configured in CI via `vars.CDN_BASE` in `.github/workflows/{deploy,release}.yml`.
+5
View File
@@ -11,6 +11,11 @@ DOMAIN=your-domain.com
# API Key # API Key
API_KEY=your_api_key_here API_KEY=your_api_key_here
# Optional CDN origin (e.g. https://cdn.example.com) prepended to hashed asset
# URLs from asset-manifest.json. Leave empty to serve assets from the same
# origin as the app. No trailing slash.
CDN_BASE=
# Server Hosts # Server Hosts
SERVER_HOST_STAGING=123.456.78.90 SERVER_HOST_STAGING=123.456.78.90
SERVER_HOST_NBG1=123.456.78.92 SERVER_HOST_NBG1=123.456.78.92
+1
View File
@@ -62,6 +62,7 @@
<script> <script>
window.GIT_COMMIT = <%- gitCommit %>; window.GIT_COMMIT = <%- gitCommit %>;
window.ASSET_MANIFEST = <%- assetManifest %>; window.ASSET_MANIFEST = <%- assetManifest %>;
window.CDN_BASE = <%- cdnBase %>;
window.BOOTSTRAP_CONFIG = { window.BOOTSTRAP_CONFIG = {
gameEnv: <%- gameEnv %>, gameEnv: <%- gameEnv %>,
}; };
+32 -3
View File
@@ -51,6 +51,7 @@ function isAbsoluteUrl(path: string): boolean {
export function buildAssetUrl( export function buildAssetUrl(
path: string, path: string,
assetManifest: AssetManifest = {}, assetManifest: AssetManifest = {},
baseUrl: string = "",
): string { ): string {
if (isAbsoluteUrl(path)) { if (isAbsoluteUrl(path)) {
return path; return path;
@@ -60,7 +61,7 @@ export function buildAssetUrl(
const directUrl = assetManifest[normalizedPath]; const directUrl = assetManifest[normalizedPath];
if (directUrl) { if (directUrl) {
return directUrl; return baseUrl ? `${baseUrl.replace(/\/+$/, "")}${directUrl}` : directUrl;
} }
return `/${encodeAssetPath(normalizedPath)}`; return `/${encodeAssetPath(normalizedPath)}`;
@@ -68,9 +69,11 @@ export function buildAssetUrl(
declare global { declare global {
var __ASSET_MANIFEST__: AssetManifest | undefined; var __ASSET_MANIFEST__: AssetManifest | undefined;
var __CDN_BASE__: string | undefined;
interface Window { interface Window {
ASSET_MANIFEST?: AssetManifest; ASSET_MANIFEST?: AssetManifest;
CDN_BASE?: string;
} }
} }
@@ -81,6 +84,32 @@ export function getAssetManifest(): AssetManifest {
return globalThis.__ASSET_MANIFEST__ ?? {}; return globalThis.__ASSET_MANIFEST__ ?? {};
} }
export function assetUrl(path: string): string { // Web workers have no `window`, so they read `__CDN_BASE__` off globalThis,
return buildAssetUrl(path, getAssetManifest()); // which Worker.worker.ts sets from the init message before any asset fetches.
// Without this fallback, asset fetches inside workers (e.g. map binaries)
// would silently bypass the CDN.
export function getCdnBase(): string {
if (typeof window !== "undefined" && window.CDN_BASE !== undefined) {
return window.CDN_BASE;
}
return globalThis.__CDN_BASE__ ?? "";
}
export function assetUrl(path: string): string {
return buildAssetUrl(path, getAssetManifest(), getCdnBase());
}
// Rewrites Vite's emitted /assets/... references in the built index.html to
// use the cdnBaseRaw EJS placeholder, so RenderHtml.ts can prefix them with
// CDN_BASE at request time. Scoped to src=/href= attribute values so inline
// scripts containing the literal "/assets/..." can't be mangled. Does NOT
// match /_assets/ (underscore) — source-asset manifest URLs are prefixed via
// buildAssetUrl, not this rewrite. Falls back to "" when cdnBaseRaw is missing
// so a future renderer that forgets to provide it still produces working
// same-origin URLs.
export function rewriteAssetsForCdn(html: string): string {
return html.replace(
/(\s(?:src|href)=)(["'])\/assets\//g,
`$1$2<%- locals.cdnBaseRaw || "" %>/assets/`,
);
} }
+3
View File
@@ -134,6 +134,9 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
switch (message.type) { switch (message.type) {
case "init": case "init":
try { try {
// Set before createGameRunner so map fetches via mapLoader pick up the
// CDN base. Workers have no `window`, so AssetUrls falls back to this.
globalThis.__CDN_BASE__ = message.cdnBase;
gameRunner = createGameRunner( gameRunner = createGameRunner(
message.gameStartInfo, message.gameStartInfo,
message.clientID, message.clientID,
+59 -5
View File
@@ -1,3 +1,4 @@
import { getCdnBase } from "../AssetUrls";
import { import {
BuildableUnit, BuildableUnit,
Cell, Cell,
@@ -12,6 +13,45 @@ import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
import { ClientID, GameStartInfo, Turn } from "../Schemas"; import { ClientID, GameStartInfo, Turn } from "../Schemas";
import { generateID } from "../Util"; import { generateID } from "../Util";
import { WorkerMessage } from "./WorkerMessages"; import { WorkerMessage } from "./WorkerMessages";
// ?worker&url returns the worker bundle's URL as a string. We load it via a
// same-origin Blob trampoline because browsers refuse cross-origin
// `new Worker(url)` even with valid CORS+CORP. A Blob URL is same-origin to
// the page so the constructor accepts it, and dynamic `import()` inside the
// Blob IS CORS-checked and can fetch the real worker module from the CDN.
// R2 must serve the worker bundle with `Access-Control-Allow-Origin`.
import workerUrl from "./Worker.worker.ts?worker&url";
function createGameWorker(): Worker {
const cdnBase = getCdnBase().replace(/\/+$/, "");
// Same-origin path (dev, or any deploy without CDN_BASE set): construct the
// worker directly. The Blob trampoline below is only needed for cross-origin
// loads — browsers refuse `new Worker(url)` cross-origin even with valid
// CORS+CORP, and Vite's dev server doesn't serve `?worker&url` URLs as
// regular ES modules so the trampoline's dynamic `import()` would hang.
if (!cdnBase) {
return new Worker(workerUrl, { type: "module" });
}
const fullUrl = `${cdnBase}${workerUrl}`;
// Buffer-and-replay: the worker's port enables when the trampoline script
// starts, so any messages posted before the imported module attaches its
// `message` handler would dispatch to no listener and be dropped. Capture
// them here, then re-dispatch after the import resolves.
const trampoline = `
const buffered = [];
const buffer = (e) => buffered.push(e);
self.addEventListener("message", buffer);
import(${JSON.stringify(fullUrl)}).then(() => {
self.removeEventListener("message", buffer);
for (const e of buffered) self.dispatchEvent(new MessageEvent("message", { data: e.data }));
}).catch((e) => self.postMessage({ type: "trampoline_error", message: String((e && e.message) || e) }));
`;
const blobUrl = URL.createObjectURL(
new Blob([trampoline], { type: "application/javascript" }),
);
const worker = new Worker(blobUrl, { type: "module" });
URL.revokeObjectURL(blobUrl);
return worker;
}
export class WorkerClient { export class WorkerClient {
private worker: Worker; private worker: Worker;
@@ -25,9 +65,7 @@ export class WorkerClient {
private gameStartInfo: GameStartInfo, private gameStartInfo: GameStartInfo,
private clientID: ClientID | undefined, private clientID: ClientID | undefined,
) { ) {
this.worker = new Worker(new URL("./Worker.worker.ts", import.meta.url), { this.worker = createGameWorker();
type: "module",
});
this.messageHandlers = new Map(); this.messageHandlers = new Map();
// Set up global message handler // Set up global message handler
@@ -74,8 +112,21 @@ export class WorkerClient {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const messageId = generateID(); const messageId = generateID();
const onTrampolineError = (event: MessageEvent) => {
if (event.data?.type !== "trampoline_error") return;
this.worker.removeEventListener("message", onTrampolineError);
this.messageHandlers.delete(messageId);
reject(
new Error(
`Worker trampoline import failed: ${event.data.message ?? "unknown error"}`,
),
);
};
this.worker.addEventListener("message", onTrampolineError);
this.messageHandlers.set(messageId, (message) => { this.messageHandlers.set(messageId, (message) => {
if (message.type === "initialized") { if (message.type === "initialized") {
this.worker.removeEventListener("message", onTrampolineError);
this.isInitialized = true; this.isInitialized = true;
resolve(); resolve();
} }
@@ -86,15 +137,18 @@ export class WorkerClient {
id: messageId, id: messageId,
gameStartInfo: this.gameStartInfo, gameStartInfo: this.gameStartInfo,
clientID: this.clientID, clientID: this.clientID,
cdnBase: getCdnBase(),
}); });
// Add timeout for initialization // Backstop for the worker hanging after a successful import (the
// trampoline_error path handles the cross-origin / CORS load failure).
setTimeout(() => { setTimeout(() => {
if (!this.isInitialized) { if (!this.isInitialized) {
this.worker.removeEventListener("message", onTrampolineError);
this.messageHandlers.delete(messageId); this.messageHandlers.delete(messageId);
reject(new Error("Worker initialization timeout")); reject(new Error("Worker initialization timeout"));
} }
}, 20000); // 20 second timeout }, 20000);
}); });
} }
+14 -2
View File
@@ -28,7 +28,8 @@ export type WorkerMessageType =
| "attack_clustered_positions" | "attack_clustered_positions"
| "attack_clustered_positions_result" | "attack_clustered_positions_result"
| "transport_ship_spawn" | "transport_ship_spawn"
| "transport_ship_spawn_result"; | "transport_ship_spawn_result"
| "trampoline_error";
// Base interface for all messages // Base interface for all messages
interface BaseWorkerMessage { interface BaseWorkerMessage {
@@ -41,6 +42,7 @@ export interface InitMessage extends BaseWorkerMessage {
type: "init"; type: "init";
gameStartInfo: GameStartInfo; gameStartInfo: GameStartInfo;
clientID: ClientID | undefined; clientID: ClientID | undefined;
cdnBase: string;
} }
export interface TurnMessage extends BaseWorkerMessage { export interface TurnMessage extends BaseWorkerMessage {
@@ -137,6 +139,15 @@ export interface TransportShipSpawnResultMessage extends BaseWorkerMessage {
result: TileRef | false; result: TileRef | false;
} }
// Posted by the Blob trampoline (see WorkerClient.createGameWorker) when the
// dynamic import of the real worker module fails. The real worker module
// never loaded, so no other message will ever arrive — initialize() must
// reject on this rather than wait out its timeout.
export interface TrampolineErrorMessage extends BaseWorkerMessage {
type: "trampoline_error";
message: string;
}
// Union types for type safety // Union types for type safety
export type MainThreadMessage = export type MainThreadMessage =
| InitMessage | InitMessage
@@ -159,4 +170,5 @@ export type WorkerMessage =
| PlayerProfileResultMessage | PlayerProfileResultMessage
| PlayerBorderTilesResultMessage | PlayerBorderTilesResultMessage
| AttackClusteredPositionsResultMessage | AttackClusteredPositionsResultMessage
| TransportShipSpawnResultMessage; | TransportShipSpawnResultMessage
| TrampolineErrorMessage;
+2 -1
View File
@@ -141,8 +141,9 @@ export async function buildPreview(
publicInfo: ExternalGameInfo | null, publicInfo: ExternalGameInfo | null,
): Promise<PreviewMeta> { ): Promise<PreviewMeta> {
const assetManifest = await getRuntimeAssetManifest(); const assetManifest = await getRuntimeAssetManifest();
const cdnBase = process.env.CDN_BASE ?? "";
const buildAbsoluteAssetUrl = (path: string) => const buildAbsoluteAssetUrl = (path: string) =>
new URL(buildAssetUrl(path, assetManifest), origin).toString(); new URL(buildAssetUrl(path, assetManifest, cdnBase), origin).toString();
const isFinished = !!publicInfo?.info?.end; const isFinished = !!publicInfo?.info?.end;
const isPrivate = lobby?.gameConfig?.gameType === "Private"; const isPrivate = lobby?.gameConfig?.gameType === "Private";
+21 -5
View File
@@ -13,19 +13,35 @@ const appShellContentCache = new Map<string, Promise<string>>();
export async function renderHtmlContent(htmlPath: string): Promise<string> { export async function renderHtmlContent(htmlPath: string): Promise<string> {
const htmlContent = await fs.readFile(htmlPath, "utf-8"); const htmlContent = await fs.readFile(htmlPath, "utf-8");
const assetManifest = await getRuntimeAssetManifest(); const assetManifest = await getRuntimeAssetManifest();
const cdnBase = process.env.CDN_BASE ?? "";
return ejs.render(htmlContent, { return ejs.render(htmlContent, {
gitCommit: JSON.stringify(process.env.GIT_COMMIT ?? "undefined"), gitCommit: JSON.stringify(process.env.GIT_COMMIT ?? "undefined"),
assetManifest: JSON.stringify(assetManifest), assetManifest: JSON.stringify(assetManifest),
cdnBase: JSON.stringify(cdnBase),
// Raw (unquoted) value for use as a URL prefix in the index.html template,
// e.g. <script src="<%- cdnBaseRaw %>/assets/index-XXX.js">. The Vite
// build plugin inject-cdn-base-template rewrites Vite's emitted /assets/
// refs to use this placeholder.
cdnBaseRaw: cdnBase,
gameEnv: JSON.stringify(process.env.GAME_ENV ?? "dev"), gameEnv: JSON.stringify(process.env.GAME_ENV ?? "dev"),
manifestHref: buildAssetUrl("manifest.json", assetManifest), manifestHref: buildAssetUrl("manifest.json", assetManifest, cdnBase),
faviconHref: buildAssetUrl("images/Favicon.svg", assetManifest), faviconHref: buildAssetUrl("images/Favicon.svg", assetManifest, cdnBase),
gameplayScreenshotUrl: buildAssetUrl( gameplayScreenshotUrl: buildAssetUrl(
"images/GameplayScreenshot.png", "images/GameplayScreenshot.png",
assetManifest, assetManifest,
cdnBase,
), ),
backgroundImageUrl: buildAssetUrl("images/background.webp", assetManifest), backgroundImageUrl: buildAssetUrl(
desktopLogoImageUrl: buildAssetUrl("images/OpenFront.png", assetManifest), "images/background.webp",
mobileLogoImageUrl: buildAssetUrl("images/OF.png", assetManifest), assetManifest,
cdnBase,
),
desktopLogoImageUrl: buildAssetUrl(
"images/OpenFront.png",
assetManifest,
cdnBase,
),
mobileLogoImageUrl: buildAssetUrl("images/OF.png", assetManifest, cdnBase),
}); });
} }
+107 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { buildAssetUrl } from "../src/core/AssetUrls"; import { buildAssetUrl, rewriteAssetsForCdn } from "../src/core/AssetUrls";
describe("AssetUrls", () => { describe("AssetUrls", () => {
test("returns hashed URLs for direct asset matches", () => { test("returns hashed URLs for direct asset matches", () => {
@@ -43,4 +43,110 @@ describe("AssetUrls", () => {
"Asset path must not be empty", "Asset path must not be empty",
); );
}); });
test("prefixes baseUrl onto hashed URLs when provided", () => {
expect(
buildAssetUrl(
"images/Favicon.svg",
{ "images/Favicon.svg": "/_assets/images/Favicon.hash.svg" },
"https://cdn.example.com",
),
).toBe("https://cdn.example.com/_assets/images/Favicon.hash.svg");
});
test("preserves direct URL when baseUrl is empty string", () => {
expect(
buildAssetUrl(
"images/Favicon.svg",
{ "images/Favicon.svg": "/_assets/images/Favicon.hash.svg" },
"",
),
).toBe("/_assets/images/Favicon.hash.svg");
});
test("returns absolute http(s) URLs unchanged and ignores baseUrl", () => {
expect(
buildAssetUrl(
"https://example.com/foo.png",
{},
"https://cdn.example.com",
),
).toBe("https://example.com/foo.png");
expect(buildAssetUrl("HTTP://example.com/foo.png", {})).toBe(
"HTTP://example.com/foo.png",
);
});
// Manifest miss → keep same-origin; the CDN only serves what was explicitly
// hashed and uploaded, so unknown paths must not be prefixed.
test("does not prefix baseUrl on manifest misses", () => {
expect(
buildAssetUrl("images/unknown.svg", {}, "https://cdn.example.com"),
).toBe("/images/unknown.svg");
});
test("strips trailing slashes on baseUrl to avoid double slash", () => {
const manifest = {
"images/Favicon.svg": "/_assets/images/Favicon.hash.svg",
};
expect(
buildAssetUrl("images/Favicon.svg", manifest, "https://cdn.example.com/"),
).toBe("https://cdn.example.com/_assets/images/Favicon.hash.svg");
expect(
buildAssetUrl(
"images/Favicon.svg",
manifest,
"https://cdn.example.com///",
),
).toBe("https://cdn.example.com/_assets/images/Favicon.hash.svg");
});
});
describe("rewriteAssetsForCdn", () => {
test("rewrites src=/assets/ to EJS placeholder", () => {
const out = rewriteAssetsForCdn(
`<script type="module" crossorigin src="/assets/index-XXX.js"></script>`,
);
expect(out).toBe(
`<script type="module" crossorigin src="<%- locals.cdnBaseRaw || "" %>/assets/index-XXX.js"></script>`,
);
});
test("rewrites href=/assets/ for modulepreload and stylesheet links", () => {
const out = rewriteAssetsForCdn(
`<link rel="modulepreload" href="/assets/vendor-XXX.js">\n<link rel="stylesheet" href="/assets/index-XXX.css">`,
);
expect(out).toBe(
`<link rel="modulepreload" href="<%- locals.cdnBaseRaw || "" %>/assets/vendor-XXX.js">\n<link rel="stylesheet" href="<%- locals.cdnBaseRaw || "" %>/assets/index-XXX.css">`,
);
});
test("supports single-quoted attribute values", () => {
expect(rewriteAssetsForCdn(`<script src='/assets/x.js'></script>`)).toBe(
`<script src='<%- locals.cdnBaseRaw || "" %>/assets/x.js'></script>`,
);
});
test("does not rewrite /_assets/ (underscore manifest paths)", () => {
const html = `<link rel="icon" href="/_assets/images/Favicon.hash.svg">`;
expect(rewriteAssetsForCdn(html)).toBe(html);
});
test("does not rewrite already-absolute asset URLs", () => {
const html = `<script src="https://example.com/assets/foo.js"></script>`;
expect(rewriteAssetsForCdn(html)).toBe(html);
});
// Inline scripts containing the literal "/assets/..." string must survive
// unrewrite — the regex requires whitespace before src=/href=, and inside a
// JS string literal there's no preceding `src=`/`href=` token at all.
test("does not mangle /assets/ inside inline script string literals", () => {
const html = `<script>const url = "/assets/foo";</script>`;
expect(rewriteAssetsForCdn(html)).toBe(html);
});
test("does not match data-src or other custom attributes", () => {
const html = `<img data-src="/assets/foo.png">`;
expect(rewriteAssetsForCdn(html)).toBe(html);
});
}); });
+36 -7
View File
@@ -6,7 +6,11 @@ import { fileURLToPath } from "url";
import { defineConfig, loadEnv, type Plugin } from "vite"; import { defineConfig, loadEnv, type Plugin } from "vite";
import { createHtmlPlugin } from "vite-plugin-html"; import { createHtmlPlugin } from "vite-plugin-html";
import tsconfigPaths from "vite-tsconfig-paths"; import tsconfigPaths from "vite-tsconfig-paths";
import { type AssetManifest, buildAssetUrl } from "./src/core/AssetUrls"; import {
type AssetManifest,
buildAssetUrl,
rewriteAssetsForCdn,
} from "./src/core/AssetUrls";
import { import {
buildPublicAssetManifest, buildPublicAssetManifest,
copyRootPublicFiles, copyRootPublicFiles,
@@ -57,20 +61,43 @@ export default defineConfig(({ mode }) => {
const assetManifest: AssetManifest = isProduction const assetManifest: AssetManifest = isProduction
? buildPublicAssetManifest(sourceDirs) ? buildPublicAssetManifest(sourceDirs)
: {}; : {};
const cdnBase = env.CDN_BASE ?? "";
const htmlAssetData = { const htmlAssetData = {
assetManifest: JSON.stringify(assetManifest), assetManifest: JSON.stringify(assetManifest),
cdnBase: JSON.stringify(cdnBase),
gameEnv: JSON.stringify(env.GAME_ENV ?? "dev"), gameEnv: JSON.stringify(env.GAME_ENV ?? "dev"),
manifestHref: buildAssetUrl("manifest.json", assetManifest), manifestHref: buildAssetUrl("manifest.json", assetManifest, cdnBase),
faviconHref: buildAssetUrl("images/Favicon.svg", assetManifest), faviconHref: buildAssetUrl("images/Favicon.svg", assetManifest, cdnBase),
gameplayScreenshotUrl: buildAssetUrl( gameplayScreenshotUrl: buildAssetUrl(
"images/GameplayScreenshot.png", "images/GameplayScreenshot.png",
assetManifest, assetManifest,
cdnBase,
), ),
backgroundImageUrl: buildAssetUrl("images/background.webp", assetManifest), backgroundImageUrl: buildAssetUrl(
desktopLogoImageUrl: buildAssetUrl("images/OpenFront.png", assetManifest), "images/background.webp",
mobileLogoImageUrl: buildAssetUrl("images/OF.png", assetManifest), assetManifest,
cdnBase,
),
desktopLogoImageUrl: buildAssetUrl(
"images/OpenFront.png",
assetManifest,
cdnBase,
),
mobileLogoImageUrl: buildAssetUrl("images/OF.png", assetManifest, cdnBase),
}; };
// Vite's HTML transform replaces the source <script src="/src/client/Main.ts">
// with the hashed bundle URL and injects <link rel="modulepreload"> /
// <link rel="stylesheet"> tags. rewriteAssetsForCdn rewrites those refs to
// an EJS placeholder so RenderHtml.ts can prefix them with CDN_BASE at
// request time.
const injectCdnBaseTemplate = (): Plugin => ({
name: "inject-cdn-base-template",
apply: "build" as const,
enforce: "post",
transformIndexHtml: rewriteAssetsForCdn,
});
let viteBundleFiles: string[] = []; let viteBundleFiles: string[] = [];
const syncHashedPublicAssets = (): Plugin => ({ const syncHashedPublicAssets = (): Plugin => ({
name: "sync-hashed-public-assets", name: "sync-hashed-public-assets",
@@ -157,7 +184,9 @@ export default defineConfig(({ mode }) => {
}, },
}), }),
]), ]),
...(isProduction ? [syncHashedPublicAssets()] : []), ...(isProduction
? [injectCdnBaseTemplate(), syncHashedPublicAssets()]
: []),
tailwindcss(), tailwindcss(),
], ],