Files
cloudflare__vinext/tests/static-file-cache.test.ts
T
Steve Faulkner 54497d42e7 fix: dev-server polish batch — HTML charset, client global polyfill, trailingSlash image endpoint (#2512)
* fix(pages): send charset=utf-8 on HTML Content-Type

Next.js serves every HTML response (SSR and prerendered) with
`Content-Type: text/html; charset=utf-8`, but vinext's Pages Router
paths sent a bare `text/html`. Without the header charset — and without
an early <meta charset> in the page — Chromium falls back to
windows-1252, so non-ASCII content renders as mojibake (e.g. nbsp as
'Â ') and the resulting DOM diverges from the hydrated tree, triggering
the full-screen hydration error overlay in dev. Reproduced against
nextjs-notion-starter-kit; baseline `next dev`/`next start` both send
the charset.

Append `; charset=utf-8` on every Pages Router HTML producer (the App
Router paths already send it):

- pages-page-response.ts — page render response headers + gSSP header
  merge
- dev-server.ts — streaming SSR, ISR HIT/STALE, and static-HTML dev
  responses
- pages-page-data.ts — ISR cache HIT/STALE responses in prod
- pages-request-pipeline.ts — the `defaultContentType` a buffering
  adapter applies when a render response carries no Content-Type
- static-file-cache.ts — `.html` static files (prerendered pages),
  matching Next.js static serving

Compression negotiation is unaffected: COMPRESSIBLE_TYPES matching
splits the media type on ";" before lookup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(client): polyfill `global` in browser bundles

Next.js exposes the Node-style `global` alias to client code: webpack
via its `node.global` runtime shim, Turbopack by compile-time rewriting
the free `global` identifier to its globalThis shortcut and folding
`typeof global` to "object" (turbopack-ecmascript references). vinext
provided nothing, so any client dependency that reads `global` (e.g.
use-dark-mode via nextjs-notion-starter-kit) threw
`ReferenceError: global is not defined` after hydration.

Add a `vinext:client-global-define` plugin that scopes
`define: { global: "globalThis" }` to the client environment:

- builds statically rewrite free `global` references (Turbopack-style)
- dev injects `"global": globalThis` into the client runtime defines
  (/@vite/env), assigning `globalThis.global` before user code runs
  (webpack-style)
- the same define is layered into the client dep optimizer
  (rolldownOptions.transform.define / esbuildOptions.define) because
  pre-bundled deps bypass the plugin transform pipeline

`typeof global` evaluates to "object" in the browser either way,
matching Next.js. Server environments are untouched — `global` remains
the real Node global — and a user-configured `compiler.define.global`
takes precedence, mirroring Turbopack's or_insert free-var semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(images): accept /_next/image/ with trailingSlash: true

With `trailingSlash: true`, the App Router dev handler 308-redirects
`/_next/image?url=...` to `/_next/image/?url=...` (the trailing-slash
normalizer runs before the image-endpoint check), but the image endpoint
only matched the exact `/_next/image` pathname — so the redirected
request 404'd and every dev-mode next/image request broke. Reproduced
on tailwind-nextjs-starter-blog, which ships trailingSlash: true.

Baseline Next 16.2.10 behaves the same way up to the redirect (dev with
trailingSlash: true also 308s `/_next/image` to `/_next/image/`) but
then SERVES the slashed form: its route matching strips a trailing
slash before matching internal paths (getItem in
packages/next/src/server/lib/router-utils/filesystem.ts), so image
requests never fail.

Match that: isImageOptimizationPath() now strips a single trailing
slash before comparing, which covers every caller — App Router
dev/prod (app-rsc-handler), Pages Router dev middleware, the Node prod
server, and the Cloudflare worker entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: align existing assertions with charset and trailing-slash fixes

Three assertions added on main after these fixes were authored still
asserted the old behavior: bare text/html Content-Type in
static-file-cache and the pages pipeline defaultContentType, and
isImageOptimizationPath rejecting the trailing-slash form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 00:46:29 +01:00

351 lines
14 KiB
TypeScript

/**
* Tests for the startup metadata cache used by the production server.
*
* StaticFileCache walks dist/client/ once at server startup, caches file
* metadata (path, size, content-type, cache-control, etag, precompressed
* variant paths), and serves lookups from memory with zero filesystem calls.
*/
import { describe, it, expect, beforeEach, afterEach } from "vite-plus/test";
import fsp from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import zlib from "node:zlib";
import { StaticFileCache } from "../packages/vinext/src/server/static-file-cache.js";
/** Create a temp directory that mimics dist/client/ structure. */
async function setupClientDir(): Promise<string> {
const dir = path.join(
os.tmpdir(),
`vinext-cache-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
await fsp.mkdir(dir, { recursive: true });
return dir;
}
async function writeFile(
clientDir: string,
relativePath: string,
content: string | Buffer,
): Promise<void> {
const fullPath = path.join(clientDir, relativePath);
await fsp.mkdir(path.dirname(fullPath), { recursive: true });
await fsp.writeFile(fullPath, content);
}
describe("StaticFileCache", () => {
let clientDir: string;
beforeEach(async () => {
clientDir = await setupClientDir();
});
afterEach(async () => {
await fsp.rm(clientDir, { recursive: true, force: true });
});
// ── Creation and scanning ──────────────────────────────────────
it("creates a cache by scanning the client directory", async () => {
await writeFile(clientDir, "_next/static/app-abc123.js", "const x = 1;");
const cache = await StaticFileCache.create(clientDir);
expect(cache).toBeDefined();
});
it("handles empty client directory", async () => {
const cache = await StaticFileCache.create(clientDir);
expect(cache.lookup("/_next/static/nope.js")).toBeUndefined();
});
it("handles non-existent client directory gracefully", async () => {
const cache = await StaticFileCache.create(path.join(clientDir, "does-not-exist"));
expect(cache.lookup("/anything")).toBeUndefined();
});
// ── Lookup ─────────────────────────────────────────────────────
it("returns cached metadata for an existing file", async () => {
await writeFile(clientDir, "_next/static/index-abc123.js", "const x = 1;");
const cache = await StaticFileCache.create(clientDir);
const entry = cache.lookup("/_next/static/index-abc123.js");
expect(entry).toBeDefined();
expect(entry!.original.headers["Content-Type"]).toBe("application/javascript");
expect(entry!.original.headers["Content-Length"]).toBe("12"); // "const x = 1;"
expect(entry!.original.path).toBe(path.join(clientDir, "_next/static/index-abc123.js"));
});
it("returns undefined for non-existent files", async () => {
await writeFile(clientDir, "_next/static/real-abc123.js", "x");
const cache = await StaticFileCache.create(clientDir);
expect(cache.lookup("/_next/static/missing-xyz789.js")).toBeUndefined();
});
it("sets immutable cache-control for hashed assets under /assets/", async () => {
await writeFile(clientDir, "_next/static/bundle-abc123.js", "x".repeat(100));
const cache = await StaticFileCache.create(clientDir);
const entry = cache.lookup("/_next/static/bundle-abc123.js");
expect(entry!.original.headers["Cache-Control"]).toBe("public, max-age=31536000, immutable");
});
it("sets short cache-control for non-hashed files", async () => {
await writeFile(clientDir, "favicon.ico", "icon-data");
const cache = await StaticFileCache.create(clientDir);
const entry = cache.lookup("/favicon.ico");
expect(entry!.original.headers["Cache-Control"]).toBe("public, max-age=3600");
});
it("generates weak etag from filename hash for hashed assets", async () => {
await writeFile(clientDir, "_next/static/app-abc123.css", ".body { margin: 0; }");
const cache = await StaticFileCache.create(clientDir);
const entry = cache.lookup("/_next/static/app-abc123.css");
// Hashed assets use the content hash from the filename
expect(entry!.etag).toBe('W/"abc123"');
});
it("generates stable weak etag for dot-delimited managed image hashes", async () => {
await writeFile(clientDir, "_next/static/media/photo.0123abcd.png", "image bytes");
const cache = await StaticFileCache.create(clientDir);
const entry = cache.lookup("/_next/static/media/photo.0123abcd.png");
expect(entry!.etag).toBe('W/"0123abcd"');
});
it("falls back to mtime etag for non-hashed files", async () => {
await writeFile(clientDir, "favicon.ico", "icon");
const cache = await StaticFileCache.create(clientDir);
const entry = cache.lookup("/favicon.ico");
expect(entry!.etag).toMatch(/^W\/"\d+-\d+"$/);
});
it("falls back to mtime etag for assets without hash suffix", async () => {
await writeFile(clientDir, "_next/static/logo.svg", "<svg></svg>");
const cache = await StaticFileCache.create(clientDir);
const entry = cache.lookup("/_next/static/logo.svg");
// No hash in filename — falls back to mtime-based ETag
expect(entry!.etag).toMatch(/^W\/"\d+-\d+"$/);
});
it("does not treat non-hash suffixes as hashed asset etags", async () => {
await writeFile(clientDir, "_next/static/my-library-v2.0.0.js", "export {};");
const cache = await StaticFileCache.create(clientDir);
const entry = cache.lookup("/_next/static/my-library-v2.0.0.js");
expect(entry!.etag).toMatch(/^W\/"\d+-\d+"$/);
});
it("does not treat arbitrary dot suffixes as managed image hashes", async () => {
await writeFile(clientDir, "_next/static/media/photo.deadbeefg.png", "image bytes");
const cache = await StaticFileCache.create(clientDir);
const entry = cache.lookup("/_next/static/media/photo.deadbeefg.png");
expect(entry!.etag).toMatch(/^W\/"\d+-\d+"$/);
});
it("does not trust exact dot-hash suffixes outside managed media", async () => {
await writeFile(clientDir, "_next/static/config.deadbeef.json", '{"version":1}');
const cache = await StaticFileCache.create(clientDir);
const entry = cache.lookup("/_next/static/config.deadbeef.json");
expect(entry!.etag).toMatch(/^W\/"\d+-\d+"$/);
expect(entry!.etag).not.toBe('W/"deadbeef"');
expect(entry!.original.headers["Cache-Control"]).toBe("public, max-age=31536000, immutable");
});
// ── Precompressed variants ─────────────────────────────────────
it("detects brotli precompressed variant", async () => {
const content = "const x = 1;\n".repeat(200);
await writeFile(clientDir, "_next/static/app-abc123.js", content);
// Simulate build-time precompression
const brContent = zlib.brotliCompressSync(Buffer.from(content));
await writeFile(clientDir, "_next/static/app-abc123.js.br", brContent);
const cache = await StaticFileCache.create(clientDir);
const entry = cache.lookup("/_next/static/app-abc123.js");
expect(entry!.br?.path).toBe(path.join(clientDir, "_next/static/app-abc123.js.br"));
expect(entry!.br?.headers["Content-Length"]).toBe(String(brContent.length));
});
it("detects gzip precompressed variant", async () => {
const content = "body { margin: 0; }\n".repeat(200);
await writeFile(clientDir, "_next/static/styles-def456.css", content);
const gzContent = zlib.gzipSync(Buffer.from(content));
await writeFile(clientDir, "_next/static/styles-def456.css.gz", gzContent);
const cache = await StaticFileCache.create(clientDir);
const entry = cache.lookup("/_next/static/styles-def456.css");
expect(entry!.gz?.path).toBe(path.join(clientDir, "_next/static/styles-def456.css.gz"));
expect(entry!.gz?.headers["Content-Length"]).toBe(String(gzContent.length));
});
it("detects zstandard precompressed variant", async () => {
const content = "const zstd = true;\n".repeat(200);
await writeFile(clientDir, "_next/static/app-zstd.js", content);
const zstdContent = zlib.zstdCompressSync(Buffer.from(content));
await writeFile(clientDir, "_next/static/app-zstd.js.zst", zstdContent);
const cache = await StaticFileCache.create(clientDir);
const entry = cache.lookup("/_next/static/app-zstd.js");
expect(entry!.zst?.path).toBe(path.join(clientDir, "_next/static/app-zstd.js.zst"));
expect(entry!.zst?.headers["Content-Length"]).toBe(String(zstdContent.length));
});
it("sets Vary: Accept-Encoding on original variant when compressed siblings exist", async () => {
const content = "const x = 1;\n".repeat(200);
await writeFile(clientDir, "_next/static/app-abc123.js", content);
await writeFile(
clientDir,
"_next/static/app-abc123.js.br",
zlib.brotliCompressSync(Buffer.from(content)),
);
const cache = await StaticFileCache.create(clientDir);
const entry = cache.lookup("/_next/static/app-abc123.js");
expect(entry!.original.headers["Vary"]).toBe("Accept-Encoding");
});
it("omits Vary on original variant when no compressed siblings exist", async () => {
await writeFile(clientDir, "favicon.ico", "icon-data");
const cache = await StaticFileCache.create(clientDir);
const entry = cache.lookup("/favicon.ico");
expect(entry!.original.headers["Vary"]).toBeUndefined();
});
it("does not expose .br/.gz/.zst files as standalone entries", async () => {
const content = "const x = 1;\n".repeat(200);
await writeFile(clientDir, "_next/static/app-abc123.js", content);
await writeFile(
clientDir,
"_next/static/app-abc123.js.br",
zlib.brotliCompressSync(Buffer.from(content)),
);
await writeFile(
clientDir,
"_next/static/app-abc123.js.gz",
zlib.gzipSync(Buffer.from(content)),
);
await writeFile(
clientDir,
"_next/static/app-abc123.js.zst",
zlib.zstdCompressSync(Buffer.from(content)),
);
const cache = await StaticFileCache.create(clientDir);
// .br, .gz, .zst should not be independently servable
expect(cache.lookup("/_next/static/app-abc123.js.br")).toBeUndefined();
expect(cache.lookup("/_next/static/app-abc123.js.gz")).toBeUndefined();
expect(cache.lookup("/_next/static/app-abc123.js.zst")).toBeUndefined();
});
// ── HTML fallbacks ─────────────────────────────────────────────
it("resolves .html extension fallback for prerendered pages", async () => {
await writeFile(clientDir, "about.html", "<html>About</html>");
const cache = await StaticFileCache.create(clientDir);
const entry = cache.lookup("/about");
expect(entry).toBeDefined();
expect(entry!.original.path).toBe(path.join(clientDir, "about.html"));
expect(entry!.original.headers["Content-Type"]).toBe("text/html; charset=utf-8");
});
it("resolves /index.html fallback for directory paths", async () => {
await writeFile(clientDir, "blog/index.html", "<html>Blog</html>");
const cache = await StaticFileCache.create(clientDir);
const entry = cache.lookup("/blog");
expect(entry).toBeDefined();
expect(entry!.original.path).toBe(path.join(clientDir, "blog/index.html"));
});
// ── Directory traversal protection ─────────────────────────────
it("blocks .vite/ internal directory access", async () => {
await writeFile(clientDir, ".vite/manifest.json", "{}");
const cache = await StaticFileCache.create(clientDir);
expect(cache.lookup("/.vite/manifest.json")).toBeUndefined();
});
it("skips root / path", async () => {
await writeFile(clientDir, "index.html", "<html>Root</html>");
const cache = await StaticFileCache.create(clientDir);
// Root index.html is served by SSR/RSC, not static serving
expect(cache.lookup("/")).toBeUndefined();
});
// ── Content type detection ─────────────────────────────────────
it("detects content types from file extensions", async () => {
await writeFile(clientDir, "_next/static/style-aaa.css", "body{}");
await writeFile(clientDir, "_next/static/data-bbb.json", "{}");
await writeFile(clientDir, "logo.svg", "<svg/>");
await writeFile(clientDir, "photo.webp", "webp-data");
const cache = await StaticFileCache.create(clientDir);
expect(cache.lookup("/_next/static/style-aaa.css")!.original.headers["Content-Type"]).toBe(
"text/css",
);
expect(cache.lookup("/_next/static/data-bbb.json")!.original.headers["Content-Type"]).toBe(
"application/json",
);
expect(cache.lookup("/logo.svg")!.original.headers["Content-Type"]).toBe("image/svg+xml");
expect(cache.lookup("/photo.webp")!.original.headers["Content-Type"]).toBe("image/webp");
});
it("falls back to application/octet-stream for unknown extensions", async () => {
await writeFile(clientDir, "_next/static/data-ccc.xyz", "unknown-data");
const cache = await StaticFileCache.create(clientDir);
expect(cache.lookup("/_next/static/data-ccc.xyz")!.original.headers["Content-Type"]).toBe(
"application/octet-stream",
);
});
// ── Nested directory scanning ──────────────────────────────────
it("scans nested directories recursively", async () => {
await writeFile(clientDir, "_next/static/chunks/vendor-aaa.js", "vendor code");
await writeFile(clientDir, "_next/static/chunks/lazy/page-bbb.js", "page code");
const cache = await StaticFileCache.create(clientDir);
expect(cache.lookup("/_next/static/chunks/vendor-aaa.js")).toBeDefined();
expect(cache.lookup("/_next/static/chunks/lazy/page-bbb.js")).toBeDefined();
});
});