Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 78 additions & 5 deletions packages/core/src/compiler/htmlBundler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,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>
Expand Down Expand Up @@ -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");
});
Expand All @@ -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')");
});

Expand All @@ -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')");
});

Expand All @@ -1272,7 +1282,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 () => {
Expand All @@ -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": `<!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 () => {
Expand Down
59 changes: 57 additions & 2 deletions packages/core/src/compiler/htmlBundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -294,8 +294,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());
Expand All @@ -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")}`;
Expand Down Expand Up @@ -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 {
Expand Down
Loading