mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-27 18:34:16 +00:00
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:
+32
-3
@@ -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/`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user