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
+32 -3
View File
@@ -51,6 +51,7 @@ function isAbsoluteUrl(path: string): boolean {
export function buildAssetUrl(
path: string,
assetManifest: AssetManifest = {},
baseUrl: string = "",
): string {
if (isAbsoluteUrl(path)) {
return path;
@@ -60,7 +61,7 @@ export function buildAssetUrl(
const directUrl = assetManifest[normalizedPath];
if (directUrl) {
return directUrl;
return baseUrl ? `${baseUrl.replace(/\/+$/, "")}${directUrl}` : directUrl;
}
return `/${encodeAssetPath(normalizedPath)}`;
@@ -68,9 +69,11 @@ export function buildAssetUrl(
declare global {
var __ASSET_MANIFEST__: AssetManifest | undefined;
var __CDN_BASE__: string | undefined;
interface Window {
ASSET_MANIFEST?: AssetManifest;
CDN_BASE?: string;
}
}
@@ -81,6 +84,32 @@ export function getAssetManifest(): AssetManifest {
return globalThis.__ASSET_MANIFEST__ ?? {};
}
export function assetUrl(path: string): string {
return buildAssetUrl(path, getAssetManifest());
// Web workers have no `window`, so they read `__CDN_BASE__` off globalThis,
// 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) {
case "init":
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(
message.gameStartInfo,
message.clientID,
+59 -5
View File
@@ -1,3 +1,4 @@
import { getCdnBase } from "../AssetUrls";
import {
BuildableUnit,
Cell,
@@ -12,6 +13,45 @@ import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
import { ClientID, GameStartInfo, Turn } from "../Schemas";
import { generateID } from "../Util";
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 {
private worker: Worker;
@@ -25,9 +65,7 @@ export class WorkerClient {
private gameStartInfo: GameStartInfo,
private clientID: ClientID | undefined,
) {
this.worker = new Worker(new URL("./Worker.worker.ts", import.meta.url), {
type: "module",
});
this.worker = createGameWorker();
this.messageHandlers = new Map();
// Set up global message handler
@@ -74,8 +112,21 @@ export class WorkerClient {
return new Promise((resolve, reject) => {
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) => {
if (message.type === "initialized") {
this.worker.removeEventListener("message", onTrampolineError);
this.isInitialized = true;
resolve();
}
@@ -86,15 +137,18 @@ export class WorkerClient {
id: messageId,
gameStartInfo: this.gameStartInfo,
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(() => {
if (!this.isInitialized) {
this.worker.removeEventListener("message", onTrampolineError);
this.messageHandlers.delete(messageId);
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_result"
| "transport_ship_spawn"
| "transport_ship_spawn_result";
| "transport_ship_spawn_result"
| "trampoline_error";
// Base interface for all messages
interface BaseWorkerMessage {
@@ -41,6 +42,7 @@ export interface InitMessage extends BaseWorkerMessage {
type: "init";
gameStartInfo: GameStartInfo;
clientID: ClientID | undefined;
cdnBase: string;
}
export interface TurnMessage extends BaseWorkerMessage {
@@ -137,6 +139,15 @@ export interface TransportShipSpawnResultMessage extends BaseWorkerMessage {
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
export type MainThreadMessage =
| InitMessage
@@ -159,4 +170,5 @@ export type WorkerMessage =
| PlayerProfileResultMessage
| PlayerBorderTilesResultMessage
| AttackClusteredPositionsResultMessage
| TransportShipSpawnResultMessage;
| TransportShipSpawnResultMessage
| TrampolineErrorMessage;
+2 -1
View File
@@ -141,8 +141,9 @@ export async function buildPreview(
publicInfo: ExternalGameInfo | null,
): Promise<PreviewMeta> {
const assetManifest = await getRuntimeAssetManifest();
const cdnBase = process.env.CDN_BASE ?? "";
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 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> {
const htmlContent = await fs.readFile(htmlPath, "utf-8");
const assetManifest = await getRuntimeAssetManifest();
const cdnBase = process.env.CDN_BASE ?? "";
return ejs.render(htmlContent, {
gitCommit: JSON.stringify(process.env.GIT_COMMIT ?? "undefined"),
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"),
manifestHref: buildAssetUrl("manifest.json", assetManifest),
faviconHref: buildAssetUrl("images/Favicon.svg", assetManifest),
manifestHref: buildAssetUrl("manifest.json", assetManifest, cdnBase),
faviconHref: buildAssetUrl("images/Favicon.svg", assetManifest, cdnBase),
gameplayScreenshotUrl: buildAssetUrl(
"images/GameplayScreenshot.png",
assetManifest,
cdnBase,
),
backgroundImageUrl: buildAssetUrl("images/background.webp", assetManifest),
desktopLogoImageUrl: buildAssetUrl("images/OpenFront.png", assetManifest),
mobileLogoImageUrl: buildAssetUrl("images/OF.png", assetManifest),
backgroundImageUrl: buildAssetUrl(
"images/background.webp",
assetManifest,
cdnBase,
),
desktopLogoImageUrl: buildAssetUrl(
"images/OpenFront.png",
assetManifest,
cdnBase,
),
mobileLogoImageUrl: buildAssetUrl("images/OF.png", assetManifest, cdnBase),
});
}