mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
271 lines
13 KiB
TypeScript
271 lines
13 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { createBuilder } from "vite";
|
|
import { beforeAll, afterAll, describe, expect, it } from "vite-plus/test";
|
|
import vinext from "../packages/vinext/src/index.js";
|
|
|
|
describe("App Router Production server self-hosted next/font/google headers", () => {
|
|
// Regression for a bug where vinext's `next/font/google` self-hosting
|
|
// pipeline emitted the dev-machine absolute filesystem path into the
|
|
// HTTP `Link:` response header, the HTML body's `<link rel="preload">`
|
|
// tags, and the `<style data-vinext-fonts>` `@font-face src: url(...)`
|
|
// block. `fetchAndCacheFont` in `packages/vinext/src/plugins/fonts.ts`
|
|
// downloaded Google Fonts `.woff2` files into `<root>/.vinext/fonts/`
|
|
// and wrote `path.join(fontDir, filename)` — an absolute filesystem
|
|
// path — into the cached `@font-face` CSS's `src: url(...)`. The CSS
|
|
// was then embedded verbatim as `selfHostedCSS` in the server bundle
|
|
// and every downstream consumer (the body preload tags, the Link
|
|
// response header, and the injected style block) read the same
|
|
// leaked filesystem path. In production this produced high-priority
|
|
// 404s (`<origin>/home/user/project/.vinext/fonts/...`) on every
|
|
// request and fell back to the real font only via the browser's
|
|
// unrelated runtime retry of the stylesheet CDN.
|
|
//
|
|
// The fix uses a separate fixture (`tests/fixtures/font-google-multiple`)
|
|
// rather than `app-basic` because `app-basic` is shared by many other
|
|
// tests — adding `next/font/google` to its root layout would force a
|
|
// real Google Fonts network fetch into every test run in this file.
|
|
// The mocked fetch below stands in for the Google Fonts CDN so the
|
|
// build is hermetic.
|
|
const FONT_FIXTURE_DIR = path.resolve(import.meta.dirname, "./fixtures/font-google-multiple");
|
|
const fontOutDir = path.resolve(FONT_FIXTURE_DIR, "dist");
|
|
const fontCacheDir = path.resolve(FONT_FIXTURE_DIR, ".vinext");
|
|
const nodeModulesLink = path.join(FONT_FIXTURE_DIR, "node_modules");
|
|
let fontServer: import("node:http").Server | undefined;
|
|
let fontBaseUrl: string;
|
|
|
|
beforeAll(async () => {
|
|
// Start from a clean slate so the test deterministically exercises
|
|
// `fetchAndCacheFont`'s fresh-fetch path and the writeBundle copy.
|
|
fs.rmSync(fontOutDir, { recursive: true, force: true });
|
|
fs.rmSync(fontCacheDir, { recursive: true, force: true });
|
|
|
|
// The font fixture has no installed node_modules of its own — mirror
|
|
// `font-google-build.test.ts` and symlink the repo-level node_modules
|
|
// so `vinext` resolves as a workspace package during the in-process
|
|
// build below.
|
|
const projectNodeModules = path.resolve(import.meta.dirname, "../node_modules");
|
|
fs.rmSync(nodeModulesLink, { recursive: true, force: true });
|
|
fs.symlinkSync(projectNodeModules, nodeModulesLink);
|
|
|
|
// Mock the Google Fonts CDN so the build is hermetic and
|
|
// `fetchAndCacheFont` exercises its real URL-rewrite code path
|
|
// (which used to bake the filesystem path into the cached CSS).
|
|
// The mocked CSS MUST contain `https://fonts.gstatic.com/...` URLs
|
|
// so `fetchAndCacheFont`'s regex extracts them and triggers the
|
|
// `css.split(fontUrl).join(filePath)` rewrite that was the source
|
|
// of the bug. Returning CSS with already-relative URLs would sidestep
|
|
// the failure mode.
|
|
const originalFetch = globalThis.fetch;
|
|
// Normalize every `fetch()` input shape to a plain URL string so the
|
|
// mock can match by substring. The build plugin currently always
|
|
// passes string URLs, but `globalThis.fetch` accepts `RequestInfo |
|
|
// URL` and a future change (or test helper) passing a `Request` or
|
|
// `URL` instance would otherwise be coerced to `[object Request]`
|
|
// by `String()` and silently skip the mock branches, falling through
|
|
// to a real network request for Google Fonts.
|
|
const resolveFetchUrl = (input: unknown): string => {
|
|
if (typeof input === "string") return input;
|
|
if (input instanceof URL) return input.toString();
|
|
if (typeof Request !== "undefined" && input instanceof Request) return input.url;
|
|
return String(input);
|
|
};
|
|
// Preserve `globalThis.fetch`'s full `(input, init)` signature so the
|
|
// fallback path forwards request options verbatim to the real fetch.
|
|
// The build plugin only issues plain GETs for Google Fonts today, so
|
|
// the `init` argument is never populated for the mock branches — but
|
|
// dropping it from the fallback signature would silently strip
|
|
// headers/method/body from any unrelated request that happens to run
|
|
// during the test and fall through.
|
|
globalThis.fetch = async (input: unknown, init?: RequestInit) => {
|
|
const url = resolveFetchUrl(input);
|
|
if (url.includes("fonts.googleapis.com")) {
|
|
const isMono = url.includes("Geist+Mono") || url.includes("Geist%20Mono");
|
|
const family = isMono ? "Geist Mono" : "Geist";
|
|
const gstaticUrl = `https://fonts.gstatic.com/s/${isMono ? "geistmono" : "geist"}/v1/${isMono ? "geistmono" : "geist"}-latin.woff2`;
|
|
const css = [
|
|
"/* latin */",
|
|
"@font-face {",
|
|
` font-family: '${family}';`,
|
|
" font-style: normal;",
|
|
" font-weight: 400;",
|
|
" font-display: swap;",
|
|
` src: url(${gstaticUrl}) format('woff2');`,
|
|
" unicode-range: U+0000-00FF;",
|
|
"}",
|
|
].join("\n");
|
|
return new Response(css, {
|
|
status: 200,
|
|
headers: { "content-type": "text/css" },
|
|
});
|
|
}
|
|
if (url.includes("fonts.gstatic.com")) {
|
|
// 16 bytes is plenty — the plugin writes whatever it gets to disk
|
|
// under `.vinext/fonts/<family>/<hash>.woff2`. The test never reads
|
|
// the contents back, it only asserts the file exists and serves
|
|
// with the right content-type.
|
|
return new Response(
|
|
new Uint8Array([0x77, 0x4f, 0x46, 0x32, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
|
|
{ status: 200, headers: { "content-type": "font/woff2" } },
|
|
);
|
|
}
|
|
return originalFetch(input as RequestInfo, init);
|
|
};
|
|
|
|
try {
|
|
const builder = await createBuilder({
|
|
root: FONT_FIXTURE_DIR,
|
|
configFile: false,
|
|
plugins: [
|
|
vinext({
|
|
appDir: FONT_FIXTURE_DIR,
|
|
// Pin a deploymentId to prove immutable, content-hashed font
|
|
// assets remain query-free. Next.js clears the client asset token
|
|
// when supportsImmutableAssets applies; its deployment parity
|
|
// fixture drives that path with NEXT_DEPLOYMENT_ID_IMMUTABLE and
|
|
// includes next/font/google in both App and Pages routers.
|
|
// https://github.com/vercel/next.js/blob/canary/test/production/deployment-id-handling/deployment-id-handling.test.ts
|
|
nextConfig: () => ({ deploymentId: "dpl-font-prod-test" }),
|
|
}),
|
|
],
|
|
logLevel: "silent",
|
|
build: { assetsInlineLimit: 0 },
|
|
});
|
|
await builder.buildApp();
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
|
|
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
|
|
({ server: fontServer } = await startProdServer({
|
|
port: 0,
|
|
outDir: fontOutDir,
|
|
noCompression: true,
|
|
}));
|
|
const addr = fontServer!.address();
|
|
const port = typeof addr === "object" && addr ? addr.port : 4212;
|
|
fontBaseUrl = `http://localhost:${port}`;
|
|
}, 60000);
|
|
|
|
afterAll(() => {
|
|
fontServer?.close();
|
|
fs.rmSync(fontOutDir, { recursive: true, force: true });
|
|
fs.rmSync(fontCacheDir, { recursive: true, force: true });
|
|
fs.rmSync(nodeModulesLink, { recursive: true, force: true });
|
|
});
|
|
|
|
it("emits served URLs in the HTTP Link response header (not filesystem paths)", async () => {
|
|
const res = await fetch(`${fontBaseUrl}/`);
|
|
expect(res.status).toBe(200);
|
|
const link = res.headers.get("link");
|
|
expect(link).toBeTruthy();
|
|
// Every preload in the Link header must reference the served URL
|
|
// namespace created by the fix. Before the fix, the header value was
|
|
// `</home/user/project/.vinext/fonts/geist-<hash>/geist-<hash>.woff2>`.
|
|
expect(link).toContain("/_next/static/_vinext_fonts/");
|
|
expect(link).toMatch(/rel=preload/);
|
|
expect(link).toMatch(/as=font/);
|
|
expect(link).toMatch(/type=font\/woff2/);
|
|
expect(link).not.toContain("?dpl=");
|
|
// Both the absolute dev-machine prefix and the relative cache dir
|
|
// name must be absent — the leaked path always contained both.
|
|
expect(link).not.toContain(FONT_FIXTURE_DIR);
|
|
expect(link).not.toContain(".vinext/fonts");
|
|
});
|
|
|
|
it("emits served URLs in the body <link rel=preload> tags", async () => {
|
|
const res = await fetch(`${fontBaseUrl}/`);
|
|
const html = await res.text();
|
|
expect(html).toMatch(
|
|
/<link rel="preload"[^>]*href="\/_next\/static\/_vinext_fonts\/[^"]+\.woff2"[^>]*as="font"/,
|
|
);
|
|
expect(html).not.toContain("?dpl=dpl-font-prod-test");
|
|
expect(html).not.toContain(FONT_FIXTURE_DIR);
|
|
expect(html).not.toContain(".vinext/fonts");
|
|
});
|
|
|
|
it("keeps Google and local font URLs query-free, byte-identical, and servable", async () => {
|
|
const res = await fetch(`${fontBaseUrl}/`);
|
|
const link = res.headers.get("link");
|
|
expect(link).toBeTruthy();
|
|
const html = await res.text();
|
|
const preloadHrefs = [
|
|
...html.matchAll(/<link rel="preload"[^>]*href="([^"]+)"[^>]*as="font"/g),
|
|
].map((m) => m[1]);
|
|
expect(preloadHrefs.length).toBeGreaterThan(0);
|
|
const googleFontHref = preloadHrefs.find((href) => href.includes("/_vinext_fonts/"));
|
|
const localFontHref = preloadHrefs.find((href) => !href.includes("/_vinext_fonts/"));
|
|
expect(googleFontHref).toBeDefined();
|
|
expect(localFontHref).toBeDefined();
|
|
const styleMatch = html.match(/<style data-vinext-fonts[^>]*>([\s\S]*?)<\/style>/);
|
|
expect(styleMatch).not.toBeNull();
|
|
const localFontFace = styleMatch![1]
|
|
.match(/@font-face\s*\{[^}]*\}/g)
|
|
?.find((fontFace) => fontFace.includes(localFontHref!));
|
|
expect(localFontFace).toBeDefined();
|
|
expect(localFontFace).not.toContain("font-weight:");
|
|
// The fixture mirrors #2793's 400/600/700 reproduction. Its omitted
|
|
// local-font weight also matches the equivalent Next.js fixture:
|
|
// test/e2e/next-font/app/pages/with-local-fonts.js
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/next-font/app/pages/with-local-fonts.js
|
|
for (const weight of ["400", "600", "700"]) {
|
|
expect(html).toContain(`data-local-font-weight="${weight}"`);
|
|
}
|
|
for (const href of preloadHrefs) {
|
|
expect(href).not.toContain("?");
|
|
expect(styleMatch![1]).toContain(href);
|
|
const fontRes = await fetch(`${fontBaseUrl}${href}`);
|
|
expect(fontRes.status, href).toBe(200);
|
|
expect(fontRes.headers.get("content-type")).toBe(
|
|
href === localFontHref ? "font/ttf" : "font/woff2",
|
|
);
|
|
expect(fontRes.headers.get("cache-control")).toContain("immutable");
|
|
}
|
|
const linkHrefs = [...link!.matchAll(/<([^>]+)>; rel=preload; as=font/g)].map((m) => m[1]);
|
|
expect(linkHrefs.length).toBeGreaterThan(0);
|
|
expect(linkHrefs.some((href) => href.includes("/_vinext_fonts/"))).toBe(true);
|
|
expect(linkHrefs.some((href) => !href.includes("/_vinext_fonts/"))).toBe(true);
|
|
for (const href of linkHrefs) {
|
|
expect(href).not.toContain("?");
|
|
expect(styleMatch![1]).toContain(href);
|
|
expect(preloadHrefs).toContain(href);
|
|
}
|
|
});
|
|
|
|
it("emits served URLs in the injected <style data-vinext-fonts> block", async () => {
|
|
// The injected @font-face CSS is the upstream source of truth the body
|
|
// `<link>` tags and HTTP `Link:` header are both derived from — a
|
|
// regression here would reproduce the bug across all three emission
|
|
// paths at once.
|
|
const res = await fetch(`${fontBaseUrl}/`);
|
|
const html = await res.text();
|
|
const styleMatch = html.match(/<style data-vinext-fonts[^>]*>([\s\S]*?)<\/style>/);
|
|
expect(styleMatch).not.toBeNull();
|
|
const styleContent = styleMatch![1];
|
|
expect(styleContent).toMatch(/url\(\/_next\/static\/_vinext_fonts\/[^)]+\.woff2\)/);
|
|
expect(styleContent).not.toContain("?dpl=");
|
|
expect(styleContent).not.toContain(FONT_FIXTURE_DIR);
|
|
expect(styleContent).not.toContain(".vinext/fonts");
|
|
});
|
|
|
|
it("serves the cached font files copied into the client output", async () => {
|
|
// Regression guard for the writeBundle copy hook: without it, the
|
|
// rewritten URLs would be syntactically correct but 404 at request
|
|
// time because the font files never leave `<root>/.vinext/fonts/`.
|
|
const res = await fetch(`${fontBaseUrl}/`);
|
|
const html = await res.text();
|
|
const match = html.match(/\/_next\/static\/_vinext_fonts\/[^"]+\.woff2/);
|
|
expect(match).not.toBeNull();
|
|
const fontPath = match![0];
|
|
const fontRes = await fetch(`${fontBaseUrl}${fontPath}`);
|
|
expect(fontRes.status).toBe(200);
|
|
expect(fontRes.headers.get("content-type")).toBe("font/woff2");
|
|
expect(fontRes.headers.get("cache-control")).toContain("immutable");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Malformed percent-encoded URL regression tests — App Router dev server
|
|
// (covers entries/app-rsc-entry.ts generated RSC handler decodeURIComponent)
|
|
// ---------------------------------------------------------------------------
|