diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts
index 43c2b94caf..4fe8857370 100644
--- a/packages/core/src/compiler/htmlBundler.test.ts
+++ b/packages/core/src/compiler/htmlBundler.test.ts
@@ -19,6 +19,16 @@ function makeTempProject(files: Record): string {
return dir;
}
+/**
+ * The data URL a correctly-resolved asset must inline to. Asserting on the
+ * asset's CONTENT, not on the rewritten path string, is what proves rebasing
+ * resolved to the right file: resolving from the wrong base directory finds no
+ * file at all, so nothing is inlined and the assertion fails.
+ */
+function inlinedAs(mime: string, content: string): string {
+ return `data:${mime};base64,${Buffer.from(content, "utf-8").toString("base64")}`;
+}
+
function makeColorGradingProject(lutSrc: string, files: Record = {}): string {
return makeTempProject({
"index.html": `
@@ -1210,7 +1220,7 @@ describe("bundleToSingleHtml", () => {
const bundled = await bundleToSingleHtml(dir);
- expect(bundled).toContain("url('styles/assets/fonts/brand.woff2')");
+ expect(bundled).toContain(`url('${inlinedAs("font/woff2", "fake-font-data")}')`);
expect(bundled).not.toContain("url('assets/fonts/brand.woff2')");
expect(bundled).not.toContain("@import");
});
@@ -1229,7 +1239,7 @@ describe("bundleToSingleHtml", () => {
const bundled = await bundleToSingleHtml(dir);
- expect(bundled).toContain("url('theme/images/grain.png')");
+ expect(bundled).toContain(`url('${inlinedAs("image/png", "fake-image-data")}')`);
expect(bundled).not.toContain("url('./images/grain.png')");
});
@@ -1248,7 +1258,7 @@ describe("bundleToSingleHtml", () => {
const bundled = await bundleToSingleHtml(dir);
- expect(bundled).toContain("url('assets/bg.png')");
+ expect(bundled).toContain(`url('${inlinedAs("image/png", "fake-image")}')`);
expect(bundled).not.toContain("url('../../assets/bg.png')");
});
@@ -1272,7 +1282,7 @@ describe("bundleToSingleHtml", () => {
expect(bundled).toContain("url('https://cdn.example.com/font.woff2')");
expect(bundled).toContain("url('data:image/svg+xml,')");
- expect(bundled).toContain("url('styles/img/bg.png')");
+ expect(bundled).toContain(`url('${inlinedAs("image/png", "fake")}')`);
});
it("preserves url() query strings and hash fragments during rebasing", async () => {
@@ -1289,7 +1299,70 @@ describe("bundleToSingleHtml", () => {
const bundled = await bundleToSingleHtml(dir);
- expect(bundled).toContain("url('styles/sprite.png?v=2#section')");
+ // The query/hash suffix rides along onto the inlined data URL.
+ expect(bundled).toContain(`url('${inlinedAs("image/png", "fake-sprite")}?v=2#section')`);
+ });
+
+ it("inlines fonts, images and scripts so no relative asset reference survives", async () => {
+ const dir = makeTempProject({
+ "index.html": `
+
+
+
+
+
+
+
+
+
+`,
+ "assets/fonts/brand.woff2": "font-bytes",
+ "assets/hero.jpg": "hero-bytes",
+ "assets/logo.png": "logo-bytes",
+ "assets/logo2x.webp": "logo2x-bytes",
+ "assets/poster.gif": "poster-bytes",
+ "assets/app.js": "window.__APP_LOADED__ = true;",
+ });
+
+ const bundled = await bundleToSingleHtml(dir);
+
+ // Each asset arrives as its own bytes, which is what proves its path
+ // resolved to the right file rather than merely being rewritten.
+ expect(bundled).toContain(inlinedAs("font/woff2", "font-bytes"));
+ expect(bundled).toContain(inlinedAs("image/jpeg", "hero-bytes"));
+ expect(bundled).toContain(inlinedAs("image/png", "logo-bytes"));
+ expect(bundled).toContain(inlinedAs("image/webp", "logo2x-bytes"));
+ expect(bundled).toContain(inlinedAs("image/gif", "poster-bytes"));
+ // A local classic script is folded in as source, not as a data: URL.
+ expect(bundled).toContain("window.__APP_LOADED__ = true;");
+
+ // Nothing still points into the sibling assets/ directory that a consumer
+ // storing this bundle as a lone file will not have.
+ expect(bundled).not.toMatch(/["'(]assets\//);
+ });
+
+ it("leaves an oversized asset relative and warns rather than inlining it", async () => {
+ const dir = makeTempProject({
+ "index.html": `
+
+
+
+
+
+`,
+ "assets/huge.png": "x".repeat(2 * 1024 * 1024 + 1),
+ });
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+
+ const bundled = await bundleToSingleHtml(dir);
+
+ expect(bundled).toContain('src="assets/huge.png"');
+ expect(bundled).not.toContain("data:image/png");
+ expect(warn.mock.calls.flat().join(" ")).toContain("may not be self-contained");
+ warn.mockRestore();
});
it("deduplicates diamond @import (same file imported by two parents)", async () => {
diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts
index 1eff4f9624..04821e57ff 100644
--- a/packages/core/src/compiler/htmlBundler.ts
+++ b/packages/core/src/compiler/htmlBundler.ts
@@ -3,7 +3,7 @@ export { FLATTENED_INNER_ROOT_STRIP_ATTRS } from "../runtime/flattenedRoot";
import { parseHostVariableValues, warnUnknownEnumValues } from "../runtime/getVariables";
import { sanitizeCssValue } from "../runtime/applyVariableBindings";
import { cssVariableName } from "../tokenSlug";
-import { readFileSync, existsSync } from "fs";
+import { readFileSync, existsSync, statSync } from "fs";
import { resolve, relative, dirname, isAbsolute, sep } from "path";
import { CSS_URL_RE, isNonRelativeUrl } from "./assetPaths.js";
import { transformSync } from "esbuild";
@@ -294,8 +294,54 @@ const INLINE_MIME: Record = {
".txt": "text/plain",
".cube": "text/plain",
".xml": "application/xml",
+ // Fonts and raster images. A bundle handed to a consumer that stores it as a
+ // lone object — no sibling `assets/` directory — 404s on every surviving
+ // relative reference, and a missing font silently reflows the whole frame
+ // rather than failing loudly. Media (mp4/webm/mp3/wav) is deliberately absent:
+ // it is large, streamed rather than laid out, and its absence is obvious.
+ ".woff2": "font/woff2",
+ ".woff": "font/woff",
+ ".ttf": "font/ttf",
+ ".otf": "font/otf",
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".gif": "image/gif",
+ ".webp": "image/webp",
+ ".avif": "image/avif",
};
+/**
+ * Per-asset ceiling on base64 inlining.
+ *
+ * Base64 costs ~33% over the raw bytes, so an unbounded rule turns one careless
+ * 40 MB asset into a bundle no browser should be asked to parse. 2 MiB is
+ * measured against this repo's own assets rather than picked: the largest of
+ * 164 tracked `.woff2` files is 105 KB (p90 75 KB) and the largest of 284
+ * tracked raster images is 2.00 MB (p90 437 KB). So every font and effectively
+ * every image in-tree inlines, while a video-sized file cannot.
+ *
+ * Oversized assets keep their project-relative URL — correct wherever the
+ * bundle is served from its project directory, and warned about because that is
+ * exactly where "self-contained" stops being true.
+ */
+const MAX_INLINE_ASSET_BYTES = 2 * 1024 * 1024;
+
+function safeStatSize(filePath: string): number | null {
+ try {
+ return statSync(filePath).size;
+ } catch {
+ return null;
+ }
+}
+
+function warnAssetTooLargeToInline(assetPath: string, byteLength: number): void {
+ const mb = (byteLength / (1024 * 1024)).toFixed(1);
+ console.warn(
+ `[HyperFrames] Not inlining "${assetPath}" (${mb} MB exceeds the ${MAX_INLINE_ASSET_BYTES / (1024 * 1024)} MB inline limit). The bundle may not be self-contained.`,
+ );
+}
+
function maybeInlineRelativeAssetUrl(urlValue: string, projectDir: string): string | null {
if (!urlValue || !isRelativeUrl(urlValue)) return null;
const { basePath, suffix } = splitUrlSuffix(urlValue.trim());
@@ -305,6 +351,13 @@ function maybeInlineRelativeAssetUrl(urlValue: string, projectDir: string): stri
const ext = filePath.toLowerCase().match(/\.[^.]+$/)?.[0] ?? "";
const mimeType = INLINE_MIME[ext];
if (!mimeType) return null;
+ // Size-check before reading: an oversized asset must not be pulled into memory
+ // just to be discarded.
+ const byteLength = safeStatSize(filePath);
+ if (byteLength !== null && byteLength > MAX_INLINE_ASSET_BYTES) {
+ warnAssetTooLargeToInline(basePath, byteLength);
+ return null;
+ }
const content = safeReadFileBuffer(filePath);
if (content == null) return null;
const dataUrl = `data:${mimeType};base64,${content.toString("base64")}`;
@@ -719,7 +772,9 @@ export interface BundleOptions {
* - Injects the HyperFrames runtime script
* - Inlines local CSS and JS files
* - Inlines sub-composition HTML fragments (data-composition-src)
- * - Inlines small textual assets as data URLs
+ * - Inlines textual assets, fonts and raster images as data URLs, up to a
+ * per-asset size limit; audio/video and oversized assets keep their
+ * project-relative URL and require the project directory to be served
*/
function ensureExternalScriptTag(doc: Document, src: string): void {