mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user