Files
OpenFrontIO/src/core/AssetUrls.ts
T
Evan 4aa726cfd8 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
2026-04-27 11:27:54 -06:00

116 lines
3.3 KiB
TypeScript

export type AssetManifest = Record<string, string>;
function safeDecodeAssetSegment(segment: string): string {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
}
function assertSafeAssetSegment(segment: string): string {
const decodedSegment = safeDecodeAssetSegment(segment);
if (
segment === "." ||
segment === ".." ||
decodedSegment === "." ||
decodedSegment === ".."
) {
throw new Error(`Invalid asset path segment: ${segment}`);
}
return decodedSegment;
}
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 {
const normalizedPath = path
.replace(/^\/+/, "")
.split("/")
.filter((segment) => segment.length > 0)
.map((segment) => assertSafeAssetSegment(segment))
.join("/");
if (normalizedPath.length === 0) {
throw new Error("Asset path must not be empty");
}
return normalizedPath;
}
function isAbsoluteUrl(path: string): boolean {
return /^https?:\/\//i.test(path);
}
export function buildAssetUrl(
path: string,
assetManifest: AssetManifest = {},
baseUrl: string = "",
): string {
if (isAbsoluteUrl(path)) {
return path;
}
const normalizedPath = normalizeAssetPath(path);
const directUrl = assetManifest[normalizedPath];
if (directUrl) {
return baseUrl ? `${baseUrl.replace(/\/+$/, "")}${directUrl}` : directUrl;
}
return `/${encodeAssetPath(normalizedPath)}`;
}
declare global {
var __ASSET_MANIFEST__: AssetManifest | undefined;
var __CDN_BASE__: string | undefined;
interface Window {
ASSET_MANIFEST?: AssetManifest;
CDN_BASE?: string;
}
}
export function getAssetManifest(): AssetManifest {
if (typeof window !== "undefined" && window.ASSET_MANIFEST !== undefined) {
return window.ASSET_MANIFEST;
}
return globalThis.__ASSET_MANIFEST__ ?? {};
}
// 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/`,
);
}