fix: emit served URLs for next/font/google self-hosted assets#819
Conversation
`fetchAndCacheFont()` in `packages/vinext/src/plugins/fonts.ts` downloads
Google Fonts `.woff2` files into `<root>/.vinext/fonts/<family>-<urlHash>/`
and rewrites the cached `@font-face` CSS with
`css.split(fontUrl).join(path.join(fontDir, filename))` — an absolute
dev-machine filesystem path. That CSS is then embedded verbatim as
`_selfHostedCSS` in the server bundle, and every downstream consumer reads
from the same leaked string:
1. The injected `<style data-vinext-fonts>` block's
`@font-face { src: url(...) }` (via `ssrFontStyles` in
`shims/font-google-base.ts`).
2. The HTML body's `<link rel="preload">` tags emitted from
`server/app-ssr-entry.ts:renderFontHtml()` via
`collectFontPreloadsFromCSS()`'s url-extraction regex.
3. The HTTP `Link:` response header emitted from
`buildAppPageFontLinkHeader()` in `server/app-page-execution.ts` and
set on the response in `server/app-page-response.ts:242`.
Production requests on workerd produce header and body preload entries
like `</home/<user>/<project>/.vinext/fonts/geist-<hash>/geist-<hash>.woff2>`
— the browser requests `<origin>/home/<user>/...`, workerd returns 404
(the cached files are never copied into `dist/client/` either), and the
console fills with `downloadable font: download failed` and
`preloaded with link preload was not used` warnings on every page view.
Because preload is high-priority for fonts, the broken request contends
with real critical-path traffic.
The fix adds `_rewriteCachedFontCssToServedUrls()` which replaces the
`cacheDir` prefix with the served URL namespace `/assets/_vinext_fonts`
right before `_selfHostedCSS` is embedded in the bundle. A matching
`writeBundle` hook on the `vinext:google-fonts` plugin (client environment
only) recursively copies every `.woff2`/`.woff`/`.ttf`/`.otf`/`.eot` file
out of `<root>/.vinext/fonts/` into
`<clientOutDir>/assets/_vinext_fonts/` preserving the subdirectory
structure, so the rewritten URLs resolve against the origin instead of
404ing. No config surface is added — this does not implement `cloudflare#472`
(`assetPrefix` support), it just stops leaking the filesystem path
regardless of `assetPrefix`.
The existing `_headers` rule for `/assets/*` covers the new namespace,
and `StaticFileCache` picks the copied files up via its recursive walk of
`dist/client/`, so `Content-Type: font/woff2` and
`Cache-Control: public, max-age=31536000, immutable` and an automatic
content-hashed ETag all flow through without any server-side changes. On
Cloudflare the edge returns `CF-Cache-Status: HIT` on the second request,
unchanged from other assets.
## Tests
Two new regression tests, both verified to fail on `main` (before this
commit) and pass with the fix:
- **Unit** — `tests/font-google.test.ts`: five cases in a new
`_rewriteCachedFontCssToServedUrls` describe block that drive the
helper directly with crafted cache directories, multi-occurrence
replacements, regex-metacharacter paths, empty cacheDir, and
no-op CSS. Failure on `main`: the symbol does not exist, then
asserts would detect the leaked `/home/` prefix even if it did.
- **Integration** — `tests/app-router.test.ts`, in a new
`App Router Production server self-hosted next/font/google headers`
describe block. Builds the existing `tests/fixtures/font-google-multiple`
fixture (`Geist` + `Geist_Mono` via `next/font/google`) with a mocked
fetch that stands in for the Google Fonts CDN — the mock returns CSS
with real `https://fonts.gstatic.com/...` URLs so
`fetchAndCacheFont`'s regex actually exercises the path-rewriting code
that was the bug source. Starts `startProdServer()` 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/fonts`
appears 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 with `content-type: font/woff2` and an
`immutable` cache-control. Failure on `main`: asserts produce the
leaked path verbatim, e.g.
`src: url(/home/<user>/.../.vinext/fonts/geist-<hash>/geist-<hash>.woff2)`.
A separate fixture is used instead of extending `app-basic` because
`app-basic` is shared by every integration test in the file — adding
`next/font/google` to its root layout would force a real Google Fonts
network fetch into every test run.
## Verified
With the fix applied:
| Check | Result |
|---|---|
| `pnpm test:unit` | **2853 / 2853** pass |
| `pnpm test:integration` | **1262 / 1262** pass (2 pre-existing skips) |
| `pnpm run check` (lint + format + types) | **0** warnings, **0** errors |
| `pnpm test:e2e` — `app-router` project | **322 / 322** pass (3 flaky on retry, 9 pre-existing skips) |
| `pnpm test:e2e` — `cloudflare-workers` project (Miniflare) | **38 / 38** pass (1 flaky on retry) |
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 and the unit suite failed at the
export site.
End-to-end runtime verification on `workerd` (Miniflare, via
`wrangler dev`) against `examples/app-router-cloudflare` with a
`next/font/google` layout import:
- **Before** — `curl -sI /` returns
`Link: </home/<user>/<project>/.vinext/fonts/geist-<hash>/geist-<hash>.woff2>; rel=preload; as=font; ...`,
the body `<link rel="preload">` tags contain the same path, and
requesting the leaked path returns `HTTP/1.1 404 Not Found`.
- **After** — the same `curl -sI /` returns
`Link: </assets/_vinext_fonts/geist-<hash>/geist-<hash>.woff2>; rel=preload; as=font; ...`,
the body `<link>` tags match, and `curl -sI` on the served URL returns
`HTTP/1.1 200 OK`, `Content-Type: font/woff2`,
`Cache-Control: public, max-age=31536000, immutable`,
`CF-Cache-Status: HIT` on the second request.
## References
- cloudflare#472 — `assetPrefix` / `basePath` support in `next.config` (orthogonal:
this PR does not implement `assetPrefix`, it just stops the absolute
filesystem path from leaking into the preload emitters regardless of
whether `assetPrefix` is set)
- cloudflare#812 — parallel `applyMiddlewareRequestHeaders` sealed-snapshot
invalidation fix that followed the same report-and-fixture pattern
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
commit: |
|
/bigbonk review this PR |
There was a problem hiding this comment.
Review
This is a well-diagnosed and well-fixed bug. The PR description is thorough, the root cause analysis is accurate, and the fix is minimal and correct for the default configuration. The tests are solid — they cover the rewrite helper exhaustively and the integration test exercises the full production pipeline end-to-end.
One concern about build.assetsDir parity between the URL rewriter and the writeBundle copier, and a few minor notes below.
Summary
- The core approach (rewrite CSS paths before embedding, copy font files in
writeBundle) is sound and well-targeted. - Test coverage is excellent: unit tests cover edge cases (regex metacharacters, empty
cacheDir, multi-occurrence), and the integration test uses a mocked fetch that exercises the actualfetchAndCacheFontrewrite codepath. - The fixture design decision (separate fixture vs. extending
app-basic) is well-reasoned. - One real concern: hardcoded
assetsin_rewriteCachedFontCssToServedUrlswill diverge from thewriteBundlehook'sconfig.build.assetsDirwhen a user customizes it.
| */ | ||
| export function _rewriteCachedFontCssToServedUrls(css: string, cacheDir: string): string { | ||
| if (!cacheDir || !css.includes(cacheDir)) return css; | ||
| return css.split(cacheDir).join(`/${DEFAULT_ASSETS_DIR}/${VINEXT_FONT_URL_NAMESPACE}`); |
There was a problem hiding this comment.
Concern: build.assetsDir mismatch when customized.
_rewriteCachedFontCssToServedUrls hardcodes DEFAULT_ASSETS_DIR ("assets") into the served URL, but the writeBundle hook on line 887 reads the real assetsDir from this.environment.config.build.assetsDir. If a user sets build: { assetsDir: 'static' } in their Vite config, the CSS will contain /assets/_vinext_fonts/... while the files get copied to <outDir>/static/_vinext_fonts/... — producing 404s.
The JSDoc on DEFAULT_ASSETS_DIR (line 93-98) acknowledges this indirectly but frames it as "the matching writeBundle hook ... will fall back to this value when it is unset", which undersells the divergence risk.
I understand this helper is exported and used in tests without plugin state, which makes threading the real assetsDir through slightly awkward. A couple of options:
- Add an optional third parameter
assetsDir = DEFAULT_ASSETS_DIRto_rewriteCachedFontCssToServedUrlsand pass the resolvedassetsDirat the call site ininjectSelfHostedCss(where you have access to plugin state). The test API stays the same since the parameter would default. - Store the resolved
assetsDirin a closure variable (likecacheDir), populated inconfigResolved, and read it at the call site.
This is a low-probability issue (few apps customize assetsDir) but it would be a confusing 404 to debug and the fix is trivial, so worth addressing in this PR.
| handler(outputOptions: { dir?: string }) { | ||
| // Only copy on the client build — the server/SSR environments | ||
| // don't serve static assets. | ||
| if (this.environment?.name !== "client") return; |
There was a problem hiding this comment.
Nit: this.environment?.name and this.environment?.config?.build?.assetsDir — is the optional chaining on this.environment necessary here? In Vite's writeBundle hook, this.environment should always be available. If it's a defensive guard for Vite 7 compat (where this.environment might not exist), a comment would help. Otherwise the optional chaining on a property that's always present can mask bugs where the hook runs in an unexpected context.
| // of the bug. Returning CSS with already-relative URLs would sidestep | ||
| // the failure mode. | ||
| const originalFetch = globalThis.fetch; | ||
| globalThis.fetch = async (input: unknown) => { |
There was a problem hiding this comment.
Good: the mock types input as unknown and coerces to string. Worth noting that globalThis.fetch signature expects RequestInfo | URL — the input as RequestInfo cast in the fallback is fine since it's only reached for non-Google URLs, but if Vite or any test helper passes a Request object with a Google Fonts URL, String(input) would produce [object Request] and the mock would miss it.
This is unlikely to matter in practice (the build plugin always passes string URLs to fetch), just flagging for awareness.
|
Review posted. Here's a summary of the feedback: Overall: This is a well-diagnosed and well-fixed bug. The root cause analysis is accurate, the fix is minimal and correctly targeted, and the test coverage is excellent. One real concern: Two minor notes:
|
Three follow-ups from `ask-bonk`'s review of df2710c: ## 1. Thread the real `build.assetsDir` through (real concern) `_rewriteCachedFontCssToServedUrls` previously hardcoded the default `assets` directory into the served URL while the `writeBundle` hook read the real `envConfig.build.assetsDir` from Vite. A user who customized `build.assetsDir` — for example to `"static"` — would get a silent divergence: the embedded CSS would contain `/assets/_vinext_fonts/...` while the font files were copied to `<clientOutDir>/static/_vinext_fonts/...`, and every preload would 404 in production. The fix adds an optional `assetsDir` parameter to the exported helper (default `DEFAULT_ASSETS_DIR` so the existing unit tests still drive it without plugin state) and threads the resolved value from the call site in `injectSelfHostedCss`. Because `injectSelfHostedCss` is a nested function declaration with no `this` binding, the value is read once at the top of the outer `transform` handler (where `this.environment` is bound by Rollup to the plugin context) and closed over by the inner helper. Both the transform-time rewrite and the `writeBundle` copy hook now read `this.environment?.config?.build?.assetsDir ?? DEFAULT_ASSETS_DIR` from the same source, so they cannot diverge by construction. The follow-up was verified end-to-end on workerd via Miniflare with a fixture that sets `build.assetsDir: "static"` in its Vite config: - `dist/client/static/_vinext_fonts/geist-<hash>/geist-<hash>.woff2` — files landed under the custom directory - `_selfHostedCSS` in `dist/server/index.js` contains `src: url(/static/_vinext_fonts/...)` - `curl -sI /` returns `Link: </static/_vinext_fonts/...>; rel=preload; as=font; ...` - `curl -sI /static/_vinext_fonts/geist-<hash>/geist-<hash>.woff2` returns `200 OK`, `Content-Type: font/woff2`, `Cache-Control: public, max-age=31536000, immutable`, `CF-Cache-Status: HIT` Two new unit test cases in `tests/font-google.test.ts` exercise `rewriteCachedFontCssToServedUrls(..., "static")` and assert the URL prefix tracks the argument, plus a defensive-guard case where an empty string falls back to the default (so the URL never has a `//` segment). Note: the Node.js production server's request handler hardcodes `pathname.startsWith("/assets/")` as its static-file fast path (see `server/prod-server.ts:995, 1286, 1551`) — this is a pre-existing limitation affecting _all_ custom `build.assetsDir` users, not just fonts, and is explicitly out of scope for this PR. On Cloudflare Workers (the primary deployment target) the asset binding serves the entire `dist/client/` tree recursively, so the custom-`assetsDir` case works end-to-end today. ## 2. Document the `this.environment?` optional chaining (nit) The reviewer asked whether the optional chaining on `this.environment` in the `writeBundle` hook is defensive or masking bugs. It matches the existing convention in `vinext:precompress` and `vinext:cloudflare-build` (both in `src/index.ts`) but a short explainer keeps future readers from wondering. Added a comment noting that under normal Vite builds `this.environment` is always populated, the guard is there so the hook safely no-ops if invoked outside a bound environment (e.g. from a thin unit-test harness), and the early-return path is unreachable during a real build. The same comment clarifies why `this.environment.config` is read inside the hook rather than via a closure variable from `configResolved` — Vite's `configResolved` receives the top-level resolved config and the per-environment `build.assetsDir` may differ, so reading from `this.environment.config` at hook time is the only way to get the value Vite actually used for the current environment. ## 3. Normalize `fetch()` input shapes in the test mock (nit) The integration test's mock previously used `String(input)` to coerce to a URL string, which would produce `[object Request]` for a `Request` instance — not matching any of the mock's substring checks and silently falling through to a real network fetch for Google Fonts. The build plugin always passes string URLs today so this was theoretical, but the fix is a four-line `resolveFetchUrl` helper that handles `string`, `URL`, and `Request` explicitly before falling back to `String()`. Prevents a future-vinext or test-helper regression where the mock silently stops covering the build's real network calls. ## Verified With the follow-up fixes applied: | Check | Result | |---|---| | `pnpm test:unit` | **2855 / 2855** pass (+2 new `assetsDir` cases) | | `pnpm test:integration` | **1262 / 1262** pass (2 pre-existing skips) | | `pnpm run check` | **0** warnings, **0** errors | | `pnpm test:e2e` — `app-router` project | **322 / 322** pass (2 flaky on retry, 9 pre-existing skips) | | `pnpm test:e2e` — `cloudflare-workers` project (Miniflare) | **38 / 38** pass | Custom `build.assetsDir: "static"` additionally verified end-to-end on Miniflare against `examples/app-router-cloudflare` with a `next/font/google` import — `Link:` header, body `<link rel="preload">` tags, and the font file `200 OK` response all use `/static/...` consistently. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Thanks for the review. Pushed 93af737 addressing all three points. 1.
|
| Check | Result |
|---|---|
pnpm test:unit |
2855 / 2855 pass (+2 new assetsDir cases) |
pnpm test:integration |
1262 / 1262 pass (2 pre-existing skips) |
pnpm run check |
0 warnings, 0 errors |
pnpm test:e2e — app-router |
322 / 322 pass (2 flaky on retry, 9 skipped) |
pnpm test:e2e — cloudflare-workers (Miniflare) |
38 / 38 pass |
|
/bigbonk review |
There was a problem hiding this comment.
Review
This is a well-executed fix for a real production bug. The root cause analysis is accurate, the fix is minimal and correctly targeted at the right layer (rewriting CSS paths before bundle embedding so all three downstream consumers get fixed at once), and the test coverage is thorough.
What I verified
- Rewrite correctness:
_rewriteCachedFontCssToServedUrlsusessplit/joinwhich handles both the regex-metacharacter edge case and the multi-occurrence case correctly. ThecacheDirprefix frompath.join(config.root, ".vinext", "fonts")never has a trailing slash, so the split is exact. - Cache hit path:
fetchAndCacheFontreturns CSS with filesystem paths whether fresh-fetched (line 445 rewrite) or read from cache (line 401style.cssread). The rewrite function handles both since the CSS containscacheDiras a prefix either way. assetsDirthreading: After the first review's feedback, both the transform-time rewrite (line 635) and thewriteBundlecopy (line 939) readassetsDirfromthis.environment.config.build.assetsDir, so they cannot diverge. The default fallback only exists for the exported test API.- Dev mode is unaffected: The entire
injectSelfHostedCssflow is gated behindisBuild(line 834), so dev mode continues using CDN mode. writeBundlescoping: The hook correctly filters toclientenvironment only and skips non-font files._headerscoverage: The existing_headersfile generation (line 3687-3695) uses the resolvedassetsDir, so the immutable cache-control directive correctly covers/<assetsDir>/_vinext_fonts/.- Test design: The separate fixture (
font-google-multiple) and mocked fetch are the right call. The mock returns CSS with realhttps://fonts.gstatic.com/...URLs that triggerfetchAndCacheFont's path-rewrite codepath.
Minor nits (non-blocking)
-
Stale comment in
fetchAndCacheFont(line 444, not in this diff): The existing comment says "Vite will resolve/@fs/for dev, or asset for build" — this was the incorrect assumption that caused the bug. Now that_rewriteCachedFontCssToServedUrlshandles the rewrite explicitly, the comment is misleading. Worth updating to mention the rewrite in a follow-up. -
Noted caveat: The author flagged that
prod-server.tshardcodespathname.startsWith("/assets/")at lines 995/1286/1551, meaning a custombuild.assetsDirwould 404 for all hashed assets on the Node.js prod server (not just fonts). That's pre-existing and orthogonal to this PR — worth a follow-up issue but not a blocker here. On Cloudflare Workers (the primary target), the asset binding serves the entiredist/client/tree.
| if (typeof Request !== "undefined" && input instanceof Request) return input.url; | ||
| return String(input); | ||
| }; | ||
| globalThis.fetch = async (input: unknown) => { |
There was a problem hiding this comment.
Very minor: the async (input: unknown) signature loses the second init parameter of globalThis.fetch, so the fallback originalFetch(input as RequestInfo) on line 2397 never forwards request options. This doesn't matter for the current test since all intercepted calls are simple GETs and the fallback path is only for non-Google URLs during the build, but if a future test case relies on headers or method in the fallback path, it would silently drop them.
Not blocking — just noting for awareness since the resolveFetchUrl hardening was specifically about future-proofing.
|
Review posted successfully. Here's a summary of my review: Approved the PR. This is a well-executed fix for a real production bug where Key points I verified:
Two non-blocking nits:
|
…view
Both noted as non-blocking by `ask-bonk`'s approval review; landing
them keeps the review trail tidy and closes the documentation loop so
the next maintainer does not rebuild the incorrect mental model that
caused the original bug.
## 1. Stale comment in `fetchAndCacheFont`
The old comment at the `css.split(fontUrl).join(filePath)` line read
// Rewrite CSS to use absolute path
// (Vite will resolve /@fs/ for dev, or asset for build)
which was never true: the CSS ends up as a JavaScript string literal
inside the server bundle, and Vite's asset pipeline only scans real
CSS files and `import`/`new URL(...)` references — not string
literals. That incorrect assumption is exactly what seeded the leak
this PR fixes. Replaced the comment with an explicit description of
the handoff: `fetchAndCacheFont` writes absolute filesystem paths as
the on-disk intermediate form, `_rewriteCachedFontCssToServedUrls()`
rewrites the `cacheDir` prefix to the served URL namespace at embed
time, and the filesystem path must never reach the bundle, the
`<style data-vinext-fonts>` block, the body `<link rel="preload">`
tags, or the HTTP `Link:` header. The new comment ends with a "do not
resurrect that assumption" warning aimed at future maintainers.
## 2. Preserve `fetch(input, init)` signature in the test mock
`globalThis.fetch` accepts `(input, init)` but the integration test's
mock signature was `async (input: unknown) =>`, dropping `init` on
the floor. The build plugin only issues plain GETs for Google Fonts
so the current test does not need the `init` argument in any mock
branch — but the fallback `originalFetch(input as RequestInfo)` at
the bottom silently stripped headers/method/body from any unrelated
request that happened to run during the test and fall through.
Forwarding `init` through costs one extra parameter in the signature
and one extra argument at the fallback call site; it also keeps the
mock consistent with the `resolveFetchUrl` hardening landed in the
previous review round, which was justified as "future-proofing the
mock against a vinext internals change or test helper".
Neither change affects runtime behavior or test outcomes — the first
is a comment-only edit, the second is a 2-line signature-preserving
fix that never fires during the current test. Targeted runs after
the change:
- `pnpm run check` — 0 warnings, 0 errors, all 919 files formatted.
- `pnpm test:unit -t "_rewriteCachedFontCssToServedUrls"` — 7/7 pass
(unchanged).
- `pnpm test:integration -t "self-hosted next/font/google"` — 4/4
pass (unchanged).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Both non-blocking nits addressed in a5c2b72 to close the review trail cleanly. 1. Stale
|
|
/bigbonk review |
There was a problem hiding this comment.
Review
This is a clean, well-targeted fix for a real production bug. The root cause analysis is accurate, the fix is minimal and correctly placed at the right layer (CSS rewrite before bundle embedding), and the test coverage is thorough.
What I verified
- Single source of truth: The rewrite in
_rewriteCachedFontCssToServedUrlsruns once beforeJSON.stringifyembeds the CSS into the bundle. All three downstream consumers (the<style data-vinext-fonts>block, the<link rel="preload">tags, and the HTTPLink:header) read from the same rewritten string, so the fix propagates everywhere automatically. assetsDirparity: Both the transform-time rewrite (line 647, passed to the call on line 819-822) and thewriteBundlecopy (line 951) readassetsDirfromthis.environment.config.build.assetsDir, so they cannot diverge.- Dev mode unaffected: The entire
injectSelfHostedCssflow is gated behindisBuild(line 846), and thewriteBundlehook only fires during builds. writeBundlescoping: Correctly filters toclientenvironment only (line 942) and skips non-font files via the extension regex (line 967)._headerscoverage: The_headersfile generation atsrc/index.ts:3687-3691uses/${assetsDir}/*which is a wildcard covering the new_vinext_fontssubdirectory.split/joinsafety: Correct choice over regex — avoids metacharacter issues with filesystem paths (tested explicitly with thebuild (1)case).- Defensive guards: Empty
cacheDirguard prevents split-on-empty-string corruption. EmptyassetsDirfallback prevents//in URLs. - Test design: Separate fixture avoids polluting
app-basic. Mocked fetch returns CSS with realhttps://fonts.gstatic.com/...URLs that exercise the actual bug codepath. TheresolveFetchUrlhelper andinitforwarding are good future-proofing.
Pre-existing note (not blocking)
The author already flagged this: prod-server.ts:505 and static-file-cache.ts:120 both hardcode startsWith("assets/") for the immutable cache-control heuristic. With a custom build.assetsDir, font files (and all other hashed assets) would still serve 200 but with max-age=3600 instead of immutable on the Node.js prod server. On Cloudflare Workers the _headers file handles it correctly. This is orthogonal to this PR — a follow-up issue would be appropriate.
Clean fix, good tests, no concerns. Approving.
| stack.push(src); | ||
| continue; | ||
| } | ||
| if (!/\.(woff2?|ttf|otf|eot)$/i.test(entry.name)) continue; |
There was a problem hiding this comment.
Nit: The extension allowlist here (woff2?|ttf|otf|eot) is correct and covers all standard web font formats. Worth noting that if a future Google Fonts response ever included .svg font references (extremely unlikely — SVG fonts are deprecated), they'd be silently skipped. Not worth changing, just documenting the intent.
|
Review posted and approved. Here's a summary of my findings: Approved. This is a well-executed fix for a production bug where Key things I verified:
I noted one pre-existing concern (already flagged by the author): |
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