From c222a92052db05e3b6abc4edc400b0067dc8ca0b Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 1 Sep 2026 17:02:33 -0400 Subject: [PATCH] fix(core/bundler): inline fonts and images so a lone bundle renders `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. --- .../core/src/compiler/htmlBundler.test.ts | 83 +++++++++++++++++-- packages/core/src/compiler/htmlBundler.ts | 59 ++++++++++++- 2 files changed, 135 insertions(+), 7 deletions(-) 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 {