mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 06:10:42 +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:
@@ -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 }}
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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/`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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(),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user