[PR #819] [MERGED] fix: emit served URLs for next/font/google self-hosted assets #869

Closed
opened 2026-05-06 13:10:34 +02:00 by BreizhHardware · 0 comments

📋 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: mainHead: fix/preload-link-header-uses-served-url


📝 Commits (3)

  • df2710c fix: emit served URLs for next/font/google self-hosted assets
  • 93af737 fix(fonts): address review feedback on PR #819
  • a5c2b72 chore(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() 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: the injected <style data-vinext-fonts> block's @font-face { src: url(...) }, the HTML body's <link rel="preload"> tags, and the HTTP Link: 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 absolute href to <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 and Cloudflare's 103 Early Hints path would emit these broken entries before the HTML even starts streaming.

Any app using stock next/font/google self-hosted mode is affected — no user-facing config can work around it (#472 tracks assetPrefix support but that's orthogonal, and the leak happens regardless of whether assetPrefix is set).

Root cause

Three downstream font-preload emitters all read from the same in-memory array populated by collectFontPreloadsFromCSS() in shims/font-google-base.ts, which extracts url(...) references from _selfHostedCSS via regex:

  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 fontData.preloads[*].href.
  3. The HTTP Link: response header, built by buildAppPageFontLinkHeader() in server/app-page-execution.ts and set on the response in server/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():

// packages/vinext/src/plugins/fonts.ts
for (const [fontUrl, filename] of urls) {
  const filePath = path.join(fontDir, filename);
  // ... download + write to disk ...
  // Rewrite CSS to use absolute path (Vite will resolve /@fs/ for dev, or asset for build)
  css = css.split(fontUrl).join(filePath);
}

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 _selfHostedCSS verbatim, and nothing downstream rewrites it.

Separately, fetchAndCacheFont leaves the downloaded .woff2 files in <root>/.vinext/fonts/ and nothing ever copies them into dist/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 existing vinext:google-fonts plugin:

  1. _rewriteCachedFontCssToServedUrls() — a small helper (3 lines of real logic plus regression-detection JSDoc) that replaces the absolute cacheDir prefix in a cached CSS string with the served URL namespace /assets/_vinext_fonts. Called from injectSelfHostedCss() right before the CSS string is JSON.stringify'd into the bundle, so every downstream consumer sees the rewritten URL.

  2. writeBundle hook (client environment only) — recursively copies every .woff2/.woff/.ttf/.otf/.eot file out of <root>/.vinext/fonts/ into <clientOutDir>/assets/_vinext_fonts/, preserving the <family>-<hash>/ subdirectory structure. The existing _headers rule for /assets/* already covers the new namespace, and StaticFileCache picks the copied files up via its recursive walk of dist/client/, so Content-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 / basePath support), it just stops the absolute filesystem path from leaking into the preload emitters regardless of whether assetPrefix is set.

Tests

Two new regression tests, both verified to fail on main and pass with the fix:

  • Unittests/font-google.test.ts: five cases in a new _rewriteCachedFontCssToServedUrls describe block drive the helper directly with (a) a realistic cached CSS containing multiple url(...) 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.
  • Integrationtests/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 (stock 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 — returning CSS with already-relative URLs would sidestep the failure mode. 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.

A separate fixture is used instead of extending app-basic because app-basic is shared by every integration test in tests/app-router.test.ts — adding next/font/google to 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:

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:e2eapp-router project 322 / 322 pass (3 flaky on retry, 9 pre-existing skips)
pnpm test:e2ecloudflare-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 in the <style data-vinext-fonts> assertion, e.g.

src: url(/home/<user>/.../.vinext/fonts/geist-4db05770f54f/geist-2fba11a4.woff2) format('woff2');

and the font-file 200 OK assertion failed because the cached .woff2 files were never copied into dist/client/.

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 http://localhost:4999/
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Link: </home/<user>/<project>/.vinext/fonts/geist-4db05770f54f/geist-8e42e564.woff2>; rel=preload; as=font; type=font/woff2; crossorigin, </home/<user>/<project>/.vinext/fonts/geist-4db05770f54f/geist-bd9fc9d8.woff2>; rel=preload; as=font; type=font/woff2; crossorigin, </home/<user>/<project>/.vinext/fonts/geist-4db05770f54f/geist-79e7a99c.woff2>; rel=preload; as=font; type=font/woff2; crossorigin
$ curl -s http://localhost:4999/ | grep -oE '<link[^>]*preload[^>]*woff2[^>]*>'
<link rel="preload" href="/home/<user>/<project>/.vinext/fonts/geist-4db05770f54f/geist-8e42e564.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/home/<user>/<project>/.vinext/fonts/geist-4db05770f54f/geist-bd9fc9d8.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/home/<user>/<project>/.vinext/fonts/geist-4db05770f54f/geist-79e7a99c.woff2" as="font" type="font/woff2" crossorigin />
$ curl -sI 'http://localhost:4999/home/<user>/<project>/.vinext/fonts/geist-4db05770f54f/geist-8e42e564.woff2'
HTTP/1.1 404 Not Found

After

$ curl -sI http://localhost:4999/
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Link: </assets/_vinext_fonts/geist-4db05770f54f/geist-8e42e564.woff2>; rel=preload; as=font; type=font/woff2; crossorigin, </assets/_vinext_fonts/geist-4db05770f54f/geist-bd9fc9d8.woff2>; rel=preload; as=font; type=font/woff2; crossorigin, </assets/_vinext_fonts/geist-4db05770f54f/geist-79e7a99c.woff2>; rel=preload; as=font; type=font/woff2; crossorigin
$ curl -s http://localhost:4999/ | grep -oE '<link[^>]*preload[^>]*woff2[^>]*>'
<link rel="preload" href="/assets/_vinext_fonts/geist-4db05770f54f/geist-8e42e564.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/assets/_vinext_fonts/geist-4db05770f54f/geist-bd9fc9d8.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/assets/_vinext_fonts/geist-4db05770f54f/geist-79e7a99c.woff2" as="font" type="font/woff2" crossorigin />
$ curl -sI http://localhost:4999/assets/_vinext_fonts/geist-4db05770f54f/geist-8e42e564.woff2
HTTP/1.1 200 OK
Content-Type: font/woff2
Cache-Control: public, max-age=31536000, immutable
ETag: "99db0b97ae3a4012f8b03153490d9518"
CF-Cache-Status: HIT

The writeBundle copy hook landed the cached files in dist/client/assets/_vinext_fonts/geist-4db05770f54f/ and the existing _headers rule for /assets/* applies an immutable cache-control header at the edge — workerd returned CF-Cache-Status: HIT on the second request, identical to every other hashed asset.

References

  • #472assetPrefix / 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)
  • #812 — parallel applyMiddlewareRequestHeaders sealed-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.

## 📋 Pull Request Information **Original PR:** https://github.com/cloudflare/vinext/pull/819 **Author:** [@Shorebirdmgmt](https://github.com/Shorebirdmgmt) **Created:** 4/10/2026 **Status:** ✅ Merged **Merged:** 4/12/2026 **Merged by:** [@james-elicx](https://github.com/james-elicx) **Base:** `main` ← **Head:** `fix/preload-link-header-uses-served-url` --- ### 📝 Commits (3) - [`df2710c`](https://github.com/cloudflare/vinext/commit/df2710c7dfa7626b8766ab07c3114b151a6ccac1) fix: emit served URLs for next/font/google self-hosted assets - [`93af737`](https://github.com/cloudflare/vinext/commit/93af737a339f910a6f4ee38408fb6efdfca114bd) fix(fonts): address review feedback on PR #819 - [`a5c2b72`](https://github.com/cloudflare/vinext/commit/a5c2b72f54b873035a5db958c579ba5ef3a7a0cd) chore(fonts): address two non-blocking nits from PR #819 review ### 📊 Changes **4 files changed** (+496 additions, -3 deletions) <details> <summary>View changed files</summary> 📝 `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) </details> ### 📄 Description ## Summary `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: the injected `<style data-vinext-fonts>` block's `@font-face { src: url(...) }`, the HTML body's `<link rel="preload">` tags, and the HTTP `Link:` 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 absolute `href` to `<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 and Cloudflare's 103 Early Hints path would emit these broken entries before the HTML even starts streaming. Any app using stock `next/font/google` self-hosted mode is affected — no user-facing config can work around it (`#472` tracks `assetPrefix` support but that's orthogonal, and the leak happens regardless of whether `assetPrefix` is set). ## Root cause Three downstream font-preload emitters all read from the same in-memory array populated by `collectFontPreloadsFromCSS()` in `shims/font-google-base.ts`, which extracts `url(...)` references from `_selfHostedCSS` via regex: 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 `fontData.preloads[*].href`. 3. The HTTP `Link:` response header, built by `buildAppPageFontLinkHeader()` in `server/app-page-execution.ts` and set on the response in `server/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()`: ```ts // packages/vinext/src/plugins/fonts.ts for (const [fontUrl, filename] of urls) { const filePath = path.join(fontDir, filename); // ... download + write to disk ... // Rewrite CSS to use absolute path (Vite will resolve /@fs/ for dev, or asset for build) css = css.split(fontUrl).join(filePath); } ``` 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 `_selfHostedCSS` verbatim, and nothing downstream rewrites it. Separately, `fetchAndCacheFont` leaves the downloaded `.woff2` files in `<root>/.vinext/fonts/` and nothing ever copies them into `dist/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 existing `vinext:google-fonts` plugin: 1. **`_rewriteCachedFontCssToServedUrls()`** — a small helper (3 lines of real logic plus regression-detection JSDoc) that replaces the absolute `cacheDir` prefix in a cached CSS string with the served URL namespace `/assets/_vinext_fonts`. Called from `injectSelfHostedCss()` right before the CSS string is `JSON.stringify`'d into the bundle, so every downstream consumer sees the rewritten URL. 2. **`writeBundle` hook** (client environment only) — recursively copies every `.woff2`/`.woff`/`.ttf`/`.otf`/`.eot` file out of `<root>/.vinext/fonts/` into `<clientOutDir>/assets/_vinext_fonts/`, preserving the `<family>-<hash>/` subdirectory structure. The existing `_headers` rule for `/assets/*` already covers the new namespace, and `StaticFileCache` picks the copied files up via its recursive walk of `dist/client/`, so `Content-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` / `basePath` support), it just stops the absolute filesystem path from leaking into the preload emitters regardless of whether `assetPrefix` is set. ## Tests Two new regression tests, both verified to fail on `main` and pass with the fix: - **Unit** — `tests/font-google.test.ts`: five cases in a new `_rewriteCachedFontCssToServedUrls` describe block drive the helper directly with (a) a realistic cached CSS containing multiple `url(...)` 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. - **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 (stock `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 — returning CSS with already-relative URLs would sidestep the failure mode. 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. A separate fixture is used instead of extending `app-basic` because `app-basic` is shared by every integration test in `tests/app-router.test.ts` — adding `next/font/google` to 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: | 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 in the `<style data-vinext-fonts>` assertion, e.g. ``` src: url(/home/<user>/.../.vinext/fonts/geist-4db05770f54f/geist-2fba11a4.woff2) format('woff2'); ``` and the font-file `200 OK` assertion failed because the cached `.woff2` files were never copied into `dist/client/`. 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 http://localhost:4999/ HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 Link: </home/<user>/<project>/.vinext/fonts/geist-4db05770f54f/geist-8e42e564.woff2>; rel=preload; as=font; type=font/woff2; crossorigin, </home/<user>/<project>/.vinext/fonts/geist-4db05770f54f/geist-bd9fc9d8.woff2>; rel=preload; as=font; type=font/woff2; crossorigin, </home/<user>/<project>/.vinext/fonts/geist-4db05770f54f/geist-79e7a99c.woff2>; rel=preload; as=font; type=font/woff2; crossorigin ``` ``` $ curl -s http://localhost:4999/ | grep -oE '<link[^>]*preload[^>]*woff2[^>]*>' <link rel="preload" href="/home/<user>/<project>/.vinext/fonts/geist-4db05770f54f/geist-8e42e564.woff2" as="font" type="font/woff2" crossorigin /> <link rel="preload" href="/home/<user>/<project>/.vinext/fonts/geist-4db05770f54f/geist-bd9fc9d8.woff2" as="font" type="font/woff2" crossorigin /> <link rel="preload" href="/home/<user>/<project>/.vinext/fonts/geist-4db05770f54f/geist-79e7a99c.woff2" as="font" type="font/woff2" crossorigin /> ``` ``` $ curl -sI 'http://localhost:4999/home/<user>/<project>/.vinext/fonts/geist-4db05770f54f/geist-8e42e564.woff2' HTTP/1.1 404 Not Found ``` **After** ``` $ curl -sI http://localhost:4999/ HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 Link: </assets/_vinext_fonts/geist-4db05770f54f/geist-8e42e564.woff2>; rel=preload; as=font; type=font/woff2; crossorigin, </assets/_vinext_fonts/geist-4db05770f54f/geist-bd9fc9d8.woff2>; rel=preload; as=font; type=font/woff2; crossorigin, </assets/_vinext_fonts/geist-4db05770f54f/geist-79e7a99c.woff2>; rel=preload; as=font; type=font/woff2; crossorigin ``` ``` $ curl -s http://localhost:4999/ | grep -oE '<link[^>]*preload[^>]*woff2[^>]*>' <link rel="preload" href="/assets/_vinext_fonts/geist-4db05770f54f/geist-8e42e564.woff2" as="font" type="font/woff2" crossorigin /> <link rel="preload" href="/assets/_vinext_fonts/geist-4db05770f54f/geist-bd9fc9d8.woff2" as="font" type="font/woff2" crossorigin /> <link rel="preload" href="/assets/_vinext_fonts/geist-4db05770f54f/geist-79e7a99c.woff2" as="font" type="font/woff2" crossorigin /> ``` ``` $ curl -sI http://localhost:4999/assets/_vinext_fonts/geist-4db05770f54f/geist-8e42e564.woff2 HTTP/1.1 200 OK Content-Type: font/woff2 Cache-Control: public, max-age=31536000, immutable ETag: "99db0b97ae3a4012f8b03153490d9518" CF-Cache-Status: HIT ``` The `writeBundle` copy hook landed the cached files in `dist/client/assets/_vinext_fonts/geist-4db05770f54f/` and the existing `_headers` rule for `/assets/*` applies an immutable cache-control header at the edge — workerd returned `CF-Cache-Status: HIT` on the second request, identical to every other hashed asset. ## References - #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) - #812 — parallel `applyMiddlewareRequestHeaders` sealed-snapshot invalidation fix that followed the same report-and-fixture pattern --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
BreizhHardware 2026-05-06 13:10:34 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/vinext#869
No description provided.