fix(core/bundler): inline fonts and images so a lone bundle renders (#3590)

`bundleToSingleHtml` documented itself as producing "a single
self-contained HTML file", but `INLINE_MIME` covered only `.svg`,
`.json`, `.txt`, `.cube` and `.xml`. Every font and raster image stayed
a live project-relative reference.

That is invisible to every consumer in this repo, because each one
serves the bundled string from a server rooted at the project directory,
so the relative paths resolve. It breaks the moment the bundle is stored
on its own, with no sibling asset directory: the font 404s and the page
silently reflows in a fallback face, which is worse than a visible
failure.

Widen the inline set to fonts (woff2/woff/ttf/otf) and raster images
(png/jpg/jpeg/gif/webp/avif), behind a 2 MiB per-asset cap. The cap is
measured against this repo's own assets rather than guessed: the largest
of 164 tracked `.woff2` files is 105 KB, and the largest of 284 tracked
raster images is 2.00 MB, so everything in-tree inlines while a
video-sized file cannot. Oversized assets keep their relative URL and
warn, reusing the existing "may not be self-contained" wording.

Audio and video stay external on purpose: they are large, streamed
rather than laid out, and their absence is obvious rather than silent.
Scripts already had a better path (`script[src]` is folded in as source),
so they are deliberately not added to the MIME table.

The five rebasing tests that asserted a relative path survived now
assert the data URL's decoded content instead. That is a stronger check:
resolving from the wrong base directory finds no file, so nothing
inlines and the assertion fails.
This commit is contained in:
Miguel Ángel
2026-09-13 12:47:01 -04:00
committed by GitHub
parent 4c26ffe8ce
commit cd975d8c28
2 changed files with 135 additions and 7 deletions
+78 -5
View File
@@ -20,6 +20,16 @@ function makeTempProject(files: Record<string, string>): 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, string> = {}): string {
return makeTempProject({
"index.html": `<!doctype html>
@@ -1211,7 +1221,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");
});
@@ -1230,7 +1240,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')");
});
@@ -1249,7 +1259,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')");
});
@@ -1273,7 +1283,7 @@ describe("bundleToSingleHtml", () => {
expect(bundled).toContain("url('https://cdn.example.com/font.woff2')");
expect(bundled).toContain("url('data:image/svg+xml,<svg/>')");
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 () => {
@@ -1290,7 +1300,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": `<!doctype html>
<html><head>
<style>
@font-face { font-family: "Brand"; src: url('assets/fonts/brand.woff2') format('woff2'); }
.hero { background: url('assets/hero.jpg'); }
</style>
</head><body>
<div data-composition-id="root" data-width="320" data-height="180">
<img id="logo" src="assets/logo.png" srcset="assets/logo2x.webp 2x">
<video id="clip" poster="assets/poster.gif"></video>
</div>
<script src="assets/app.js"></script>
<script>window.__timelines = window.__timelines || {}; window.__timelines.root = {}</script>
</body></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": `<!doctype html>
<html><body>
<div data-composition-id="root" data-width="320" data-height="180">
<img id="big" src="assets/huge.png">
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines.root = {}</script>
</body></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 () => {
+57 -2
View File
@@ -8,7 +8,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";
@@ -299,8 +299,54 @@ const INLINE_MIME: Record<string, string> = {
".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());
@@ -310,6 +356,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")}`;
@@ -724,7 +777,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
*/
type DeferredScriptChunk = string | (() => string);