Skip to content

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

Draft
miguel-heygen wants to merge 1 commit into
mainfrom
fix/bundle-inline-fonts-images
Draft

fix(core/bundler): inline fonts and images so a lone bundle renders#3590
miguel-heygen wants to merge 1 commit into
mainfrom
fix/bundle-inline-fonts-images

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

The gap

bundleToSingleHtml documents itself as producing "a single self-contained HTML file", and BundleOptions.runtime: "inline" is described as right for "any ship a single .html file use case". INLINE_MIME covered only .svg, .json, .txt, .cube and .xml, so every font and raster image stayed a live project-relative reference.

This is invisible to every consumer in this repo, because each one serves the bundled string from an HTTP server rooted at the project directory (serveStaticProjectHtml, the producer file server), so the relative paths resolve. It breaks as soon as the bundle is stored on its own with no sibling asset directory. A missing image is obvious; a missing font is not, because the page silently reflows into a fallback face and still looks plausible.

Measured, with the real bundler and real binary assets

Each bundle loaded alone in an empty directory, which is what a single-object consumer actually has:

failed requests "Brand" font usable <img> loaded
before 3 (icon.png, Brand.woff2, gsap.min.js) false false
after 1 (gsap.min.js) true true

The change

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, not guessed. Against this repo's own tracked assets: the largest of 164 .woff2 files is 105 KB (p90 75 KB), and the largest of 284 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. Base64 costs ~33%, so an unbounded rule would let one careless asset produce a bundle nothing should be asked to parse. Oversized assets keep their relative URL and warn, reusing the existing "may not be self-contained" wording.

Deliberately not included:

  • Audio and video. Large, streamed rather than laid out, and their absence is obvious rather than silent.
  • Scripts. They already have a better path: script[src] is folded in as source by the existing "Inline local JS" pass. Adding .js to the MIME table would only catch type="module" scripts, which are skipped on purpose because a data: URL changes their import base URL.

Known limitation

A <script src> written from inside a document.write string is invisible to any static rewriter, so a GSAP tag injected that way still 404s. That is the one remaining failed request in the table above. Handling it would mean executing author JS at bundle time; naming it beats pretending it is covered.

Tests

The new fixture is the point: the previous asset tests could not contain this bug, because they asserted that a relative path survived. Added a fixture with a font, four image formats, a srcset, a poster and a script, asserting no reference into assets/ survives.

The five rebasing tests that asserted a surviving relative path now assert the data URL's decoded content. That is a stronger check: resolving from the wrong base directory finds no file at all, so nothing inlines and the assertion fails.

Non-vacuousness proven by reverting the implementation: the new test fails on expected … to contain 'data:font/woff2;base64,Zm9udC1ieXRlcw…'.

Verification

  • packages/core full suite: 2589 passed (125 files), green on main beforehand as well
  • CLI tests that consume the bundler (bundleWithLocalizedFonts, validate, inspect, layout, registryBlocks): 37 passed
  • tsc --noEmit clean, oxlint/oxfmt/fallow clean

Worth a reviewer's opinion

publish already solves self-containment a different way — it zips the whole project tree, assets included. If the intended contract is that a composition always travels with its directory, then a consumer storing one key is the thing to fix, not the bundler. This change is the right one only if a bundle must be able to stand alone, which is what the function's own name and docstring already promise.

`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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant