mirror of
https://github.com/cloudflare/vinext.git
synced 2026-05-09 08:25:34 +02:00
[PR #819] [MERGED] fix: emit served URLs for next/font/google self-hosted assets #869
Labels
No labels
enhancement
enhancement
good first issue
help wanted
nextjs-tracking
nextjs-tracking
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
starred/vinext#869
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
📋 Pull Request Information
Original PR: https://github.com/cloudflare/vinext/pull/819
Author: @Shorebirdmgmt
Created: 4/10/2026
Status: ✅ Merged
Merged: 4/12/2026
Merged by: @james-elicx
Base:
main← Head:fix/preload-link-header-uses-served-url📝 Commits (3)
df2710cfix: emit served URLs for next/font/google self-hosted assets93af737fix(fonts): address review feedback on PR #819a5c2b72chore(fonts): address two non-blocking nits from PR #819 review📊 Changes
4 files changed (+496 additions, -3 deletions)
View changed files
📝
packages/vinext/src/index.ts(+2 -1)📝
packages/vinext/src/plugins/fonts.ts(+171 -2)📝
tests/app-router.test.ts(+196 -0)📝
tests/font-google.test.ts(+127 -0)📄 Description
Summary
fetchAndCacheFont()inpackages/vinext/src/plugins/fonts.tsdownloads Google Fonts.woff2files into<root>/.vinext/fonts/<family>-<urlHash>/and rewrites the cached@font-faceCSS withcss.split(fontUrl).join(path.join(fontDir, filename))— an absolute dev-machine filesystem path. That CSS is then embedded verbatim as_selfHostedCSSin the server bundle, and every downstream consumer reads from the same leaked string: the injected<style data-vinext-fonts>block's@font-face { src: url(...) }, the HTML body's<link rel="preload">tags, and the HTTPLink:response header.Production requests on workerd produce header and body preload entries like
</home/<user>/<project>/.vinext/fonts/geist-<hash>/geist-<hash>.woff2>— the browser follows the absolutehrefto<origin>/home/<user>/..., workerd returns 404 (the cached files are never copied intodist/client/either), and the console fills withdownloadable font: download failedandpreloaded with link preload was not usedwarnings on every page view. Because preload is high-priority for fonts, the broken request contends with real critical-path traffic and Cloudflare's 103 Early Hints path would emit these broken entries before the HTML even starts streaming.Any app using stock
next/font/googleself-hosted mode is affected — no user-facing config can work around it (#472tracksassetPrefixsupport but that's orthogonal, and the leak happens regardless of whetherassetPrefixis set).Root cause
Three downstream font-preload emitters all read from the same in-memory array populated by
collectFontPreloadsFromCSS()inshims/font-google-base.ts, which extractsurl(...)references from_selfHostedCSSvia regex:<style data-vinext-fonts>block's@font-face { src: url(...) }(viassrFontStylesinshims/font-google-base.ts).<link rel="preload">tags emitted fromserver/app-ssr-entry.ts:renderFontHtml()viafontData.preloads[*].href.Link:response header, built bybuildAppPageFontLinkHeader()inserver/app-page-execution.tsand set on the response inserver/app-page-response.ts:242.All three read from the same source of truth, so a single fix at the CSS level propagates to every emitter.
The upstream source — the cached CSS — is written by
fetchAndCacheFont():The "Vite will resolve /@fs/ for dev, or asset for build" comment describes the intended behaviour but is not what actually happens: the CSS is embedded as a JavaScript string literal in the bundle, and Vite's asset pipeline operates on CSS files and
import/new URL(...)references — not on string literals inside JS. The filesystem path gets baked into_selfHostedCSSverbatim, and nothing downstream rewrites it.Separately,
fetchAndCacheFontleaves the downloaded.woff2files in<root>/.vinext/fonts/and nothing ever copies them intodist/client/— so even a correctly-rewritten URL would 404 in production without a companion copy step.Fix
Two changes in
packages/vinext/src/plugins/fonts.ts, both inside the existingvinext:google-fontsplugin:_rewriteCachedFontCssToServedUrls()— a small helper (3 lines of real logic plus regression-detection JSDoc) that replaces the absolutecacheDirprefix in a cached CSS string with the served URL namespace/assets/_vinext_fonts. Called frominjectSelfHostedCss()right before the CSS string isJSON.stringify'd into the bundle, so every downstream consumer sees the rewritten URL.writeBundlehook (client environment only) — recursively copies every.woff2/.woff/.ttf/.otf/.eotfile out of<root>/.vinext/fonts/into<clientOutDir>/assets/_vinext_fonts/, preserving the<family>-<hash>/subdirectory structure. The existing_headersrule for/assets/*already covers the new namespace, andStaticFileCachepicks the copied files up via its recursive walk ofdist/client/, soContent-Type: font/woff2,Cache-Control: public, max-age=31536000, immutable, and an automatic content-hashed ETag all flow through without any server-side changes.No config surface is added — this does not implement
#472(assetPrefix/basePathsupport), it just stops the absolute filesystem path from leaking into the preload emitters regardless of whetherassetPrefixis set.Tests
Two new regression tests, both verified to fail on
mainand pass with the fix:tests/font-google.test.ts: five cases in a new_rewriteCachedFontCssToServedUrlsdescribe block drive the helper directly with (a) a realistic cached CSS containing multipleurl(...)references, (b) multi-occurrence replacement where the same path appears multiple times in a single block, (c) a cache directory containing regex metacharacters (/tmp/build (1)/...) to prove split/join is safer than a constructed regex, (d) CSS that never references the cache directory (no-op), and (e) an empty cacheDir defensive guard so a split on""doesn't insert the URL namespace between every character.tests/app-router.test.ts, in a newApp Router Production server self-hosted next/font/google headersdescribe block. Builds the existingtests/fixtures/font-google-multiplefixture (stockGeist+Geist_Monovianext/font/google) with a mocked fetch that stands in for the Google Fonts CDN. The mock returns CSS with realhttps://fonts.gstatic.com/...URLs sofetchAndCacheFont's regex actually exercises the path-rewriting code that was the bug source — returning CSS with already-relative URLs would sidestep the failure mode. StartsstartProdServer()on a random port and asserts on the raw HTTP response: (a)Link:header contains the served URL and neither the absolute fixture path nor.vinext/fontsappears anywhere in it, (b) body<link rel="preload">tags match/assets/_vinext_fonts/..., (c) the injected<style data-vinext-fonts>block's@font-face src: url()also uses the served URL, and (d) the copied font file serves 200 withcontent-type: font/woff2and animmutablecache-control.A separate fixture is used instead of extending
app-basicbecauseapp-basicis shared by every integration test intests/app-router.test.ts— addingnext/font/googleto its root layout would force a real Google Fonts network fetch into every test run in the file. The mocked-fetch approach keeps the test hermetic.Verified
With the fix applied:
pnpm test:unitpnpm test:integrationpnpm run check(lint + format + types)pnpm test:e2e—app-routerprojectpnpm test:e2e—cloudflare-workersproject (Miniflare)Both new tests were also re-run with the fix reverted on a clean tree to confirm they deterministically catch the regression — the integration suite printed the leaked path verbatim in the
<style data-vinext-fonts>assertion, e.g.and the font-file
200 OKassertion failed because the cached.woff2files were never copied intodist/client/.End-to-end runtime verification on
workerd(Miniflare, viawrangler dev) againstexamples/app-router-cloudflarewith anext/font/googlelayout import:Before
After
The
writeBundlecopy hook landed the cached files indist/client/assets/_vinext_fonts/geist-4db05770f54f/and the existing_headersrule for/assets/*applies an immutable cache-control header at the edge — workerd returnedCF-Cache-Status: HITon the second request, identical to every other hashed asset.References
assetPrefix/basePathsupport innext.config(orthogonal: this PR does not implementassetPrefix, it just stops the absolute filesystem path from leaking into the preload emitters regardless of whetherassetPrefixis set)applyMiddlewareRequestHeaderssealed-snapshot invalidation fix that followed the same report-and-fixture pattern🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.