From d2e8e5703738c8798bf47cc362263c8224394868 Mon Sep 17 00:00:00 2001 From: Boy Steven Benaya Aritonang Date: Sun, 26 Jul 2026 23:03:02 +0700 Subject: [PATCH 01/23] fix(server): use weak comparison for If-None-Match --- .../vinext/src/server/http-conditional.ts | 19 ++++++++++ packages/vinext/src/server/prod-server.ts | 16 +++------ tests/http-conditional.test.ts | 35 +++++++++++++++++++ 3 files changed, 58 insertions(+), 12 deletions(-) create mode 100644 packages/vinext/src/server/http-conditional.ts create mode 100644 tests/http-conditional.test.ts diff --git a/packages/vinext/src/server/http-conditional.ts b/packages/vinext/src/server/http-conditional.ts new file mode 100644 index 0000000000..d532117453 --- /dev/null +++ b/packages/vinext/src/server/http-conditional.ts @@ -0,0 +1,19 @@ +/** + * Test an If-None-Match field value against a current entity tag. + * + * RFC 9110 requires weak comparison for If-None-Match: a weak and strong + * validator with the same opaque tag compare equal. + */ +export function matchesIfNoneMatch(ifNoneMatch: string | undefined, etag: string): boolean { + if (!ifNoneMatch) return false; + + const normalizedEtag = stripWeakPrefix(etag); + return ifNoneMatch.split(",").some((candidate) => { + const trimmed = candidate.trim(); + return trimmed === "*" || stripWeakPrefix(trimmed) === normalizedEtag; + }); +} + +function stripWeakPrefix(value: string): string { + return value.startsWith("W/") ? value.slice(2) : value; +} diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 44ceda6060..3730f53600 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -92,6 +92,7 @@ import { parseAcceptedEncodings, selectContentEncoding, } from "./accept-encoding.js"; +import { matchesIfNoneMatch } from "./http-conditional.js"; import type { NextI18nConfig } from "../config/next-config.js"; import { readTrustedRevalidationHostname } from "./revalidation-host.js"; @@ -410,15 +411,6 @@ function mergeVaryHeader( return merged; } -function matchesIfNoneMatchHeader(ifNoneMatch: string | undefined, etag: string): boolean { - if (!ifNoneMatch) return false; - if (ifNoneMatch === "*") return true; - return ifNoneMatch - .split(",") - .map((value) => value.trim()) - .some((value) => value === etag); -} - function installClientBuildManifestGlobals( clientDir: string, assetBase: string, @@ -633,7 +625,7 @@ async function tryServeStatic( if ( responseStatus === 200 && typeof ifNoneMatch === "string" && - matchesIfNoneMatchHeader(ifNoneMatch, entry.etag) + matchesIfNoneMatch(ifNoneMatch, entry.etag) ) { const notModifiedHeaders = variesByEncoding ? mergeVaryHeader({ ...entry.notModifiedHeaders, ...extraHeaders }, "Accept-Encoding") @@ -721,7 +713,7 @@ async function tryServeStatic( if ( responseStatus === 200 && typeof ifNoneMatch === "string" && - matchesIfNoneMatchHeader(ifNoneMatch, etag) + matchesIfNoneMatch(ifNoneMatch, etag) ) { const notModifiedHeaders = mergeVaryHeader(baseHeaders, "Accept-Encoding"); if (encoding !== "identity") notModifiedHeaders["Content-Encoding"] = encoding; @@ -757,7 +749,7 @@ async function tryServeStatic( if ( responseStatus === 200 && typeof ifNoneMatch === "string" && - matchesIfNoneMatchHeader(ifNoneMatch, etag) + matchesIfNoneMatch(ifNoneMatch, etag) ) { res.writeHead( 304, diff --git a/tests/http-conditional.test.ts b/tests/http-conditional.test.ts new file mode 100644 index 0000000000..96a3a142aa --- /dev/null +++ b/tests/http-conditional.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { matchesIfNoneMatch } from "../packages/vinext/src/server/http-conditional.js"; + +describe("matchesIfNoneMatch", () => { + it("matches an identical entity tag", () => { + expect(matchesIfNoneMatch('"asset"', '"asset"')).toBe(true); + }); + + it("uses weak comparison in either direction", () => { + expect(matchesIfNoneMatch('"asset"', 'W/"asset"')).toBe(true); + expect(matchesIfNoneMatch('W/"asset"', '"asset"')).toBe(true); + }); + + it("matches a validator within a comma-separated field value", () => { + expect(matchesIfNoneMatch('"other", W/"asset", "last"', '"asset"')).toBe(true); + }); + + it("matches a wildcard with optional whitespace", () => { + expect(matchesIfNoneMatch(" * ", 'W/"asset"')).toBe(true); + }); + + it("does not match different opaque tags or case", () => { + expect(matchesIfNoneMatch('"other"', '"asset"')).toBe(false); + expect(matchesIfNoneMatch('"ASSET"', '"asset"')).toBe(false); + }); + + it("does not treat a lowercase weak prefix as W/", () => { + expect(matchesIfNoneMatch('w/"asset"', '"asset"')).toBe(false); + }); + + it("rejects an absent or empty field value", () => { + expect(matchesIfNoneMatch(undefined, '"asset"')).toBe(false); + expect(matchesIfNoneMatch("", '"asset"')).toBe(false); + }); +}); From 932bfc805870d59dedb6a0c8412a67a7c2dcfb0a Mon Sep 17 00:00:00 2001 From: Boy Steven Benaya Aritonang Date: Sun, 26 Jul 2026 23:44:10 +0700 Subject: [PATCH 02/23] chore: retrigger CI From 9ce704a7c88689e5b90b04a5b2b1c5d95b31eab1 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 11:39:27 +0100 Subject: [PATCH 03/23] fix(server): validate conditional static requests --- .../vinext/src/server/http-conditional.ts | 65 +++++++++++++++++-- packages/vinext/src/server/prod-server.ts | 9 +++ tests/http-conditional.test.ts | 32 ++++++++- tests/serve-static.test.ts | 48 +++++++++++++- 4 files changed, 144 insertions(+), 10 deletions(-) diff --git a/packages/vinext/src/server/http-conditional.ts b/packages/vinext/src/server/http-conditional.ts index d532117453..63102e7199 100644 --- a/packages/vinext/src/server/http-conditional.ts +++ b/packages/vinext/src/server/http-conditional.ts @@ -7,13 +7,64 @@ export function matchesIfNoneMatch(ifNoneMatch: string | undefined, etag: string): boolean { if (!ifNoneMatch) return false; - const normalizedEtag = stripWeakPrefix(etag); - return ifNoneMatch.split(",").some((candidate) => { - const trimmed = candidate.trim(); - return trimmed === "*" || stripWeakPrefix(trimmed) === normalizedEtag; - }); + const current = parseEntityTag(etag, 0); + if (!current || current.end !== etag.length) return false; + const currentOpaqueTag = etag.slice(current.opaqueStart, current.opaqueEnd); + + let index = skipOptionalWhitespace(ifNoneMatch, 0); + if (ifNoneMatch[index] === "*") { + return skipOptionalWhitespace(ifNoneMatch, index + 1) === ifNoneMatch.length; + } + + let matched = false; + let sawTag = false; + while (index < ifNoneMatch.length) { + // RFC 9110's list extension allows recipients to ignore empty members. + if (ifNoneMatch[index] === ",") { + index = skipOptionalWhitespace(ifNoneMatch, index + 1); + continue; + } + + const candidate = parseEntityTag(ifNoneMatch, index); + if (!candidate) return false; + sawTag = true; + matched ||= ifNoneMatch.slice(candidate.opaqueStart, candidate.opaqueEnd) === currentOpaqueTag; + + index = skipOptionalWhitespace(ifNoneMatch, candidate.end); + if (index === ifNoneMatch.length) break; + if (ifNoneMatch[index] !== ",") return false; + index = skipOptionalWhitespace(ifNoneMatch, index + 1); + } + + return sawTag && matched; +} + +type ParsedEntityTag = { + opaqueStart: number; + opaqueEnd: number; + end: number; +}; + +function parseEntityTag(value: string, start: number): ParsedEntityTag | null { + let index = start; + if (value.startsWith("W/", index)) index += 2; + if (value[index] !== '"') return null; + + const opaqueStart = ++index; + while (index < value.length && value[index] !== '"') { + const code = value.charCodeAt(index); + // etagc = %x21 / %x23-7E / obs-text. A backslash is an ordinary + // character here; entity tags do not use quoted-string escaping. + if (code !== 0x21 && (code < 0x23 || code > 0xff)) return null; + index++; + } + if (value[index] !== '"') return null; + + return { opaqueStart, opaqueEnd: index, end: index + 1 }; } -function stripWeakPrefix(value: string): string { - return value.startsWith("W/") ? value.slice(2) : value; +function skipOptionalWhitespace(value: string, start: number): number { + let index = start; + while (value[index] === " " || value[index] === "\t") index++; + return index; } diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 3730f53600..bb6b424cc8 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -591,6 +591,7 @@ async function tryServeStatic( const entry = cache.lookup(lookupPath); if (!entry) return false; + if (responseStatus === 200 && rejectUnsupportedStaticMethod(req, res)) return true; // Pick the best precompressed variant: zstd → br → gzip → original. // Each variant has pre-computed headers — zero string building. @@ -680,6 +681,7 @@ async function tryServeStatic( const resolved = await resolveStaticFile(staticFile); if (!resolved) return false; + if (responseStatus === 200 && rejectUnsupportedStaticMethod(req, res)) return true; const ext = path.extname(resolved.path); const ct = CONTENT_TYPES[ext] ?? "application/octet-stream"; @@ -782,6 +784,13 @@ async function tryServeStatic( return true; } +function rejectUnsupportedStaticMethod(req: IncomingMessage, res: ServerResponse): boolean { + if (req.method === "GET" || req.method === "HEAD") return false; + res.writeHead(405, { Allow: "GET, HEAD" }); + res.end(); + return true; +} + type ResolvedFile = { path: string; size: number; diff --git a/tests/http-conditional.test.ts b/tests/http-conditional.test.ts index 96a3a142aa..0aa2f5ff18 100644 --- a/tests/http-conditional.test.ts +++ b/tests/http-conditional.test.ts @@ -15,8 +15,22 @@ describe("matchesIfNoneMatch", () => { expect(matchesIfNoneMatch('"other", W/"asset", "last"', '"asset"')).toBe(true); }); + it("does not split commas inside an opaque tag", () => { + expect(matchesIfNoneMatch('"other", W/"asset,part"', '"asset,part"')).toBe(true); + expect(matchesIfNoneMatch('"asset,part"', '"asset"')).toBe(false); + }); + + it("treats backslashes as opaque characters rather than escapes", () => { + expect(matchesIfNoneMatch('W/"asset\\part"', '"asset\\part"')).toBe(true); + }); + it("matches a wildcard with optional whitespace", () => { - expect(matchesIfNoneMatch(" * ", 'W/"asset"')).toBe(true); + expect(matchesIfNoneMatch(" \t * \t ", 'W/"asset"')).toBe(true); + }); + + it("rejects a wildcard mixed into an entity-tag list", () => { + expect(matchesIfNoneMatch('*, "asset"', '"asset"')).toBe(false); + expect(matchesIfNoneMatch('"other", *', '"asset"')).toBe(false); }); it("does not match different opaque tags or case", () => { @@ -28,6 +42,22 @@ describe("matchesIfNoneMatch", () => { expect(matchesIfNoneMatch('w/"asset"', '"asset"')).toBe(false); }); + it("rejects malformed entity tags", () => { + expect(matchesIfNoneMatch('asset, "match"', '"match"')).toBe(false); + expect(matchesIfNoneMatch('"unterminated', '"unterminated"')).toBe(false); + expect(matchesIfNoneMatch('W/ "asset"', '"asset"')).toBe(false); + expect(matchesIfNoneMatch('"asset" suffix', '"asset"')).toBe(false); + }); + + it("ignores empty list members", () => { + expect(matchesIfNoneMatch(' , W/"asset", ', '"asset"')).toBe(true); + }); + + it("rejects an invalid current entity tag", () => { + expect(matchesIfNoneMatch('"asset"', "asset")).toBe(false); + expect(matchesIfNoneMatch('"asset"', 'W/ "asset"')).toBe(false); + }); + it("rejects an absent or empty field value", () => { expect(matchesIfNoneMatch(undefined, '"asset"')).toBe(false); expect(matchesIfNoneMatch("", '"asset"')).toBe(false); diff --git a/tests/serve-static.test.ts b/tests/serve-static.test.ts index f38b75850a..9607803dba 100644 --- a/tests/serve-static.test.ts +++ b/tests/serve-static.test.ts @@ -337,14 +337,15 @@ describe("tryServeStatic (with StaticFileCache)", () => { // ── 304 Not Modified (conditional requests) ──────────────────── - it("returns 304 when If-None-Match matches the ETag", async () => { + it("returns 304 when a strong If-None-Match matches a weak ETag", async () => { await writeFile(clientDir, "_next/static/cached-aaa111.js", "cached content"); const cache = await StaticFileCache.create(clientDir); const entry = cache.lookup("/_next/static/cached-aaa111.js"); const etag = entry!.etag; + expect(etag).toMatch(/^W\//); - const req = mockReq(undefined, { "if-none-match": etag }); + const req = mockReq(undefined, { "if-none-match": etag.slice(2) }); const { res, captured } = mockRes(); const served = await tryServeStatic( @@ -424,6 +425,22 @@ describe("tryServeStatic (with StaticFileCache)", () => { expect(captured.status).toBe(200); }); + it("rejects conditional requests with unsupported methods", async () => { + await writeFile(clientDir, "_next/static/method-aaa111.js", "cached content"); + + const cache = await StaticFileCache.create(clientDir); + const entry = cache.lookup("/_next/static/method-aaa111.js"); + const req = mockReq(undefined, { "if-none-match": entry!.etag.slice(2) }, "POST"); + const { res, captured } = mockRes(); + + await tryServeStatic(req, res, clientDir, "/_next/static/method-aaa111.js", true, cache); + + await captured.ended; + expect(captured.status).toBe(405); + expect(captured.headers.Allow).toBe("GET, HEAD"); + expect(captured.body.length).toBe(0); + }); + it("304 response excludes Content-Type per RFC 9110", async () => { await writeFile(clientDir, "_next/static/rfc-aaa111.js", "rfc content"); @@ -753,6 +770,33 @@ describe("tryServeStatic (with StaticFileCache)", () => { expect(served).toBe(false); }); + it("slow path rejects unsupported methods only after finding the file", async () => { + await writeFile(clientDir, "_next/static/method-slow-aaa111.js", "slow path content"); + + const req = mockReq(undefined, { "if-none-match": '"aaa111"' }, "PUT"); + const { res, captured } = mockRes(); + + const served = await tryServeStatic( + req, + res, + clientDir, + "/_next/static/method-slow-aaa111.js", + false, + ); + + await captured.ended; + expect(served).toBe(true); + expect(captured.status).toBe(405); + expect(captured.headers.Allow).toBe("GET, HEAD"); + expect(captured.body.length).toBe(0); + + const missingReq = mockReq(undefined, undefined, "PUT"); + const { res: missingRes } = mockRes(); + expect(await tryServeStatic(missingReq, missingRes, clientDir, "/missing.js", false)).toBe( + false, + ); + }); + it("slow path serves HEAD without body", async () => { await writeFile(clientDir, "_next/static/head-slow-bbb222.js", "head content"); From f11c2f936281ebcbf02352c4234f50de44189224 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 11:43:31 +0100 Subject: [PATCH 04/23] fix(server): tighten entity-tag parsing --- .../vinext/src/server/http-conditional.ts | 4 +- packages/vinext/src/server/prod-server.ts | 9 ---- tests/http-conditional.test.ts | 1 + tests/serve-static.test.ts | 43 ------------------- 4 files changed, 4 insertions(+), 53 deletions(-) diff --git a/packages/vinext/src/server/http-conditional.ts b/packages/vinext/src/server/http-conditional.ts index 63102e7199..97fa010acf 100644 --- a/packages/vinext/src/server/http-conditional.ts +++ b/packages/vinext/src/server/http-conditional.ts @@ -55,7 +55,9 @@ function parseEntityTag(value: string, start: number): ParsedEntityTag | null { const code = value.charCodeAt(index); // etagc = %x21 / %x23-7E / obs-text. A backslash is an ordinary // character here; entity tags do not use quoted-string escaping. - if (code !== 0x21 && (code < 0x23 || code > 0xff)) return null; + const isEtagCharacter = + code === 0x21 || (code >= 0x23 && code <= 0x7e) || (code >= 0x80 && code <= 0xff); + if (!isEtagCharacter) return null; index++; } if (value[index] !== '"') return null; diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index bb6b424cc8..3730f53600 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -591,7 +591,6 @@ async function tryServeStatic( const entry = cache.lookup(lookupPath); if (!entry) return false; - if (responseStatus === 200 && rejectUnsupportedStaticMethod(req, res)) return true; // Pick the best precompressed variant: zstd → br → gzip → original. // Each variant has pre-computed headers — zero string building. @@ -681,7 +680,6 @@ async function tryServeStatic( const resolved = await resolveStaticFile(staticFile); if (!resolved) return false; - if (responseStatus === 200 && rejectUnsupportedStaticMethod(req, res)) return true; const ext = path.extname(resolved.path); const ct = CONTENT_TYPES[ext] ?? "application/octet-stream"; @@ -784,13 +782,6 @@ async function tryServeStatic( return true; } -function rejectUnsupportedStaticMethod(req: IncomingMessage, res: ServerResponse): boolean { - if (req.method === "GET" || req.method === "HEAD") return false; - res.writeHead(405, { Allow: "GET, HEAD" }); - res.end(); - return true; -} - type ResolvedFile = { path: string; size: number; diff --git a/tests/http-conditional.test.ts b/tests/http-conditional.test.ts index 0aa2f5ff18..26f6d9720d 100644 --- a/tests/http-conditional.test.ts +++ b/tests/http-conditional.test.ts @@ -47,6 +47,7 @@ describe("matchesIfNoneMatch", () => { expect(matchesIfNoneMatch('"unterminated', '"unterminated"')).toBe(false); expect(matchesIfNoneMatch('W/ "asset"', '"asset"')).toBe(false); expect(matchesIfNoneMatch('"asset" suffix', '"asset"')).toBe(false); + expect(matchesIfNoneMatch('"asset\x7fpart"', '"asset\x7fpart"')).toBe(false); }); it("ignores empty list members", () => { diff --git a/tests/serve-static.test.ts b/tests/serve-static.test.ts index 9607803dba..ace762c3f7 100644 --- a/tests/serve-static.test.ts +++ b/tests/serve-static.test.ts @@ -425,22 +425,6 @@ describe("tryServeStatic (with StaticFileCache)", () => { expect(captured.status).toBe(200); }); - it("rejects conditional requests with unsupported methods", async () => { - await writeFile(clientDir, "_next/static/method-aaa111.js", "cached content"); - - const cache = await StaticFileCache.create(clientDir); - const entry = cache.lookup("/_next/static/method-aaa111.js"); - const req = mockReq(undefined, { "if-none-match": entry!.etag.slice(2) }, "POST"); - const { res, captured } = mockRes(); - - await tryServeStatic(req, res, clientDir, "/_next/static/method-aaa111.js", true, cache); - - await captured.ended; - expect(captured.status).toBe(405); - expect(captured.headers.Allow).toBe("GET, HEAD"); - expect(captured.body.length).toBe(0); - }); - it("304 response excludes Content-Type per RFC 9110", async () => { await writeFile(clientDir, "_next/static/rfc-aaa111.js", "rfc content"); @@ -770,33 +754,6 @@ describe("tryServeStatic (with StaticFileCache)", () => { expect(served).toBe(false); }); - it("slow path rejects unsupported methods only after finding the file", async () => { - await writeFile(clientDir, "_next/static/method-slow-aaa111.js", "slow path content"); - - const req = mockReq(undefined, { "if-none-match": '"aaa111"' }, "PUT"); - const { res, captured } = mockRes(); - - const served = await tryServeStatic( - req, - res, - clientDir, - "/_next/static/method-slow-aaa111.js", - false, - ); - - await captured.ended; - expect(served).toBe(true); - expect(captured.status).toBe(405); - expect(captured.headers.Allow).toBe("GET, HEAD"); - expect(captured.body.length).toBe(0); - - const missingReq = mockReq(undefined, undefined, "PUT"); - const { res: missingRes } = mockRes(); - expect(await tryServeStatic(missingReq, missingRes, clientDir, "/missing.js", false)).toBe( - false, - ); - }); - it("slow path serves HEAD without body", async () => { await writeFile(clientDir, "_next/static/head-slow-bbb222.js", "head content"); From 1c0b44e89f2eeccd2ec8b8a0b61d2984df2552fb Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 11:43:57 +0100 Subject: [PATCH 05/23] test(server): document entity-tag comma behavior --- tests/http-conditional.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/http-conditional.test.ts b/tests/http-conditional.test.ts index 26f6d9720d..fe2cc9c90d 100644 --- a/tests/http-conditional.test.ts +++ b/tests/http-conditional.test.ts @@ -16,6 +16,8 @@ describe("matchesIfNoneMatch", () => { }); it("does not split commas inside an opaque tag", () => { + // RFC 9110 permits commas in opaque tags. This is intentionally stricter + // than Next.js 16.2.7's compiled `fresh` parser, which splits every comma. expect(matchesIfNoneMatch('"other", W/"asset,part"', '"asset,part"')).toBe(true); expect(matchesIfNoneMatch('"asset,part"', '"asset"')).toBe(false); }); From 1e2d403bf6a60f98ae62934ee2948c91110158da Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 11:53:26 +0100 Subject: [PATCH 06/23] fix(dev): weak-match public asset etags --- packages/vinext/src/index.ts | 32 +++++++- packages/vinext/src/server/dev-public-etag.ts | 72 ++++++++++++++++++ tests/dev-public-etag.test.ts | 75 +++++++++++++++++++ tests/e2e/pages-router/script.spec.ts | 23 ++++++ 4 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 packages/vinext/src/server/dev-public-etag.ts create mode 100644 tests/dev-public-etag.test.ts diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 473e4611d2..fb2696b21d 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -242,6 +242,11 @@ import { createWasmModuleImportPlugin } from "./plugins/wasm-module-import.js"; import { getTypeofWindowReplacement, replaceTypeofWindow } from "./plugins/typeof-window.js"; import { hasMdxFiles } from "./utils/mdx-scan.js"; import { scanPublicFileRoutes } from "./utils/public-routes.js"; +import { + createDevPublicFileEtags, + resolveDevPublicIfNoneMatch, + updateDevPublicFileEtag, +} from "./server/dev-public-etag.js"; import type { Options as VitePluginReactOptions } from "@vitejs/plugin-react"; import MagicString from "magic-string"; import path, { toSlash } from "pathslash"; @@ -4257,11 +4262,36 @@ export const loadServerActionClient = ${ }, configureServer(server: ViteDevServer) { - server.middlewares.use((req, _res, next) => { + const devPublicFileEtags = createDevPublicFileEtags(root); + + server.middlewares.use((req, res, next) => { req.__vinextOriginalEncodedUrl ??= req.url; + const normalizedIfNoneMatch = resolveDevPublicIfNoneMatch( + req.method, + req.url, + req.headers["if-none-match"], + devPublicFileEtags, + nextConfig?.basePath, + ); + if (normalizedIfNoneMatch) { + // sirv compares If-None-Match to its generated weak ETag as an + // exact string. Resolve RFC weak/list/wildcard matching here, then + // give sirv the exact value so its normal 304 path handles the body. + req.headers["if-none-match"] = normalizedIfNoneMatch; + res.setHeader("ETag", normalizedIfNoneMatch); + } next(); }); + const updateDevPublicEtag = (filePath: string) => { + updateDevPublicFileEtag(devPublicFileEtags, root, filePath); + }; + server.watcher.on("add", updateDevPublicEtag); + server.watcher.on("change", updateDevPublicEtag); + server.watcher.on("unlink", (filePath: string) => { + updateDevPublicFileEtag(devPublicFileEtags, root, filePath, true); + }); + // Watch route files for additions/removals to invalidate route cache. const pageExtensions = fileMatcher.extensionRegex; diff --git a/packages/vinext/src/server/dev-public-etag.ts b/packages/vinext/src/server/dev-public-etag.ts new file mode 100644 index 0000000000..7469675512 --- /dev/null +++ b/packages/vinext/src/server/dev-public-etag.ts @@ -0,0 +1,72 @@ +import fs from "node:fs"; +import path, { toSlash } from "pathslash"; +import { hasBasePath, stripBasePath } from "../utils/base-path.js"; +import { scanPublicFileRoutes } from "../utils/public-routes.js"; +import { matchesIfNoneMatch } from "./http-conditional.js"; + +export function createDevPublicFileEtags(root: string): Map { + const etags = new Map(); + for (const route of scanPublicFileRoutes(root)) { + updateDevPublicFileEtag(etags, root, path.join(root, "public", route.slice(1))); + } + return etags; +} + +export function updateDevPublicFileEtag( + etags: Map, + root: string, + externalFilePath: string, + removed = false, +): void { + const publicDir = path.join(root, "public"); + const relativePath = path.relative(publicDir, toSlash(externalFilePath)); + if (relativePath === "" || relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + return; + } + + const route = "/" + relativePath; + if (removed) { + etags.delete(route); + return; + } + + try { + const stats = fs.statSync(path.join(publicDir, relativePath)); + if (stats.isFile()) { + // Vite's public-file middleware delegates to sirv, which uses this + // size/mtime weak validator in development. + etags.set(route, `W/"${stats.size}-${stats.mtime.getTime()}"`); + } else { + etags.delete(route); + } + } catch { + etags.delete(route); + } +} + +export function resolveDevPublicIfNoneMatch( + method: string | undefined, + requestUrl: string | undefined, + ifNoneMatch: string | string[] | undefined, + etags: ReadonlyMap, + basePath = "", +): string | undefined { + if ((method !== "GET" && method !== "HEAD") || typeof ifNoneMatch !== "string") { + return undefined; + } + + const queryIndex = requestUrl?.indexOf("?") ?? -1; + let pathname = (requestUrl ?? "/").slice(0, queryIndex === -1 ? undefined : queryIndex); + if (basePath) { + if (!hasBasePath(pathname, basePath)) return undefined; + pathname = stripBasePath(pathname, basePath); + } + try { + pathname = decodeURI(pathname); + } catch { + return undefined; + } + + const etag = etags.get(pathname); + return etag && matchesIfNoneMatch(ifNoneMatch, etag) ? etag : undefined; +} diff --git a/tests/dev-public-etag.test.ts b/tests/dev-public-etag.test.ts new file mode 100644 index 0000000000..29a66b090d --- /dev/null +++ b/tests/dev-public-etag.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + createDevPublicFileEtags, + resolveDevPublicIfNoneMatch, + updateDevPublicFileEtag, +} from "../packages/vinext/src/server/dev-public-etag.js"; + +const ETAG = 'W/"90-1234"'; +const publicEtags = new Map([["/asset.js", ETAG]]); + +describe("resolveDevPublicIfNoneMatch", () => { + it("normalizes strong and weak validators to sirv's weak ETag", () => { + expect(resolveDevPublicIfNoneMatch("GET", "/asset.js", '"90-1234"', publicEtags)).toBe(ETAG); + expect(resolveDevPublicIfNoneMatch("GET", "/asset.js", ETAG, publicEtags)).toBe(ETAG); + }); + + it("handles lists and wildcard validators", () => { + expect( + resolveDevPublicIfNoneMatch("GET", "/asset.js?cache=1", '"other", "90-1234"', publicEtags), + ).toBe(ETAG); + expect(resolveDevPublicIfNoneMatch("GET", "/asset.js", "*", publicEtags)).toBe(ETAG); + }); + + it("leaves malformed and non-matching validators untouched", () => { + expect( + resolveDevPublicIfNoneMatch("GET", "/asset.js", '*, "90-1234"', publicEtags), + ).toBeUndefined(); + expect(resolveDevPublicIfNoneMatch("GET", "/asset.js", '"other"', publicEtags)).toBeUndefined(); + }); + + it("supports HEAD and a configured base path", () => { + expect( + resolveDevPublicIfNoneMatch("HEAD", "/base/asset.js", '"90-1234"', publicEtags, "/base"), + ).toBe(ETAG); + }); + + it("does not affect methods, missing files, or paths outside basePath", () => { + expect( + resolveDevPublicIfNoneMatch("POST", "/asset.js", '"90-1234"', publicEtags), + ).toBeUndefined(); + expect( + resolveDevPublicIfNoneMatch("GET", "/missing.js", '"90-1234"', publicEtags), + ).toBeUndefined(); + expect( + resolveDevPublicIfNoneMatch("GET", "/asset.js", '"90-1234"', publicEtags, "/base"), + ).toBeUndefined(); + }); + + it("tracks the same size/mtime identity that sirv uses", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-")); + const publicDir = path.join(root, "public"); + const filePath = path.join(publicDir, "asset.js"); + try { + fs.mkdirSync(publicDir); + fs.writeFileSync(filePath, "initial"); + + const etags = createDevPublicFileEtags(root); + const initial = etags.get("/asset.js"); + expect(initial).toMatch(/^W\/"7-\d+"$/); + + fs.writeFileSync(filePath, "updated content"); + updateDevPublicFileEtag(etags, root, filePath); + expect(etags.get("/asset.js")).toMatch(/^W\/"15-\d+"$/); + expect(etags.get("/asset.js")).not.toBe(initial); + + updateDevPublicFileEtag(etags, root, filePath, true); + expect(etags.has("/asset.js")).toBe(false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/e2e/pages-router/script.spec.ts b/tests/e2e/pages-router/script.spec.ts index 5168f54d16..c0ef1bcd39 100644 --- a/tests/e2e/pages-router/script.spec.ts +++ b/tests/e2e/pages-router/script.spec.ts @@ -3,6 +3,29 @@ import { test, expect } from "@playwright/test"; const BASE = "http://localhost:4173"; test.describe("next/script", () => { + test("uses weak comparison for public-file If-None-Match requests", async ({ request }) => { + const initial = await request.get(`${BASE}/dedupe-script.js`); + const etag = initial.headers().etag; + expect(etag).toMatch(/^W\//); + + const strong = etag.slice(2); + const strongResponse = await request.get(`${BASE}/dedupe-script.js`, { + headers: { "If-None-Match": strong }, + }); + expect(strongResponse.status()).toBe(304); + expect(strongResponse.headers().etag).toBe(etag); + + const listResponse = await request.get(`${BASE}/dedupe-script.js`, { + headers: { "If-None-Match": `"other", ${strong}` }, + }); + expect(listResponse.status()).toBe(304); + + const headResponse = await request.head(`${BASE}/dedupe-script.js`, { + headers: { "If-None-Match": strong }, + }); + expect(headResponse.status()).toBe(304); + }); + // Ported from Next.js: packages/next/src/client/script.tsx // https://github.com/vercel/next.js/blob/canary/packages/next/src/client/script.tsx // Next.js keeps a ScriptCache for in-flight remote scripts so same-src From 233099b2a0ab7f37207561090f2183f167f24af7 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 11:59:02 +0100 Subject: [PATCH 07/23] fix(dev): normalize public etag lookup paths --- packages/vinext/src/server/dev-public-etag.ts | 3 ++- tests/dev-public-etag.test.ts | 9 +++++++++ tests/e2e/pages-router/script.spec.ts | 15 +++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/server/dev-public-etag.ts b/packages/vinext/src/server/dev-public-etag.ts index 7469675512..6e083fd89f 100644 --- a/packages/vinext/src/server/dev-public-etag.ts +++ b/packages/vinext/src/server/dev-public-etag.ts @@ -3,6 +3,7 @@ import path, { toSlash } from "pathslash"; import { hasBasePath, stripBasePath } from "../utils/base-path.js"; import { scanPublicFileRoutes } from "../utils/public-routes.js"; import { matchesIfNoneMatch } from "./http-conditional.js"; +import { normalizePath } from "./normalize-path.js"; export function createDevPublicFileEtags(root: string): Map { const etags = new Map(); @@ -62,7 +63,7 @@ export function resolveDevPublicIfNoneMatch( pathname = stripBasePath(pathname, basePath); } try { - pathname = decodeURI(pathname); + pathname = normalizePath(decodeURI(pathname)); } catch { return undefined; } diff --git a/tests/dev-public-etag.test.ts b/tests/dev-public-etag.test.ts index 29a66b090d..ee61188ad1 100644 --- a/tests/dev-public-etag.test.ts +++ b/tests/dev-public-etag.test.ts @@ -35,6 +35,15 @@ describe("resolveDevPublicIfNoneMatch", () => { expect( resolveDevPublicIfNoneMatch("HEAD", "/base/asset.js", '"90-1234"', publicEtags, "/base"), ).toBe(ETAG); + expect( + resolveDevPublicIfNoneMatch( + "GET", + "/base/a/%2e%2e//asset.js", + '"90-1234"', + publicEtags, + "/base", + ), + ).toBe(ETAG); }); it("does not affect methods, missing files, or paths outside basePath", () => { diff --git a/tests/e2e/pages-router/script.spec.ts b/tests/e2e/pages-router/script.spec.ts index c0ef1bcd39..806ab1b6b2 100644 --- a/tests/e2e/pages-router/script.spec.ts +++ b/tests/e2e/pages-router/script.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "@playwright/test"; +import { request as httpRequest } from "node:http"; const BASE = "http://localhost:4173"; @@ -24,6 +25,20 @@ test.describe("next/script", () => { headers: { "If-None-Match": strong }, }); expect(headResponse.status()).toBe(304); + + const normalizedPathStatus = await new Promise((resolve, reject) => { + const req = httpRequest( + `${BASE}/nested/%2e%2e//dedupe-script.js`, + { headers: { "If-None-Match": strong } }, + (response) => { + response.resume(); + response.on("end", () => resolve(response.statusCode)); + }, + ); + req.on("error", reject); + req.end(); + }); + expect(normalizedPathStatus).toBe(304); }); // Ported from Next.js: packages/next/src/client/script.tsx From 4c958134889d59bfc270d17b2f8ab381f7dd0b1a Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 12:10:09 +0100 Subject: [PATCH 08/23] fix(dev): canonicalize conditional public assets --- packages/vinext/src/index.ts | 15 +- packages/vinext/src/server/dev-public-etag.ts | 174 ++++++++++++++---- tests/dev-public-etag.test.ts | 93 +++++++++- tests/e2e/pages-router/script.spec.ts | 1 - 4 files changed, 235 insertions(+), 48 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index fb2696b21d..3573d1568b 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -4262,9 +4262,9 @@ export const loadServerActionClient = ${ }, configureServer(server: ViteDevServer) { - const devPublicFileEtags = createDevPublicFileEtags(root); + let devPublicFileEtags = createDevPublicFileEtags(root); - server.middlewares.use((req, res, next) => { + server.middlewares.use((req, _res, next) => { req.__vinextOriginalEncodedUrl ??= req.url; const normalizedIfNoneMatch = resolveDevPublicIfNoneMatch( req.method, @@ -4278,19 +4278,20 @@ export const loadServerActionClient = ${ // exact string. Resolve RFC weak/list/wildcard matching here, then // give sirv the exact value so its normal 304 path handles the body. req.headers["if-none-match"] = normalizedIfNoneMatch; - res.setHeader("ETag", normalizedIfNoneMatch); } next(); }); const updateDevPublicEtag = (filePath: string) => { - updateDevPublicFileEtag(devPublicFileEtags, root, filePath); + if (!updateDevPublicFileEtag(devPublicFileEtags, filePath)) { + devPublicFileEtags = createDevPublicFileEtags(root); + } }; server.watcher.on("add", updateDevPublicEtag); server.watcher.on("change", updateDevPublicEtag); - server.watcher.on("unlink", (filePath: string) => { - updateDevPublicFileEtag(devPublicFileEtags, root, filePath, true); - }); + server.watcher.on("unlink", updateDevPublicEtag); + server.watcher.on("addDir", updateDevPublicEtag); + server.watcher.on("unlinkDir", updateDevPublicEtag); // Watch route files for additions/removals to invalidate route cache. const pageExtensions = fileMatcher.extensionRegex; diff --git a/packages/vinext/src/server/dev-public-etag.ts b/packages/vinext/src/server/dev-public-etag.ts index 6e083fd89f..981f750914 100644 --- a/packages/vinext/src/server/dev-public-etag.ts +++ b/packages/vinext/src/server/dev-public-etag.ts @@ -1,47 +1,115 @@ import fs from "node:fs"; import path, { toSlash } from "pathslash"; import { hasBasePath, stripBasePath } from "../utils/base-path.js"; -import { scanPublicFileRoutes } from "../utils/public-routes.js"; import { matchesIfNoneMatch } from "./http-conditional.js"; import { normalizePath } from "./normalize-path.js"; -export function createDevPublicFileEtags(root: string): Map { - const etags = new Map(); - for (const route of scanPublicFileRoutes(root)) { - updateDevPublicFileEtag(etags, root, path.join(root, "public", route.slice(1))); +export type DevPublicFileEtagIndex = { + publicDir: string; + etagsByRealPath: Map; + symlinkTargets: Map; +}; + +export function createDevPublicFileEtags(root: string): DevPublicFileEtagIndex { + const publicDir = path.join(root, "public"); + const index: DevPublicFileEtagIndex = { + publicDir, + etagsByRealPath: new Map(), + symlinkTargets: new Map(), + }; + + const walk = (dir: string, realAncestors: ReadonlySet): void => { + let realDir: string; + try { + realDir = toSlash(fs.realpathSync(dir)); + } catch { + return; + } + if (realAncestors.has(realDir)) return; + const nextAncestors = new Set(realAncestors).add(realDir); + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isSymbolicLink()) { + let realTarget: string; + let stats: fs.Stats; + try { + realTarget = toSlash(fs.realpathSync(fullPath)); + stats = fs.statSync(fullPath); + } catch { + continue; + } + index.symlinkTargets.set(fullPath, realTarget); + if (stats.isDirectory()) { + walk(fullPath, nextAncestors); + } else if (stats.isFile()) { + index.etagsByRealPath.set(realTarget, etagForStats(stats)); + } + continue; + } + if (entry.isDirectory()) { + walk(fullPath, nextAncestors); + continue; + } + if (!entry.isFile()) continue; + + try { + const realPath = toSlash(fs.realpathSync(fullPath)); + index.etagsByRealPath.set(realPath, etagForStats(fs.statSync(fullPath))); + } catch { + // Ignore entries removed during the startup scan. + } + } + }; + + if (fs.existsSync(publicDir)) { + const realPublicDir = toSlash(fs.realpathSync(publicDir)); + if (realPublicDir !== publicDir) index.symlinkTargets.set(publicDir, realPublicDir); + walk(publicDir, new Set()); } - return etags; + return index; } +/** + * Update a regular public file without rescanning the directory tree. + * Returns false when a structural change (directory, symlink, or removal) + * requires the caller to rebuild the index off the request path. + */ export function updateDevPublicFileEtag( - etags: Map, - root: string, + index: DevPublicFileEtagIndex, externalFilePath: string, - removed = false, -): void { - const publicDir = path.join(root, "public"); - const relativePath = path.relative(publicDir, toSlash(externalFilePath)); - if (relativePath === "" || relativePath.startsWith("..") || path.isAbsolute(relativePath)) { - return; - } - - const route = "/" + relativePath; - if (removed) { - etags.delete(route); - return; +): boolean { + let filePath = toSlash(externalFilePath); + if (!isWithinPublicDir(index.publicDir, filePath)) { + try { + filePath = toSlash(fs.realpathSync(filePath)); + } catch { + // A removal behind a symlink requires a structural rebuild only when + // the watcher reports it through the public alias, handled above. + } + if ( + ![...index.symlinkTargets.values()].some( + (target) => filePath === target || filePath.startsWith(target + "/"), + ) + ) { + return true; + } } try { - const stats = fs.statSync(path.join(publicDir, relativePath)); - if (stats.isFile()) { - // Vite's public-file middleware delegates to sirv, which uses this - // size/mtime weak validator in development. - etags.set(route, `W/"${stats.size}-${stats.mtime.getTime()}"`); - } else { - etags.delete(route); - } + const lstat = fs.lstatSync(filePath); + if (lstat.isSymbolicLink() || !lstat.isFile()) return false; + const realPath = toSlash(fs.realpathSync(filePath)); + index.etagsByRealPath.set(realPath, etagForStats(fs.statSync(filePath))); + return true; } catch { - etags.delete(route); + return false; } } @@ -49,7 +117,7 @@ export function resolveDevPublicIfNoneMatch( method: string | undefined, requestUrl: string | undefined, ifNoneMatch: string | string[] | undefined, - etags: ReadonlyMap, + index: DevPublicFileEtagIndex, basePath = "", ): string | undefined { if ((method !== "GET" && method !== "HEAD") || typeof ifNoneMatch !== "string") { @@ -63,11 +131,53 @@ export function resolveDevPublicIfNoneMatch( pathname = stripBasePath(pathname, basePath); } try { - pathname = normalizePath(decodeURI(pathname)); + // Vite converts native separators on Windows before applying POSIX path + // normalization. `toSlash` is platform-gated, so a literal backslash + // remains a valid filename character on POSIX. + pathname = normalizePath(toSlash(decodeURI(pathname))); } catch { return undefined; } - const etag = etags.get(pathname); + let filePath = path.join(index.publicDir, pathname); + if (!isWithinPublicDir(index.publicDir, filePath)) return undefined; + filePath = resolveSymlinkTargets(filePath, index.symlinkTargets); + + const etag = index.etagsByRealPath.get(filePath); return etag && matchesIfNoneMatch(ifNoneMatch, etag) ? etag : undefined; } + +function resolveSymlinkTargets(filePath: string, targets: ReadonlyMap): string { + const seen = new Set(); + while (!seen.has(filePath)) { + seen.add(filePath); + let matchedSource: string | undefined; + for (const source of targets.keys()) { + if ( + (filePath === source || filePath.startsWith(source + "/")) && + (!matchedSource || source.length > matchedSource.length) + ) { + matchedSource = source; + } + } + if (!matchedSource) break; + filePath = path.join(targets.get(matchedSource)!, filePath.slice(matchedSource.length)); + } + return filePath; +} + +function isWithinPublicDir(publicDir: string, filePath: string): boolean { + const relativePath = path.relative(publicDir, filePath); + return ( + relativePath !== "" && + relativePath !== ".." && + !relativePath.startsWith("../") && + !path.isAbsolute(relativePath) + ); +} + +function etagForStats(stats: fs.Stats): string { + // Vite's public-file middleware delegates to sirv, which uses this + // size/mtime weak validator in development. + return `W/"${stats.size}-${stats.mtime.getTime()}"`; +} diff --git a/tests/dev-public-etag.test.ts b/tests/dev-public-etag.test.ts index ee61188ad1..c864206329 100644 --- a/tests/dev-public-etag.test.ts +++ b/tests/dev-public-etag.test.ts @@ -2,14 +2,20 @@ import { describe, expect, it } from "vitest"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { toSlash } from "pathslash"; import { createDevPublicFileEtags, + type DevPublicFileEtagIndex, resolveDevPublicIfNoneMatch, updateDevPublicFileEtag, } from "../packages/vinext/src/server/dev-public-etag.js"; const ETAG = 'W/"90-1234"'; -const publicEtags = new Map([["/asset.js", ETAG]]); +const publicEtags: DevPublicFileEtagIndex = { + publicDir: "/public", + etagsByRealPath: new Map([["/public/asset.js", ETAG]]), + symlinkTargets: new Map(), +}; describe("resolveDevPublicIfNoneMatch", () => { it("normalizes strong and weak validators to sirv's weak ETag", () => { @@ -66,19 +72,90 @@ describe("resolveDevPublicIfNoneMatch", () => { fs.mkdirSync(publicDir); fs.writeFileSync(filePath, "initial"); - const etags = createDevPublicFileEtags(root); - const initial = etags.get("/asset.js"); + const index = createDevPublicFileEtags(root); + const realPath = toSlash(fs.realpathSync(filePath)); + const initial = index.etagsByRealPath.get(realPath); expect(initial).toMatch(/^W\/"7-\d+"$/); fs.writeFileSync(filePath, "updated content"); - updateDevPublicFileEtag(etags, root, filePath); - expect(etags.get("/asset.js")).toMatch(/^W\/"15-\d+"$/); - expect(etags.get("/asset.js")).not.toBe(initial); + expect(updateDevPublicFileEtag(index, filePath)).toBe(true); + expect(index.etagsByRealPath.get(realPath)).toMatch(/^W\/"15-\d+"$/); + expect(index.etagsByRealPath.get(realPath)).not.toBe(initial); - updateDevPublicFileEtag(etags, root, filePath, true); - expect(etags.has("/asset.js")).toBe(false); + fs.rmSync(filePath); + expect(updateDevPublicFileEtag(index, filePath)).toBe(false); } finally { fs.rmSync(root, { recursive: true, force: true }); } }); + + it.runIf(process.platform !== "win32")( + "preserves directory symlink aliases without following cycles", + () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-alias-")); + const publicDir = path.join(root, "public"); + try { + fs.mkdirSync(path.join(publicDir, "a"), { recursive: true }); + fs.writeFileSync(path.join(publicDir, "a", "asset.js"), "content"); + fs.symlinkSync("a", path.join(publicDir, "b"), "dir"); + fs.symlinkSync("..", path.join(publicDir, "a", "cycle"), "dir"); + + const index = createDevPublicFileEtags(root); + const etag = index.etagsByRealPath.get( + toSlash(fs.realpathSync(path.join(publicDir, "a/asset.js"))), + ); + expect(etag).toMatch(/^W\//); + expect(resolveDevPublicIfNoneMatch("GET", "/a/asset.js", etag, index)).toBe(etag); + expect(resolveDevPublicIfNoneMatch("GET", "/b/asset.js", etag, index)).toBe(etag); + expect(resolveDevPublicIfNoneMatch("GET", "/a/cycle/a/asset.js", etag, index)).toBe(etag); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, + ); + + it.runIf(process.platform !== "win32")( + "updates files reported through a directory symlink's real path", + () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-real-watch-")); + const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-target-")); + const publicDir = path.join(root, "public"); + const externalFile = path.join(externalDir, "asset.js"); + try { + fs.mkdirSync(publicDir); + fs.writeFileSync(externalFile, "initial"); + fs.symlinkSync(externalDir, path.join(publicDir, "alias"), "dir"); + + const index = createDevPublicFileEtags(root); + const initial = resolveDevPublicIfNoneMatch( + "GET", + "/alias/asset.js", + index.etagsByRealPath.get(toSlash(fs.realpathSync(externalFile))), + index, + ); + expect(initial).toMatch(/^W\//); + + fs.writeFileSync(externalFile, "updated content"); + expect(updateDevPublicFileEtag(index, externalFile)).toBe(true); + const updated = index.etagsByRealPath.get(toSlash(fs.realpathSync(externalFile))); + expect(updated).toMatch(/^W\//); + expect(updated).not.toBe(initial); + expect(resolveDevPublicIfNoneMatch("GET", "/alias/asset.js", updated, index)).toBe(updated); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(externalDir, { recursive: true, force: true }); + } + }, + ); + + it.runIf(process.platform === "win32")("normalizes encoded Windows separators like Vite", () => { + const nestedEtags: DevPublicFileEtagIndex = { + publicDir: "/public", + etagsByRealPath: new Map([["/public/foo/bar.js", ETAG]]), + symlinkTargets: new Map(), + }; + expect(resolveDevPublicIfNoneMatch("GET", "/foo%5Cbar.js", '"90-1234"', nestedEtags)).toBe( + ETAG, + ); + }); }); diff --git a/tests/e2e/pages-router/script.spec.ts b/tests/e2e/pages-router/script.spec.ts index 806ab1b6b2..26178b3681 100644 --- a/tests/e2e/pages-router/script.spec.ts +++ b/tests/e2e/pages-router/script.spec.ts @@ -14,7 +14,6 @@ test.describe("next/script", () => { headers: { "If-None-Match": strong }, }); expect(strongResponse.status()).toBe(304); - expect(strongResponse.headers().etag).toBe(etag); const listResponse = await request.get(`${BASE}/dedupe-script.js`, { headers: { "If-None-Match": `"other", ${strong}` }, From 510f8e0d7dd63d8363c545f1ad7ffb83ad05fce9 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 12:21:05 +0100 Subject: [PATCH 09/23] fix(dev): honor Vite public file identity --- packages/vinext/src/index.ts | 10 +- packages/vinext/src/server/dev-public-etag.ts | 102 +++++++++++++++-- tests/dev-public-etag-vite.test.ts | 108 ++++++++++++++++++ tests/dev-public-etag.test.ts | 63 +++++++++- 4 files changed, 269 insertions(+), 14 deletions(-) create mode 100644 tests/dev-public-etag-vite.test.ts diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 3573d1568b..1d997d8018 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -4262,10 +4262,15 @@ export const loadServerActionClient = ${ }, configureServer(server: ViteDevServer) { - let devPublicFileEtags = createDevPublicFileEtags(root); + const devPublicDir = server.config.publicDir || undefined; + let devPublicFileEtags = devPublicDir ? createDevPublicFileEtags(devPublicDir) : undefined; server.middlewares.use((req, _res, next) => { req.__vinextOriginalEncodedUrl ??= req.url; + if (!devPublicFileEtags) { + next(); + return; + } const normalizedIfNoneMatch = resolveDevPublicIfNoneMatch( req.method, req.url, @@ -4283,8 +4288,9 @@ export const loadServerActionClient = ${ }); const updateDevPublicEtag = (filePath: string) => { + if (!devPublicFileEtags || !devPublicDir) return; if (!updateDevPublicFileEtag(devPublicFileEtags, filePath)) { - devPublicFileEtags = createDevPublicFileEtags(root); + devPublicFileEtags = createDevPublicFileEtags(devPublicDir); } }; server.watcher.on("add", updateDevPublicEtag); diff --git a/packages/vinext/src/server/dev-public-etag.ts b/packages/vinext/src/server/dev-public-etag.ts index 981f750914..455b84c5c7 100644 --- a/packages/vinext/src/server/dev-public-etag.ts +++ b/packages/vinext/src/server/dev-public-etag.ts @@ -7,15 +7,22 @@ import { normalizePath } from "./normalize-path.js"; export type DevPublicFileEtagIndex = { publicDir: string; etagsByRealPath: Map; + foldedRealPaths: Map; symlinkTargets: Map; + caseInsensitive: boolean; }; -export function createDevPublicFileEtags(root: string): DevPublicFileEtagIndex { - const publicDir = path.join(root, "public"); +export function createDevPublicFileEtags( + externalPublicDir: string, + caseInsensitive = detectCaseInsensitiveDirectory(externalPublicDir), +): DevPublicFileEtagIndex { + const publicDir = toSlash(externalPublicDir); const index: DevPublicFileEtagIndex = { publicDir, etagsByRealPath: new Map(), + foldedRealPaths: new Map(), symlinkTargets: new Map(), + caseInsensitive, }; const walk = (dir: string, realAncestors: ReadonlySet): void => { @@ -49,7 +56,7 @@ export function createDevPublicFileEtags(root: string): DevPublicFileEtagIndex { if (stats.isDirectory()) { walk(fullPath, nextAncestors); } else if (stats.isFile()) { - index.etagsByRealPath.set(realTarget, etagForStats(stats)); + indexFileEtag(index, realTarget, etagForStats(stats)); } continue; } @@ -61,7 +68,7 @@ export function createDevPublicFileEtags(root: string): DevPublicFileEtagIndex { try { const realPath = toSlash(fs.realpathSync(fullPath)); - index.etagsByRealPath.set(realPath, etagForStats(fs.statSync(fullPath))); + indexFileEtag(index, realPath, etagForStats(fs.statSync(fullPath))); } catch { // Ignore entries removed during the startup scan. } @@ -106,7 +113,7 @@ export function updateDevPublicFileEtag( const lstat = fs.lstatSync(filePath); if (lstat.isSymbolicLink() || !lstat.isFile()) return false; const realPath = toSlash(fs.realpathSync(filePath)); - index.etagsByRealPath.set(realPath, etagForStats(fs.statSync(filePath))); + indexFileEtag(index, realPath, etagForStats(fs.statSync(filePath))); return true; } catch { return false; @@ -141,20 +148,35 @@ export function resolveDevPublicIfNoneMatch( let filePath = path.join(index.publicDir, pathname); if (!isWithinPublicDir(index.publicDir, filePath)) return undefined; - filePath = resolveSymlinkTargets(filePath, index.symlinkTargets); + // Vite normally guards public requests with an exact-spelling file set. It + // disables that optimization when the public tree contains a symlink and + // lets sirv consult the filesystem, which may then accept case aliases. + const supportsFoldedLookup = index.caseInsensitive && index.symlinkTargets.size > 0; + filePath = resolveSymlinkTargets(filePath, index.symlinkTargets, supportsFoldedLookup); - const etag = index.etagsByRealPath.get(filePath); + let etag = index.etagsByRealPath.get(filePath); + if (!etag && supportsFoldedLookup) { + const canonicalPath = index.foldedRealPaths.get(foldPath(filePath)); + if (canonicalPath) etag = index.etagsByRealPath.get(canonicalPath); + } return etag && matchesIfNoneMatch(ifNoneMatch, etag) ? etag : undefined; } -function resolveSymlinkTargets(filePath: string, targets: ReadonlyMap): string { +function resolveSymlinkTargets( + filePath: string, + targets: ReadonlyMap, + caseInsensitive: boolean, +): string { const seen = new Set(); while (!seen.has(filePath)) { seen.add(filePath); let matchedSource: string | undefined; + const comparableFilePath = caseInsensitive ? foldPath(filePath) : filePath; for (const source of targets.keys()) { + const comparableSource = caseInsensitive ? foldPath(source) : source; if ( - (filePath === source || filePath.startsWith(source + "/")) && + (comparableFilePath === comparableSource || + comparableFilePath.startsWith(comparableSource + "/")) && (!matchedSource || source.length > matchedSource.length) ) { matchedSource = source; @@ -181,3 +203,65 @@ function etagForStats(stats: fs.Stats): string { // size/mtime weak validator in development. return `W/"${stats.size}-${stats.mtime.getTime()}"`; } + +function indexFileEtag(index: DevPublicFileEtagIndex, realPath: string, etag: string): void { + index.etagsByRealPath.set(realPath, etag); + if (!index.caseInsensitive) return; + + const foldedPath = foldPath(realPath); + const existing = index.foldedRealPaths.get(foldedPath); + if (existing === undefined || existing === realPath) { + index.foldedRealPaths.set(foldedPath, realPath); + } else { + // A synthetic or unusual filesystem can expose names which our ASCII fold + // aliases even though the filesystem does not. Exact spellings remain + // usable; ambiguous folded spellings fail closed. + index.foldedRealPaths.set(foldedPath, null); + } +} + +function detectCaseInsensitiveDirectory(externalDir: string): boolean { + const dir = toSlash(externalDir); + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return false; + } + + for (const entry of entries) { + const toggledName = toggleAsciiCase(entry.name); + if (!toggledName) continue; + try { + const actual = toSlash(fs.realpathSync(path.join(dir, entry.name))); + const toggled = toSlash(fs.realpathSync(path.join(dir, toggledName))); + return actual === toggled; + } catch { + return false; + } + } + + const basename = path.basename(dir); + const toggledBasename = toggleAsciiCase(basename); + if (!toggledBasename) return false; + try { + return ( + toSlash(fs.realpathSync(dir)) === + toSlash(fs.realpathSync(path.join(path.dirname(dir), toggledBasename))) + ); + } catch { + return false; + } +} + +function toggleAsciiCase(value: string): string | undefined { + const index = value.search(/[A-Za-z]/); + if (index === -1) return undefined; + const char = value[index]!; + const toggled = char === char.toLowerCase() ? char.toUpperCase() : char.toLowerCase(); + return value.slice(0, index) + toggled + value.slice(index + 1); +} + +function foldPath(value: string): string { + return value.replace(/[A-Z]/g, (char) => char.toLowerCase()); +} diff --git a/tests/dev-public-etag-vite.test.ts b/tests/dev-public-etag-vite.test.ts new file mode 100644 index 0000000000..c32444b395 --- /dev/null +++ b/tests/dev-public-etag-vite.test.ts @@ -0,0 +1,108 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createServer, type Plugin, type ViteDevServer } from "vite"; +import vinext from "../packages/vinext/src/index.js"; + +const servers: ViteDevServer[] = []; +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => server.close())); + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe("dev public ETag Vite configuration", () => { + it("does not rewrite validators when publicDir is disabled", async () => { + const root = createRoot(); + const publicDir = path.join(root, "public"); + fs.mkdirSync(publicDir); + const filePath = path.join(publicDir, "asset.js"); + fs.writeFileSync(filePath, "hello"); + const strong = etagForFile(filePath).replace(/^W\//, ""); + + const baseUrl = await startServer(root, false); + const response = await fetch(`${baseUrl}/asset.js`, { + headers: { "If-None-Match": strong }, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("x-fallback-if-none-match")).toBe(strong); + expect(await response.text()).toBe("fallback"); + }); + + it("indexes only Vite's resolved custom publicDir", async () => { + const root = createRoot(); + const defaultPublicDir = path.join(root, "public"); + const customPublicDir = path.join(root, "custom-public"); + fs.mkdirSync(defaultPublicDir); + fs.mkdirSync(customPublicDir); + const defaultFile = path.join(defaultPublicDir, "root-only.js"); + fs.writeFileSync(defaultFile, "wrong"); + fs.writeFileSync(path.join(customPublicDir, "asset.js"), "custom"); + + const baseUrl = await startServer(root, "custom-public"); + const initial = await fetch(`${baseUrl}/asset.js`); + expect(initial.status).toBe(200); + expect(await initial.text()).toBe("custom"); + const etag = initial.headers.get("etag"); + expect(etag).toMatch(/^W\//); + + const conditional = await fetch(`${baseUrl}/asset.js`, { + headers: { "If-None-Match": etag!.replace(/^W\//, "") }, + }); + expect(conditional.status).toBe(304); + + const rootOnlyStrong = etagForFile(defaultFile).replace(/^W\//, ""); + const fallback = await fetch(`${baseUrl}/root-only.js`, { + headers: { "If-None-Match": rootOnlyStrong }, + }); + expect(fallback.status).toBe(200); + expect(fallback.headers.get("x-fallback-if-none-match")).toBe(rootOnlyStrong); + expect(await fallback.text()).toBe("fallback"); + }); +}); + +function createRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-dev-public-config-")); + roots.push(root); + return root; +} + +async function startServer(root: string, publicDir: string | false): Promise { + const fallbackPlugin: Plugin = { + name: "test-public-etag-fallback", + configureServer(server) { + return () => { + server.middlewares.use((req, res) => { + const validator = req.headers["if-none-match"]; + if (typeof validator === "string") { + res.setHeader("x-fallback-if-none-match", validator); + } + res.statusCode = 200; + res.end("fallback"); + }); + }; + }, + }; + + const server = await createServer({ + root, + publicDir, + configFile: false, + plugins: [vinext(), fallbackPlugin], + logLevel: "silent", + server: { host: "127.0.0.1", port: 0 }, + }); + servers.push(server); + await server.listen(); + const address = server.httpServer!.address(); + if (!address || typeof address === "string") throw new Error("Expected a TCP dev server"); + return `http://127.0.0.1:${address.port}`; +} + +function etagForFile(filePath: string): string { + const stats = fs.statSync(filePath); + return `W/"${stats.size}-${stats.mtime.getTime()}"`; +} diff --git a/tests/dev-public-etag.test.ts b/tests/dev-public-etag.test.ts index c864206329..2923ad3eeb 100644 --- a/tests/dev-public-etag.test.ts +++ b/tests/dev-public-etag.test.ts @@ -14,7 +14,9 @@ const ETAG = 'W/"90-1234"'; const publicEtags: DevPublicFileEtagIndex = { publicDir: "/public", etagsByRealPath: new Map([["/public/asset.js", ETAG]]), + foldedRealPaths: new Map(), symlinkTargets: new Map(), + caseInsensitive: false, }; describe("resolveDevPublicIfNoneMatch", () => { @@ -72,7 +74,7 @@ describe("resolveDevPublicIfNoneMatch", () => { fs.mkdirSync(publicDir); fs.writeFileSync(filePath, "initial"); - const index = createDevPublicFileEtags(root); + const index = createDevPublicFileEtags(publicDir); const realPath = toSlash(fs.realpathSync(filePath)); const initial = index.etagsByRealPath.get(realPath); expect(initial).toMatch(/^W\/"7-\d+"$/); @@ -100,7 +102,7 @@ describe("resolveDevPublicIfNoneMatch", () => { fs.symlinkSync("a", path.join(publicDir, "b"), "dir"); fs.symlinkSync("..", path.join(publicDir, "a", "cycle"), "dir"); - const index = createDevPublicFileEtags(root); + const index = createDevPublicFileEtags(publicDir); const etag = index.etagsByRealPath.get( toSlash(fs.realpathSync(path.join(publicDir, "a/asset.js"))), ); @@ -126,7 +128,7 @@ describe("resolveDevPublicIfNoneMatch", () => { fs.writeFileSync(externalFile, "initial"); fs.symlinkSync(externalDir, path.join(publicDir, "alias"), "dir"); - const index = createDevPublicFileEtags(root); + const index = createDevPublicFileEtags(publicDir); const initial = resolveDevPublicIfNoneMatch( "GET", "/alias/asset.js", @@ -148,11 +150,66 @@ describe("resolveDevPublicIfNoneMatch", () => { }, ); + it.runIf(process.platform !== "win32")( + "folds served names only when the filesystem is case-insensitive", + () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-case-")); + const publicDir = path.join(root, "public"); + try { + fs.mkdirSync(publicDir); + fs.writeFileSync(path.join(publicDir, "MixedCase.js"), "content"); + + const exactSetIndex = createDevPublicFileEtags(publicDir, true); + exactSetIndex.symlinkTargets.clear(); + expect( + resolveDevPublicIfNoneMatch("GET", "/mixedcase.js", "*", exactSetIndex), + ).toBeUndefined(); + + fs.symlinkSync("MixedCase.js", path.join(publicDir, "alias.js")); + + const caseInsensitiveIndex = createDevPublicFileEtags(publicDir, true); + const etag = resolveDevPublicIfNoneMatch("GET", "/mixedcase.js", "*", caseInsensitiveIndex); + expect(etag).toMatch(/^W\//); + expect(resolveDevPublicIfNoneMatch("GET", "/ALIAS.JS", "*", caseInsensitiveIndex)).toBe( + etag, + ); + + const caseSensitiveIndex = createDevPublicFileEtags(publicDir, false); + expect( + resolveDevPublicIfNoneMatch("GET", "/mixedcase.js", "*", caseSensitiveIndex), + ).toBeUndefined(); + expect( + resolveDevPublicIfNoneMatch("GET", "/MixedCase.js", "*", caseSensitiveIndex), + ).toMatch(/^W\//); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, + ); + + it("fails ambiguous folded names closed while preserving exact spellings", () => { + const index: DevPublicFileEtagIndex = { + publicDir: "/public", + etagsByRealPath: new Map([ + ["/public/A.js", 'W/"5-1"'], + ["/public/a.js", 'W/"5-2"'], + ]), + foldedRealPaths: new Map([["/public/a.js", null]]), + symlinkTargets: new Map([["/public/alias", "/public/A.js"]]), + caseInsensitive: true, + }; + expect(resolveDevPublicIfNoneMatch("GET", "/A.js", "*", index)).toBe('W/"5-1"'); + expect(resolveDevPublicIfNoneMatch("GET", "/a.js", "*", index)).toBe('W/"5-2"'); + expect(resolveDevPublicIfNoneMatch("GET", "/a.JS", "*", index)).toBeUndefined(); + }); + it.runIf(process.platform === "win32")("normalizes encoded Windows separators like Vite", () => { const nestedEtags: DevPublicFileEtagIndex = { publicDir: "/public", etagsByRealPath: new Map([["/public/foo/bar.js", ETAG]]), + foldedRealPaths: new Map(), symlinkTargets: new Map(), + caseInsensitive: false, }; expect(resolveDevPublicIfNoneMatch("GET", "/foo%5Cbar.js", '"90-1234"', nestedEtags)).toBe( ETAG, From 1b3c835aafe645521a545cd9722013ed67eabb9e Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 12:25:19 +0100 Subject: [PATCH 10/23] fix(dev): mirror Vite public filesystem semantics --- packages/vinext/src/server/dev-public-etag.ts | 99 ++++++++++++++----- tests/dev-public-etag-vite.test.ts | 72 ++++++++++++++ tests/dev-public-etag.test.ts | 71 +++++++++++++ 3 files changed, 217 insertions(+), 25 deletions(-) diff --git a/packages/vinext/src/server/dev-public-etag.ts b/packages/vinext/src/server/dev-public-etag.ts index 455b84c5c7..c59194da0b 100644 --- a/packages/vinext/src/server/dev-public-etag.ts +++ b/packages/vinext/src/server/dev-public-etag.ts @@ -9,12 +9,15 @@ export type DevPublicFileEtagIndex = { etagsByRealPath: Map; foldedRealPaths: Map; symlinkTargets: Map; + hasSymlink: boolean; caseInsensitive: boolean; + normalizationInsensitive: boolean; }; export function createDevPublicFileEtags( externalPublicDir: string, caseInsensitive = detectCaseInsensitiveDirectory(externalPublicDir), + normalizationInsensitive = detectNormalizationInsensitiveDirectory(externalPublicDir), ): DevPublicFileEtagIndex { const publicDir = toSlash(externalPublicDir); const index: DevPublicFileEtagIndex = { @@ -22,13 +25,15 @@ export function createDevPublicFileEtags( etagsByRealPath: new Map(), foldedRealPaths: new Map(), symlinkTargets: new Map(), + hasSymlink: false, caseInsensitive, + normalizationInsensitive, }; const walk = (dir: string, realAncestors: ReadonlySet): void => { let realDir: string; try { - realDir = toSlash(fs.realpathSync(dir)); + realDir = toSlash(fs.realpathSync.native(dir)); } catch { return; } @@ -44,10 +49,11 @@ export function createDevPublicFileEtags( for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isSymbolicLink()) { + index.hasSymlink = true; let realTarget: string; let stats: fs.Stats; try { - realTarget = toSlash(fs.realpathSync(fullPath)); + realTarget = toSlash(fs.realpathSync.native(fullPath)); stats = fs.statSync(fullPath); } catch { continue; @@ -67,7 +73,7 @@ export function createDevPublicFileEtags( if (!entry.isFile()) continue; try { - const realPath = toSlash(fs.realpathSync(fullPath)); + const realPath = toSlash(fs.realpathSync.native(fullPath)); indexFileEtag(index, realPath, etagForStats(fs.statSync(fullPath))); } catch { // Ignore entries removed during the startup scan. @@ -76,7 +82,7 @@ export function createDevPublicFileEtags( }; if (fs.existsSync(publicDir)) { - const realPublicDir = toSlash(fs.realpathSync(publicDir)); + const realPublicDir = toSlash(fs.realpathSync.native(publicDir)); if (realPublicDir !== publicDir) index.symlinkTargets.set(publicDir, realPublicDir); walk(publicDir, new Set()); } @@ -95,7 +101,7 @@ export function updateDevPublicFileEtag( let filePath = toSlash(externalFilePath); if (!isWithinPublicDir(index.publicDir, filePath)) { try { - filePath = toSlash(fs.realpathSync(filePath)); + filePath = toSlash(fs.realpathSync.native(filePath)); } catch { // A removal behind a symlink requires a structural rebuild only when // the watcher reports it through the public alias, handled above. @@ -112,7 +118,7 @@ export function updateDevPublicFileEtag( try { const lstat = fs.lstatSync(filePath); if (lstat.isSymbolicLink() || !lstat.isFile()) return false; - const realPath = toSlash(fs.realpathSync(filePath)); + const realPath = toSlash(fs.realpathSync.native(filePath)); indexFileEtag(index, realPath, etagForStats(fs.statSync(filePath))); return true; } catch { @@ -151,12 +157,19 @@ export function resolveDevPublicIfNoneMatch( // Vite normally guards public requests with an exact-spelling file set. It // disables that optimization when the public tree contains a symlink and // lets sirv consult the filesystem, which may then accept case aliases. - const supportsFoldedLookup = index.caseInsensitive && index.symlinkTargets.size > 0; - filePath = resolveSymlinkTargets(filePath, index.symlinkTargets, supportsFoldedLookup); + const supportsFoldedLookup = index.caseInsensitive && index.hasSymlink; + filePath = resolveSymlinkTargets( + filePath, + index.symlinkTargets, + supportsFoldedLookup, + index.normalizationInsensitive, + ); let etag = index.etagsByRealPath.get(filePath); if (!etag && supportsFoldedLookup) { - const canonicalPath = index.foldedRealPaths.get(foldPath(filePath)); + const canonicalPath = index.foldedRealPaths.get( + foldPath(filePath, index.normalizationInsensitive), + ); if (canonicalPath) etag = index.etagsByRealPath.get(canonicalPath); } return etag && matchesIfNoneMatch(ifNoneMatch, etag) ? etag : undefined; @@ -166,24 +179,30 @@ function resolveSymlinkTargets( filePath: string, targets: ReadonlyMap, caseInsensitive: boolean, + normalizationInsensitive: boolean, ): string { const seen = new Set(); while (!seen.has(filePath)) { seen.add(filePath); let matchedSource: string | undefined; - const comparableFilePath = caseInsensitive ? foldPath(filePath) : filePath; + const fileSegments = filePath.split("/"); for (const source of targets.keys()) { - const comparableSource = caseInsensitive ? foldPath(source) : source; - if ( - (comparableFilePath === comparableSource || - comparableFilePath.startsWith(comparableSource + "/")) && - (!matchedSource || source.length > matchedSource.length) - ) { + const sourceSegments = source.split("/"); + if (sourceSegments.length > fileSegments.length) continue; + const matches = sourceSegments.every((segment, index) => { + const fileSegment = fileSegments[index]!; + return caseInsensitive + ? foldPath(fileSegment, normalizationInsensitive) === + foldPath(segment, normalizationInsensitive) + : fileSegment === segment; + }); + if (matches && (!matchedSource || sourceSegments.length > matchedSource.split("/").length)) { matchedSource = source; } } if (!matchedSource) break; - filePath = path.join(targets.get(matchedSource)!, filePath.slice(matchedSource.length)); + const suffix = fileSegments.slice(matchedSource.split("/").length).join("/"); + filePath = path.join(targets.get(matchedSource)!, suffix); } return filePath; } @@ -208,7 +227,7 @@ function indexFileEtag(index: DevPublicFileEtagIndex, realPath: string, etag: st index.etagsByRealPath.set(realPath, etag); if (!index.caseInsensitive) return; - const foldedPath = foldPath(realPath); + const foldedPath = foldPath(realPath, index.normalizationInsensitive); const existing = index.foldedRealPaths.get(foldedPath); if (existing === undefined || existing === realPath) { index.foldedRealPaths.set(foldedPath, realPath); @@ -233,11 +252,11 @@ function detectCaseInsensitiveDirectory(externalDir: string): boolean { const toggledName = toggleAsciiCase(entry.name); if (!toggledName) continue; try { - const actual = toSlash(fs.realpathSync(path.join(dir, entry.name))); - const toggled = toSlash(fs.realpathSync(path.join(dir, toggledName))); + const actual = toSlash(fs.realpathSync.native(path.join(dir, entry.name))); + const toggled = toSlash(fs.realpathSync.native(path.join(dir, toggledName))); return actual === toggled; } catch { - return false; + continue; } } @@ -246,14 +265,37 @@ function detectCaseInsensitiveDirectory(externalDir: string): boolean { if (!toggledBasename) return false; try { return ( - toSlash(fs.realpathSync(dir)) === - toSlash(fs.realpathSync(path.join(path.dirname(dir), toggledBasename))) + toSlash(fs.realpathSync.native(dir)) === + toSlash(fs.realpathSync.native(path.join(path.dirname(dir), toggledBasename))) ); } catch { return false; } } +function detectNormalizationInsensitiveDirectory(externalDir: string): boolean { + const dir = toSlash(externalDir); + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return false; + } + + for (const entry of entries) { + const alternateName = alternateNormalization(entry.name); + if (!alternateName) continue; + try { + const actual = toSlash(fs.realpathSync.native(path.join(dir, entry.name))); + const alternate = toSlash(fs.realpathSync.native(path.join(dir, alternateName))); + return actual === alternate; + } catch { + continue; + } + } + return false; +} + function toggleAsciiCase(value: string): string | undefined { const index = value.search(/[A-Za-z]/); if (index === -1) return undefined; @@ -262,6 +304,13 @@ function toggleAsciiCase(value: string): string | undefined { return value.slice(0, index) + toggled + value.slice(index + 1); } -function foldPath(value: string): string { - return value.replace(/[A-Z]/g, (char) => char.toLowerCase()); +function alternateNormalization(value: string): string | undefined { + const decomposed = value.normalize("NFD"); + if (decomposed !== value) return decomposed; + const composed = value.normalize("NFC"); + return composed !== value ? composed : undefined; +} + +function foldPath(value: string, normalizationInsensitive: boolean): string { + return (normalizationInsensitive ? value.normalize("NFD") : value).toLowerCase(); } diff --git a/tests/dev-public-etag-vite.test.ts b/tests/dev-public-etag-vite.test.ts index c32444b395..ae85f711cf 100644 --- a/tests/dev-public-etag-vite.test.ts +++ b/tests/dev-public-etag-vite.test.ts @@ -62,6 +62,78 @@ describe("dev public ETag Vite configuration", () => { expect(fallback.headers.get("x-fallback-if-none-match")).toBe(rootOnlyStrong); expect(await fallback.text()).toBe("fallback"); }); + + it.runIf(process.platform === "darwin")( + "matches case and normalization aliases when a dangling symlink enables Vite stat lookup", + async () => { + const root = createRoot(); + const publicDir = path.join(root, "public"); + fs.mkdirSync(publicDir); + const mixedCaseFile = path.join(publicDir, "MixedCase.js"); + const unicodeFile = path.join(publicDir, "Éclair.js"); + fs.writeFileSync(mixedCaseFile, "mixed"); + fs.writeFileSync(unicodeFile, "unicode"); + fs.symlinkSync("missing", path.join(publicDir, "0-broken")); + + const lowerCaseFile = path.join(publicDir, "mixedCase.js"); + if (fs.realpathSync.native(mixedCaseFile) !== fs.realpathSync.native(lowerCaseFile)) return; + + const baseUrl = await startServer(root, "public"); + const mixedEtag = (await fetch(`${baseUrl}/MixedCase.js`)).headers.get("etag"); + expect(mixedEtag).toMatch(/^W\//); + expect( + ( + await fetch(`${baseUrl}/mixedcase.js`, { + headers: { "If-None-Match": mixedEtag!.replace(/^W\//, "") }, + }) + ).status, + ).toBe(304); + + const unicodeEtag = (await fetch(`${baseUrl}/%C3%89clair.js`)).headers.get("etag"); + expect(unicodeEtag).toMatch(/^W\//); + expect( + ( + await fetch(`${baseUrl}/%C3%A9clair.js`, { + headers: { "If-None-Match": unicodeEtag!.replace(/^W\//, "") }, + }) + ).status, + ).toBe(304); + + const decomposedFile = path.join(publicDir, "E\u0301clair.js"); + if (fs.realpathSync.native(unicodeFile) === fs.realpathSync.native(decomposedFile)) { + expect( + ( + await fetch(`${baseUrl}/E%CC%81clair.js`, { + headers: { "If-None-Match": unicodeEtag!.replace(/^W\//, "") }, + }) + ).status, + ).toBe(304); + } + }, + ); + + it.runIf(process.platform !== "win32")( + "does not fold or rewrite a wrong-case request for a symlinked public root", + async () => { + const root = createRoot(); + const actualPublicDir = path.join(root, "actual-public"); + const publicDir = path.join(root, "public"); + fs.mkdirSync(actualPublicDir); + fs.writeFileSync(path.join(actualPublicDir, "MixedCase.js"), "content"); + fs.symlinkSync(actualPublicDir, publicDir, "dir"); + + const baseUrl = await startServer(root, "public"); + const initial = await fetch(`${baseUrl}/MixedCase.js`); + const strong = initial.headers.get("etag")!.replace(/^W\//, ""); + const fallback = await fetch(`${baseUrl}/mixedcase.js`, { + headers: { "If-None-Match": strong }, + }); + + expect(fallback.status).toBe(200); + expect(fallback.headers.get("x-fallback-if-none-match")).toBe(strong); + expect(await fallback.text()).toBe("fallback"); + }, + ); }); function createRoot(): string { diff --git a/tests/dev-public-etag.test.ts b/tests/dev-public-etag.test.ts index 2923ad3eeb..cedc55a8b4 100644 --- a/tests/dev-public-etag.test.ts +++ b/tests/dev-public-etag.test.ts @@ -16,7 +16,9 @@ const publicEtags: DevPublicFileEtagIndex = { etagsByRealPath: new Map([["/public/asset.js", ETAG]]), foldedRealPaths: new Map(), symlinkTargets: new Map(), + hasSymlink: false, caseInsensitive: false, + normalizationInsensitive: false, }; describe("resolveDevPublicIfNoneMatch", () => { @@ -196,20 +198,89 @@ describe("resolveDevPublicIfNoneMatch", () => { ]), foldedRealPaths: new Map([["/public/a.js", null]]), symlinkTargets: new Map([["/public/alias", "/public/A.js"]]), + hasSymlink: true, caseInsensitive: true, + normalizationInsensitive: false, }; expect(resolveDevPublicIfNoneMatch("GET", "/A.js", "*", index)).toBe('W/"5-1"'); expect(resolveDevPublicIfNoneMatch("GET", "/a.js", "*", index)).toBe('W/"5-2"'); expect(resolveDevPublicIfNoneMatch("GET", "/a.JS", "*", index)).toBeUndefined(); }); + it.runIf(process.platform !== "win32")( + "uses dangling symlinks to mirror Vite's stat-based public lookup", + () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-dangling-")); + const publicDir = path.join(root, "public"); + try { + fs.mkdirSync(publicDir); + fs.symlinkSync("missing", path.join(publicDir, "0-broken")); + fs.writeFileSync(path.join(publicDir, "MixedCase.js"), "content"); + + const index = createDevPublicFileEtags(publicDir, true); + expect(index.hasSymlink).toBe(true); + expect([...index.symlinkTargets.keys()].some((key) => key.endsWith("/0-broken"))).toBe( + false, + ); + expect(resolveDevPublicIfNoneMatch("GET", "/mixedcase.js", "*", index)).toMatch(/^W\//); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, + ); + + it.runIf(process.platform !== "win32")( + "does not treat a symlinked public root as a child symlink", + () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-root-link-")); + const actualPublicDir = path.join(root, "actual-public"); + const publicDir = path.join(root, "public"); + try { + fs.mkdirSync(actualPublicDir); + fs.writeFileSync(path.join(actualPublicDir, "MixedCase.js"), "content"); + fs.symlinkSync(actualPublicDir, publicDir, "dir"); + + const index = createDevPublicFileEtags(publicDir, true); + expect(index.symlinkTargets.get(toSlash(publicDir))).toBe( + toSlash(fs.realpathSync.native(actualPublicDir)), + ); + expect(index.hasSymlink).toBe(false); + expect(resolveDevPublicIfNoneMatch("GET", "/mixedcase.js", "*", index)).toBeUndefined(); + expect(resolveDevPublicIfNoneMatch("GET", "/MixedCase.js", "*", index)).toMatch(/^W\//); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, + ); + + it.runIf(process.platform !== "win32")( + "folds Unicode case and filesystem normalization aliases", + () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-unicode-")); + const publicDir = path.join(root, "public"); + try { + fs.mkdirSync(publicDir); + fs.writeFileSync(path.join(publicDir, "Éclair.js"), "content"); + fs.symlinkSync("Éclair.js", path.join(publicDir, "alias.js")); + + const index = createDevPublicFileEtags(publicDir, true, true); + expect(resolveDevPublicIfNoneMatch("GET", "/%C3%A9clair.js", "*", index)).toMatch(/^W\//); + expect(resolveDevPublicIfNoneMatch("GET", "/e%CC%81clair.js", "*", index)).toMatch(/^W\//); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, + ); + it.runIf(process.platform === "win32")("normalizes encoded Windows separators like Vite", () => { const nestedEtags: DevPublicFileEtagIndex = { publicDir: "/public", etagsByRealPath: new Map([["/public/foo/bar.js", ETAG]]), foldedRealPaths: new Map(), symlinkTargets: new Map(), + hasSymlink: false, caseInsensitive: false, + normalizationInsensitive: false, }; expect(resolveDevPublicIfNoneMatch("GET", "/foo%5Cbar.js", '"90-1234"', nestedEtags)).toBe( ETAG, From b395c757119fcf5435f52d3efc80d8b7d852695f Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 12:28:13 +0100 Subject: [PATCH 11/23] fix(dev): refresh public filesystem capabilities --- packages/vinext/src/server/dev-public-etag.ts | 76 +++++++++++++------ tests/dev-public-etag-vite.test.ts | 42 ++++++++++ tests/dev-public-etag.test.ts | 37 +++++++++ 3 files changed, 132 insertions(+), 23 deletions(-) diff --git a/packages/vinext/src/server/dev-public-etag.ts b/packages/vinext/src/server/dev-public-etag.ts index c59194da0b..d42eb7b8ea 100644 --- a/packages/vinext/src/server/dev-public-etag.ts +++ b/packages/vinext/src/server/dev-public-etag.ts @@ -118,6 +118,12 @@ export function updateDevPublicFileEtag( try { const lstat = fs.lstatSync(filePath); if (lstat.isSymbolicLink() || !lstat.isFile()) return false; + if ( + (!index.caseInsensitive && pathHasCaseInsensitiveAlias(filePath)) || + (!index.normalizationInsensitive && pathHasNormalizationInsensitiveAlias(filePath)) + ) { + return false; + } const realPath = toSlash(fs.realpathSync.native(filePath)); indexFileEtag(index, realPath, etagForStats(fs.statSync(filePath))); return true; @@ -157,18 +163,19 @@ export function resolveDevPublicIfNoneMatch( // Vite normally guards public requests with an exact-spelling file set. It // disables that optimization when the public tree contains a symlink and // lets sirv consult the filesystem, which may then accept case aliases. - const supportsFoldedLookup = index.caseInsensitive && index.hasSymlink; + const supportsFoldedLookup = + index.hasSymlink && (index.caseInsensitive || index.normalizationInsensitive); filePath = resolveSymlinkTargets( filePath, index.symlinkTargets, - supportsFoldedLookup, + index.caseInsensitive, index.normalizationInsensitive, ); let etag = index.etagsByRealPath.get(filePath); if (!etag && supportsFoldedLookup) { const canonicalPath = index.foldedRealPaths.get( - foldPath(filePath, index.normalizationInsensitive), + foldPath(filePath, index.caseInsensitive, index.normalizationInsensitive), ); if (canonicalPath) etag = index.etagsByRealPath.get(canonicalPath); } @@ -191,9 +198,9 @@ function resolveSymlinkTargets( if (sourceSegments.length > fileSegments.length) continue; const matches = sourceSegments.every((segment, index) => { const fileSegment = fileSegments[index]!; - return caseInsensitive - ? foldPath(fileSegment, normalizationInsensitive) === - foldPath(segment, normalizationInsensitive) + return caseInsensitive || normalizationInsensitive + ? foldPath(fileSegment, caseInsensitive, normalizationInsensitive) === + foldPath(segment, caseInsensitive, normalizationInsensitive) : fileSegment === segment; }); if (matches && (!matchedSource || sourceSegments.length > matchedSource.split("/").length)) { @@ -225,9 +232,9 @@ function etagForStats(stats: fs.Stats): string { function indexFileEtag(index: DevPublicFileEtagIndex, realPath: string, etag: string): void { index.etagsByRealPath.set(realPath, etag); - if (!index.caseInsensitive) return; + if (!index.caseInsensitive && !index.normalizationInsensitive) return; - const foldedPath = foldPath(realPath, index.normalizationInsensitive); + const foldedPath = foldPath(realPath, index.caseInsensitive, index.normalizationInsensitive); const existing = index.foldedRealPaths.get(foldedPath); if (existing === undefined || existing === realPath) { index.foldedRealPaths.set(foldedPath, realPath); @@ -247,17 +254,13 @@ function detectCaseInsensitiveDirectory(externalDir: string): boolean { } catch { return false; } + const entryNames = new Set(entries.map((entry) => entry.name)); for (const entry of entries) { const toggledName = toggleAsciiCase(entry.name); if (!toggledName) continue; - try { - const actual = toSlash(fs.realpathSync.native(path.join(dir, entry.name))); - const toggled = toSlash(fs.realpathSync.native(path.join(dir, toggledName))); - return actual === toggled; - } catch { - continue; - } + if (entryNames.has(toggledName)) return false; + if (pathHasCaseInsensitiveAlias(path.join(dir, entry.name))) return true; } const basename = path.basename(dir); @@ -281,16 +284,14 @@ function detectNormalizationInsensitiveDirectory(externalDir: string): boolean { } catch { return false; } + const entryNames = new Set(entries.map((entry) => entry.name)); for (const entry of entries) { const alternateName = alternateNormalization(entry.name); if (!alternateName) continue; - try { - const actual = toSlash(fs.realpathSync.native(path.join(dir, entry.name))); - const alternate = toSlash(fs.realpathSync.native(path.join(dir, alternateName))); - return actual === alternate; - } catch { - continue; + if (entryNames.has(alternateName)) return false; + if (pathHasNormalizationInsensitiveAlias(path.join(dir, entry.name))) { + return true; } } return false; @@ -311,6 +312,35 @@ function alternateNormalization(value: string): string | undefined { return composed !== value ? composed : undefined; } -function foldPath(value: string, normalizationInsensitive: boolean): string { - return (normalizationInsensitive ? value.normalize("NFD") : value).toLowerCase(); +function pathHasCaseInsensitiveAlias(filePath: string): boolean { + const toggledName = toggleAsciiCase(path.basename(filePath)); + return toggledName + ? resolvesToSamePath(filePath, path.join(path.dirname(filePath), toggledName)) + : false; +} + +function pathHasNormalizationInsensitiveAlias(filePath: string): boolean { + const alternateName = alternateNormalization(path.basename(filePath)); + return alternateName + ? resolvesToSamePath(filePath, path.join(path.dirname(filePath), alternateName)) + : false; +} + +function resolvesToSamePath(left: string, right: string): boolean { + try { + return toSlash(fs.realpathSync.native(left)) === toSlash(fs.realpathSync.native(right)); + } catch { + return false; + } +} + +function foldPath( + value: string, + caseInsensitive: boolean, + normalizationInsensitive: boolean, +): string { + const normalized = normalizationInsensitive ? value.normalize("NFD") : value; + // Uppercase expansion followed by lowercase approximates Unicode's full + // default case fold (for example ß/SS and final sigma) using built-in data. + return caseInsensitive ? normalized.toUpperCase().toLowerCase() : normalized; } diff --git a/tests/dev-public-etag-vite.test.ts b/tests/dev-public-etag-vite.test.ts index ae85f711cf..54e2e4b7d6 100644 --- a/tests/dev-public-etag-vite.test.ts +++ b/tests/dev-public-etag-vite.test.ts @@ -71,8 +71,10 @@ describe("dev public ETag Vite configuration", () => { fs.mkdirSync(publicDir); const mixedCaseFile = path.join(publicDir, "MixedCase.js"); const unicodeFile = path.join(publicDir, "Éclair.js"); + const sharpSFile = path.join(publicDir, "Straße.js"); fs.writeFileSync(mixedCaseFile, "mixed"); fs.writeFileSync(unicodeFile, "unicode"); + fs.writeFileSync(sharpSFile, "sharp-s"); fs.symlinkSync("missing", path.join(publicDir, "0-broken")); const lowerCaseFile = path.join(publicDir, "mixedCase.js"); @@ -109,6 +111,46 @@ describe("dev public ETag Vite configuration", () => { ).status, ).toBe(304); } + + const sharpSEtag = (await fetch(`${baseUrl}/Stra%C3%9Fe.js`)).headers.get("etag"); + expect(sharpSEtag).toMatch(/^W\//); + expect( + ( + await fetch(`${baseUrl}/STRASSE.js`, { + headers: { "If-None-Match": sharpSEtag!.replace(/^W\//, "") }, + }) + ).status, + ).toBe(304); + }, + ); + + it.runIf(process.platform === "darwin")( + "expands normalization semantics when a file is added after startup", + async () => { + const root = createRoot(); + const publicDir = path.join(root, "public"); + fs.mkdirSync(publicDir); + fs.writeFileSync(path.join(publicDir, "base.js"), "base"); + fs.symlinkSync("missing", path.join(publicDir, "0-broken")); + + const baseUrl = await startServer(root, "public"); + fs.writeFileSync(path.join(publicDir, "Éclair.js"), "unicode"); + + await expect + .poll( + async () => { + const canonical = await fetch(`${baseUrl}/%C3%89clair.js`); + const etag = canonical.headers.get("etag"); + if (!etag) return canonical.status; + return ( + await fetch(`${baseUrl}/E%CC%81clair.js`, { + headers: { "If-None-Match": etag.replace(/^W\//, "") }, + }) + ).status; + }, + { timeout: 5000 }, + ) + .toBe(304); }, ); diff --git a/tests/dev-public-etag.test.ts b/tests/dev-public-etag.test.ts index cedc55a8b4..3da995a1a0 100644 --- a/tests/dev-public-etag.test.ts +++ b/tests/dev-public-etag.test.ts @@ -261,11 +261,48 @@ describe("resolveDevPublicIfNoneMatch", () => { try { fs.mkdirSync(publicDir); fs.writeFileSync(path.join(publicDir, "Éclair.js"), "content"); + fs.writeFileSync(path.join(publicDir, "Straße.js"), "content"); fs.symlinkSync("Éclair.js", path.join(publicDir, "alias.js")); const index = createDevPublicFileEtags(publicDir, true, true); expect(resolveDevPublicIfNoneMatch("GET", "/%C3%A9clair.js", "*", index)).toMatch(/^W\//); expect(resolveDevPublicIfNoneMatch("GET", "/e%CC%81clair.js", "*", index)).toMatch(/^W\//); + expect(resolveDevPublicIfNoneMatch("GET", "/STRASSE.js", "*", index)).toMatch(/^W\//); + + const normalizationOnlyIndex = createDevPublicFileEtags(publicDir, false, true); + expect( + resolveDevPublicIfNoneMatch("GET", "/E%CC%81clair.js", "*", normalizationOnlyIndex), + ).toMatch(/^W\//); + expect( + resolveDevPublicIfNoneMatch("GET", "/e%CC%81clair.js", "*", normalizationOnlyIndex), + ).toBeUndefined(); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, + ); + + it.runIf(process.platform !== "win32")( + "does not infer insensitive semantics from distinct aliases to one target", + () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-alias-probe-")); + const publicDir = path.join(root, "public"); + try { + fs.mkdirSync(publicDir); + fs.writeFileSync(path.join(publicDir, "target.js"), "content"); + fs.symlinkSync("target.js", path.join(publicDir, "alias")); + try { + fs.symlinkSync("target.js", path.join(publicDir, "Alias")); + fs.symlinkSync("target.js", path.join(publicDir, "É")); + fs.symlinkSync("target.js", path.join(publicDir, "E\u0301")); + } catch { + return; + } + + const index = createDevPublicFileEtags(publicDir); + expect(index.caseInsensitive).toBe(false); + expect(index.normalizationInsensitive).toBe(false); + expect(resolveDevPublicIfNoneMatch("GET", "/ALIAS", "*", index)).toBeUndefined(); } finally { fs.rmSync(root, { recursive: true, force: true }); } From b4dfe416b6c842b5713c6c89356069413d5f47b3 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 12:33:14 +0100 Subject: [PATCH 12/23] fix(dev): verify public path aliases --- packages/vinext/src/index.ts | 7 ++- packages/vinext/src/server/dev-public-etag.ts | 19 +++++- tests/dev-public-etag-vite.test.ts | 62 +++++++++++++++++++ tests/dev-public-etag.test.ts | 9 ++- 4 files changed, 92 insertions(+), 5 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 1d997d8018..25cc1aac80 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -4290,7 +4290,12 @@ export const loadServerActionClient = ${ const updateDevPublicEtag = (filePath: string) => { if (!devPublicFileEtags || !devPublicDir) return; if (!updateDevPublicFileEtag(devPublicFileEtags, filePath)) { - devPublicFileEtags = createDevPublicFileEtags(devPublicDir); + devPublicFileEtags = createDevPublicFileEtags( + devPublicDir, + undefined, + undefined, + devPublicFileEtags.viteUsesStatLookup, + ); } }; server.watcher.on("add", updateDevPublicEtag); diff --git a/packages/vinext/src/server/dev-public-etag.ts b/packages/vinext/src/server/dev-public-etag.ts index d42eb7b8ea..efc81ca127 100644 --- a/packages/vinext/src/server/dev-public-etag.ts +++ b/packages/vinext/src/server/dev-public-etag.ts @@ -10,6 +10,7 @@ export type DevPublicFileEtagIndex = { foldedRealPaths: Map; symlinkTargets: Map; hasSymlink: boolean; + viteUsesStatLookup: boolean; caseInsensitive: boolean; normalizationInsensitive: boolean; }; @@ -18,6 +19,7 @@ export function createDevPublicFileEtags( externalPublicDir: string, caseInsensitive = detectCaseInsensitiveDirectory(externalPublicDir), normalizationInsensitive = detectNormalizationInsensitiveDirectory(externalPublicDir), + viteUsesStatLookup?: boolean, ): DevPublicFileEtagIndex { const publicDir = toSlash(externalPublicDir); const index: DevPublicFileEtagIndex = { @@ -26,6 +28,7 @@ export function createDevPublicFileEtags( foldedRealPaths: new Map(), symlinkTargets: new Map(), hasSymlink: false, + viteUsesStatLookup: false, caseInsensitive, normalizationInsensitive, }; @@ -86,6 +89,7 @@ export function createDevPublicFileEtags( if (realPublicDir !== publicDir) index.symlinkTargets.set(publicDir, realPublicDir); walk(publicDir, new Set()); } + index.viteUsesStatLookup = viteUsesStatLookup ?? index.hasSymlink; return index; } @@ -164,7 +168,7 @@ export function resolveDevPublicIfNoneMatch( // disables that optimization when the public tree contains a symlink and // lets sirv consult the filesystem, which may then accept case aliases. const supportsFoldedLookup = - index.hasSymlink && (index.caseInsensitive || index.normalizationInsensitive); + index.viteUsesStatLookup && (index.caseInsensitive || index.normalizationInsensitive); filePath = resolveSymlinkTargets( filePath, index.symlinkTargets, @@ -233,6 +237,7 @@ function etagForStats(stats: fs.Stats): string { function indexFileEtag(index: DevPublicFileEtagIndex, realPath: string, etag: string): void { index.etagsByRealPath.set(realPath, etag); if (!index.caseInsensitive && !index.normalizationInsensitive) return; + if (!hasVerifiedFoldedAlias(index, realPath)) return; const foldedPath = foldPath(realPath, index.caseInsensitive, index.normalizationInsensitive); const existing = index.foldedRealPaths.get(foldedPath); @@ -246,6 +251,18 @@ function indexFileEtag(index: DevPublicFileEtagIndex, realPath: string, etag: st } } +function hasVerifiedFoldedAlias(index: DevPublicFileEtagIndex, realPath: string): boolean { + let alias = realPath; + if (index.normalizationInsensitive) { + alias = alternateNormalization(alias) ?? alias; + } + if (index.caseInsensitive) { + const upper = alias.toUpperCase(); + alias = upper === alias ? alias.toLowerCase() : upper; + } + return alias !== realPath && resolvesToSamePath(realPath, alias); +} + function detectCaseInsensitiveDirectory(externalDir: string): boolean { const dir = toSlash(externalDir); let entries: fs.Dirent[]; diff --git a/tests/dev-public-etag-vite.test.ts b/tests/dev-public-etag-vite.test.ts index 54e2e4b7d6..286e12389f 100644 --- a/tests/dev-public-etag-vite.test.ts +++ b/tests/dev-public-etag-vite.test.ts @@ -72,9 +72,11 @@ describe("dev public ETag Vite configuration", () => { const mixedCaseFile = path.join(publicDir, "MixedCase.js"); const unicodeFile = path.join(publicDir, "Éclair.js"); const sharpSFile = path.join(publicDir, "Straße.js"); + const dotlessIFile = path.join(publicDir, "ı.js"); fs.writeFileSync(mixedCaseFile, "mixed"); fs.writeFileSync(unicodeFile, "unicode"); fs.writeFileSync(sharpSFile, "sharp-s"); + fs.writeFileSync(dotlessIFile, "dotless-i"); fs.symlinkSync("missing", path.join(publicDir, "0-broken")); const lowerCaseFile = path.join(publicDir, "mixedCase.js"); @@ -91,6 +93,16 @@ describe("dev public ETag Vite configuration", () => { ).status, ).toBe(304); + const dotlessIEtag = (await fetch(`${baseUrl}/%C4%B1.js`)).headers.get("etag"); + expect(dotlessIEtag).toMatch(/^W\//); + const dottedFallback = await fetch(`${baseUrl}/i.js`, { + headers: { "If-None-Match": dotlessIEtag!.replace(/^W\//, "") }, + }); + expect(dottedFallback.status).toBe(200); + expect(dottedFallback.headers.get("x-fallback-if-none-match")).toBe( + dotlessIEtag!.replace(/^W\//, ""), + ); + const unicodeEtag = (await fetch(`${baseUrl}/%C3%89clair.js`)).headers.get("etag"); expect(unicodeEtag).toMatch(/^W\//); expect( @@ -176,6 +188,56 @@ describe("dev public ETag Vite configuration", () => { expect(await fallback.text()).toBe("fallback"); }, ); + + it.runIf(process.platform === "darwin")( + "keeps Vite's exact public lookup mode when a symlink is added later", + async () => { + const root = createRoot(); + const publicDir = path.join(root, "public"); + fs.mkdirSync(publicDir); + fs.writeFileSync(path.join(publicDir, "MixedCase.js"), "content"); + + const baseUrl = await startServer(root, "public"); + const initial = await fetch(`${baseUrl}/MixedCase.js`); + const strong = initial.headers.get("etag")!.replace(/^W\//, ""); + fs.symlinkSync("MixedCase.js", path.join(publicDir, "alias.js")); + await expect.poll(async () => (await fetch(`${baseUrl}/alias.js`)).status).toBe(200); + + const fallback = await fetch(`${baseUrl}/mixedcase.js`, { + headers: { "If-None-Match": strong }, + }); + expect(fallback.status).toBe(200); + expect(fallback.headers.get("x-fallback-if-none-match")).toBe(strong); + }, + ); + + it.runIf(process.platform === "darwin")( + "keeps Vite's stat lookup mode when its startup symlink is removed", + async () => { + const root = createRoot(); + const publicDir = path.join(root, "public"); + fs.mkdirSync(publicDir); + fs.writeFileSync(path.join(publicDir, "MixedCase.js"), "content"); + const alias = path.join(publicDir, "alias.js"); + fs.symlinkSync("MixedCase.js", alias); + + const baseUrl = await startServer(root, "public"); + const initial = await fetch(`${baseUrl}/MixedCase.js`); + const strong = initial.headers.get("etag")!.replace(/^W\//, ""); + fs.rmSync(alias); + + await expect + .poll( + async () => + ( + await fetch(`${baseUrl}/mixedcase.js`, { + headers: { "If-None-Match": strong }, + }) + ).status, + ) + .toBe(304); + }, + ); }); function createRoot(): string { diff --git a/tests/dev-public-etag.test.ts b/tests/dev-public-etag.test.ts index 3da995a1a0..eba446c5a9 100644 --- a/tests/dev-public-etag.test.ts +++ b/tests/dev-public-etag.test.ts @@ -17,6 +17,7 @@ const publicEtags: DevPublicFileEtagIndex = { foldedRealPaths: new Map(), symlinkTargets: new Map(), hasSymlink: false, + viteUsesStatLookup: false, caseInsensitive: false, normalizationInsensitive: false, }; @@ -152,7 +153,7 @@ describe("resolveDevPublicIfNoneMatch", () => { }, ); - it.runIf(process.platform !== "win32")( + it.runIf(process.platform === "darwin")( "folds served names only when the filesystem is case-insensitive", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-case-")); @@ -199,6 +200,7 @@ describe("resolveDevPublicIfNoneMatch", () => { foldedRealPaths: new Map([["/public/a.js", null]]), symlinkTargets: new Map([["/public/alias", "/public/A.js"]]), hasSymlink: true, + viteUsesStatLookup: true, caseInsensitive: true, normalizationInsensitive: false, }; @@ -207,7 +209,7 @@ describe("resolveDevPublicIfNoneMatch", () => { expect(resolveDevPublicIfNoneMatch("GET", "/a.JS", "*", index)).toBeUndefined(); }); - it.runIf(process.platform !== "win32")( + it.runIf(process.platform === "darwin")( "uses dangling symlinks to mirror Vite's stat-based public lookup", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-dangling-")); @@ -253,7 +255,7 @@ describe("resolveDevPublicIfNoneMatch", () => { }, ); - it.runIf(process.platform !== "win32")( + it.runIf(process.platform === "darwin")( "folds Unicode case and filesystem normalization aliases", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-unicode-")); @@ -316,6 +318,7 @@ describe("resolveDevPublicIfNoneMatch", () => { foldedRealPaths: new Map(), symlinkTargets: new Map(), hasSymlink: false, + viteUsesStatLookup: false, caseInsensitive: false, normalizationInsensitive: false, }; From ecc9fad1b977e06eefa0c1a5873cac7e4c53e103 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 12:40:14 +0100 Subject: [PATCH 13/23] fix(dev): index verified public request aliases --- packages/vinext/src/server/dev-public-etag.ts | 161 ++++++++++++------ tests/dev-public-etag-vite.test.ts | 35 +++- tests/dev-public-etag.test.ts | 6 +- 3 files changed, 147 insertions(+), 55 deletions(-) diff --git a/packages/vinext/src/server/dev-public-etag.ts b/packages/vinext/src/server/dev-public-etag.ts index efc81ca127..dc766ac402 100644 --- a/packages/vinext/src/server/dev-public-etag.ts +++ b/packages/vinext/src/server/dev-public-etag.ts @@ -7,7 +7,7 @@ import { normalizePath } from "./normalize-path.js"; export type DevPublicFileEtagIndex = { publicDir: string; etagsByRealPath: Map; - foldedRealPaths: Map; + requestPathRealPaths: Map; symlinkTargets: Map; hasSymlink: boolean; viteUsesStatLookup: boolean; @@ -25,7 +25,7 @@ export function createDevPublicFileEtags( const index: DevPublicFileEtagIndex = { publicDir, etagsByRealPath: new Map(), - foldedRealPaths: new Map(), + requestPathRealPaths: new Map(), symlinkTargets: new Map(), hasSymlink: false, viteUsesStatLookup: false, @@ -61,11 +61,11 @@ export function createDevPublicFileEtags( } catch { continue; } - index.symlinkTargets.set(fullPath, realTarget); + indexSymlinkTarget(index, fullPath, realTarget); if (stats.isDirectory()) { walk(fullPath, nextAncestors); } else if (stats.isFile()) { - indexFileEtag(index, realTarget, etagForStats(stats)); + indexFileEtag(index, realTarget, etagForStats(stats), fullPath); } continue; } @@ -77,7 +77,7 @@ export function createDevPublicFileEtags( try { const realPath = toSlash(fs.realpathSync.native(fullPath)); - indexFileEtag(index, realPath, etagForStats(fs.statSync(fullPath))); + indexFileEtag(index, realPath, etagForStats(fs.statSync(fullPath)), fullPath); } catch { // Ignore entries removed during the startup scan. } @@ -129,7 +129,10 @@ export function updateDevPublicFileEtag( return false; } const realPath = toSlash(fs.realpathSync.native(filePath)); - indexFileEtag(index, realPath, etagForStats(fs.statSync(filePath))); + const requestPaths = requestPathsForRealPath(index, filePath, realPath); + for (const requestPath of requestPaths) { + indexFileEtag(index, realPath, etagForStats(fs.statSync(filePath)), requestPath); + } return true; } catch { return false; @@ -169,20 +172,11 @@ export function resolveDevPublicIfNoneMatch( // lets sirv consult the filesystem, which may then accept case aliases. const supportsFoldedLookup = index.viteUsesStatLookup && (index.caseInsensitive || index.normalizationInsensitive); - filePath = resolveSymlinkTargets( - filePath, - index.symlinkTargets, - index.caseInsensitive, - index.normalizationInsensitive, - ); + let realPath = supportsFoldedLookup ? resolveRequestPath(index, filePath) : undefined; + filePath = resolveSymlinkTargets(filePath, index.symlinkTargets, index.caseInsensitive); + realPath ??= supportsFoldedLookup ? resolveRequestPath(index, filePath) : undefined; - let etag = index.etagsByRealPath.get(filePath); - if (!etag && supportsFoldedLookup) { - const canonicalPath = index.foldedRealPaths.get( - foldPath(filePath, index.caseInsensitive, index.normalizationInsensitive), - ); - if (canonicalPath) etag = index.etagsByRealPath.get(canonicalPath); - } + const etag = index.etagsByRealPath.get(realPath ?? filePath); return etag && matchesIfNoneMatch(ifNoneMatch, etag) ? etag : undefined; } @@ -190,7 +184,6 @@ function resolveSymlinkTargets( filePath: string, targets: ReadonlyMap, caseInsensitive: boolean, - normalizationInsensitive: boolean, ): string { const seen = new Set(); while (!seen.has(filePath)) { @@ -202,9 +195,8 @@ function resolveSymlinkTargets( if (sourceSegments.length > fileSegments.length) continue; const matches = sourceSegments.every((segment, index) => { const fileSegment = fileSegments[index]!; - return caseInsensitive || normalizationInsensitive - ? foldPath(fileSegment, caseInsensitive, normalizationInsensitive) === - foldPath(segment, caseInsensitive, normalizationInsensitive) + return caseInsensitive + ? asciiCaseKey(fileSegment) === asciiCaseKey(segment) : fileSegment === segment; }); if (matches && (!matchedSource || sourceSegments.length > matchedSource.split("/").length)) { @@ -234,33 +226,105 @@ function etagForStats(stats: fs.Stats): string { return `W/"${stats.size}-${stats.mtime.getTime()}"`; } -function indexFileEtag(index: DevPublicFileEtagIndex, realPath: string, etag: string): void { +function indexFileEtag( + index: DevPublicFileEtagIndex, + realPath: string, + etag: string, + requestPath: string, +): void { index.etagsByRealPath.set(realPath, etag); - if (!index.caseInsensitive && !index.normalizationInsensitive) return; - if (!hasVerifiedFoldedAlias(index, realPath)) return; + indexRequestPath(index, requestPath, realPath); + for (const alias of verifiedRequestAliases(index, requestPath, realPath)) { + indexRequestPath(index, alias, realPath); + } +} - const foldedPath = foldPath(realPath, index.caseInsensitive, index.normalizationInsensitive); - const existing = index.foldedRealPaths.get(foldedPath); +function indexRequestPath( + index: DevPublicFileEtagIndex, + requestPath: string, + realPath: string, +): void { + const key = requestPathKey(requestPath, index.caseInsensitive); + const existing = index.requestPathRealPaths.get(key); if (existing === undefined || existing === realPath) { - index.foldedRealPaths.set(foldedPath, realPath); + index.requestPathRealPaths.set(key, realPath); } else { - // A synthetic or unusual filesystem can expose names which our ASCII fold - // aliases even though the filesystem does not. Exact spellings remain - // usable; ambiguous folded spellings fail closed. - index.foldedRealPaths.set(foldedPath, null); + index.requestPathRealPaths.set(key, null); } } -function hasVerifiedFoldedAlias(index: DevPublicFileEtagIndex, realPath: string): boolean { - let alias = realPath; - if (index.normalizationInsensitive) { - alias = alternateNormalization(alias) ?? alias; +function indexSymlinkTarget( + index: DevPublicFileEtagIndex, + requestPath: string, + realTarget: string, +): void { + index.symlinkTargets.set(requestPath, realTarget); + for (const alias of verifiedRequestAliases(index, requestPath, realTarget)) { + index.symlinkTargets.set(alias, realTarget); + } +} + +function verifiedRequestAliases( + index: DevPublicFileEtagIndex, + requestPath: string, + realPath: string, +): string[] { + const relativePath = path.relative(index.publicDir, requestPath); + if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) return []; + + const segments = relativePath.split("/"); + const aliases: string[] = []; + for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) { + const segment = segments[segmentIndex]!; + const candidates = new Set(); + if (index.caseInsensitive) { + candidates.add(segment.toUpperCase()); + candidates.add(segment.toLowerCase()); + } + if (index.normalizationInsensitive) { + candidates.add(segment.normalize("NFD")); + candidates.add(segment.normalize("NFC")); + } + for (const candidate of candidates) { + if (index.caseInsensitive) { + candidates.add(candidate.toUpperCase()); + candidates.add(candidate.toLowerCase()); + } + } + for (const candidate of candidates) { + if (candidate === segment) continue; + const aliasSegments = segments.slice(); + aliasSegments[segmentIndex] = candidate; + const alias = path.join(index.publicDir, aliasSegments.join("/")); + if (resolvesToSamePath(realPath, alias)) aliases.push(alias); + } } - if (index.caseInsensitive) { - const upper = alias.toUpperCase(); - alias = upper === alias ? alias.toLowerCase() : upper; + return aliases; +} + +function requestPathsForRealPath( + index: DevPublicFileEtagIndex, + watcherPath: string, + realPath: string, +): string[] { + const paths = new Set(); + if (isWithinPublicDir(index.publicDir, watcherPath)) paths.add(watcherPath); + for (const [source, target] of index.symlinkTargets) { + if (realPath === target || realPath.startsWith(target + "/")) { + paths.add(path.join(source, realPath.slice(target.length))); + } } - return alias !== realPath && resolvesToSamePath(realPath, alias); + return paths.size > 0 ? [...paths] : [realPath]; +} + +function resolveRequestPath( + index: DevPublicFileEtagIndex, + requestPath: string, +): string | undefined { + const realPath = index.requestPathRealPaths.get( + requestPathKey(requestPath, index.caseInsensitive), + ); + return realPath ?? undefined; } function detectCaseInsensitiveDirectory(externalDir: string): boolean { @@ -351,13 +415,10 @@ function resolvesToSamePath(left: string, right: string): boolean { } } -function foldPath( - value: string, - caseInsensitive: boolean, - normalizationInsensitive: boolean, -): string { - const normalized = normalizationInsensitive ? value.normalize("NFD") : value; - // Uppercase expansion followed by lowercase approximates Unicode's full - // default case fold (for example ß/SS and final sigma) using built-in data. - return caseInsensitive ? normalized.toUpperCase().toLowerCase() : normalized; +function requestPathKey(value: string, caseInsensitive: boolean): string { + return caseInsensitive ? asciiCaseKey(value) : value; +} + +function asciiCaseKey(value: string): string { + return value.replace(/[A-Z]/g, (char) => char.toLowerCase()); } diff --git a/tests/dev-public-etag-vite.test.ts b/tests/dev-public-etag-vite.test.ts index 286e12389f..1ff0d81822 100644 --- a/tests/dev-public-etag-vite.test.ts +++ b/tests/dev-public-etag-vite.test.ts @@ -238,10 +238,41 @@ describe("dev public ETag Vite configuration", () => { .toBe(304); }, ); + + it.runIf(process.platform === "darwin")( + "keeps unverified Unicode aliases distinct from dotted files and symlinks", + async () => { + const root = createRoot("ı-etag-root-"); + const publicDir = path.join(root, "public"); + fs.mkdirSync(publicDir); + fs.writeFileSync(path.join(publicDir, "MixedCase.js"), "mixed"); + fs.writeFileSync(path.join(publicDir, "target.js"), "target"); + fs.symlinkSync("target.js", path.join(publicDir, "i.js")); + + const baseUrl = await startServer(root, "public"); + const mixedEtag = (await fetch(`${baseUrl}/MixedCase.js`)).headers.get("etag")!; + expect( + ( + await fetch(`${baseUrl}/mixedcase.js`, { + headers: { "If-None-Match": mixedEtag.replace(/^W\//, "") }, + }) + ).status, + ).toBe(304); + + const dottedEtag = (await fetch(`${baseUrl}/i.js`)).headers.get("etag")!; + const dotlessFallback = await fetch(`${baseUrl}/%C4%B1.js`, { + headers: { "If-None-Match": dottedEtag.replace(/^W\//, "") }, + }); + expect(dotlessFallback.status).toBe(200); + expect(dotlessFallback.headers.get("x-fallback-if-none-match")).toBe( + dottedEtag.replace(/^W\//, ""), + ); + }, + ); }); -function createRoot(): string { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-dev-public-config-")); +function createRoot(prefix = "vinext-dev-public-config-"): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); roots.push(root); return root; } diff --git a/tests/dev-public-etag.test.ts b/tests/dev-public-etag.test.ts index eba446c5a9..1135e7fafb 100644 --- a/tests/dev-public-etag.test.ts +++ b/tests/dev-public-etag.test.ts @@ -14,7 +14,7 @@ const ETAG = 'W/"90-1234"'; const publicEtags: DevPublicFileEtagIndex = { publicDir: "/public", etagsByRealPath: new Map([["/public/asset.js", ETAG]]), - foldedRealPaths: new Map(), + requestPathRealPaths: new Map(), symlinkTargets: new Map(), hasSymlink: false, viteUsesStatLookup: false, @@ -197,7 +197,7 @@ describe("resolveDevPublicIfNoneMatch", () => { ["/public/A.js", 'W/"5-1"'], ["/public/a.js", 'W/"5-2"'], ]), - foldedRealPaths: new Map([["/public/a.js", null]]), + requestPathRealPaths: new Map([["/public/a.js", null]]), symlinkTargets: new Map([["/public/alias", "/public/A.js"]]), hasSymlink: true, viteUsesStatLookup: true, @@ -315,7 +315,7 @@ describe("resolveDevPublicIfNoneMatch", () => { const nestedEtags: DevPublicFileEtagIndex = { publicDir: "/public", etagsByRealPath: new Map([["/public/foo/bar.js", ETAG]]), - foldedRealPaths: new Map(), + requestPathRealPaths: new Map(), symlinkTargets: new Map(), hasSymlink: false, viteUsesStatLookup: false, From 0db664500b471aefc1f53d1b3c2c7355df3c3a1f Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 12:44:44 +0100 Subject: [PATCH 14/23] fix(dev): compose verified public path aliases --- packages/vinext/src/server/dev-public-etag.ts | 197 ++++++++++++------ tests/dev-public-etag-vite.test.ts | 12 ++ tests/dev-public-etag.test.ts | 6 +- 3 files changed, 143 insertions(+), 72 deletions(-) diff --git a/packages/vinext/src/server/dev-public-etag.ts b/packages/vinext/src/server/dev-public-etag.ts index dc766ac402..a433f52a05 100644 --- a/packages/vinext/src/server/dev-public-etag.ts +++ b/packages/vinext/src/server/dev-public-etag.ts @@ -7,7 +7,7 @@ import { normalizePath } from "./normalize-path.js"; export type DevPublicFileEtagIndex = { publicDir: string; etagsByRealPath: Map; - requestPathRealPaths: Map; + requestPathRoot: DevPublicPathNode; symlinkTargets: Map; hasSymlink: boolean; viteUsesStatLookup: boolean; @@ -15,6 +15,13 @@ export type DevPublicFileEtagIndex = { normalizationInsensitive: boolean; }; +export type DevPublicPathNode = { + children: Map; + aliases: Map; + realPath?: string | null; + redirect?: string; +}; + export function createDevPublicFileEtags( externalPublicDir: string, caseInsensitive = detectCaseInsensitiveDirectory(externalPublicDir), @@ -25,7 +32,7 @@ export function createDevPublicFileEtags( const index: DevPublicFileEtagIndex = { publicDir, etagsByRealPath: new Map(), - requestPathRealPaths: new Map(), + requestPathRoot: createPathNode(), symlinkTargets: new Map(), hasSymlink: false, viteUsesStatLookup: false, @@ -172,44 +179,11 @@ export function resolveDevPublicIfNoneMatch( // lets sirv consult the filesystem, which may then accept case aliases. const supportsFoldedLookup = index.viteUsesStatLookup && (index.caseInsensitive || index.normalizationInsensitive); - let realPath = supportsFoldedLookup ? resolveRequestPath(index, filePath) : undefined; - filePath = resolveSymlinkTargets(filePath, index.symlinkTargets, index.caseInsensitive); - realPath ??= supportsFoldedLookup ? resolveRequestPath(index, filePath) : undefined; - + const realPath = resolveRequestPath(index, filePath, supportsFoldedLookup); const etag = index.etagsByRealPath.get(realPath ?? filePath); return etag && matchesIfNoneMatch(ifNoneMatch, etag) ? etag : undefined; } -function resolveSymlinkTargets( - filePath: string, - targets: ReadonlyMap, - caseInsensitive: boolean, -): string { - const seen = new Set(); - while (!seen.has(filePath)) { - seen.add(filePath); - let matchedSource: string | undefined; - const fileSegments = filePath.split("/"); - for (const source of targets.keys()) { - const sourceSegments = source.split("/"); - if (sourceSegments.length > fileSegments.length) continue; - const matches = sourceSegments.every((segment, index) => { - const fileSegment = fileSegments[index]!; - return caseInsensitive - ? asciiCaseKey(fileSegment) === asciiCaseKey(segment) - : fileSegment === segment; - }); - if (matches && (!matchedSource || sourceSegments.length > matchedSource.split("/").length)) { - matchedSource = source; - } - } - if (!matchedSource) break; - const suffix = fileSegments.slice(matchedSource.split("/").length).join("/"); - filePath = path.join(targets.get(matchedSource)!, suffix); - } - return filePath; -} - function isWithinPublicDir(publicDir: string, filePath: string): boolean { const relativePath = path.relative(publicDir, filePath); return ( @@ -233,49 +207,65 @@ function indexFileEtag( requestPath: string, ): void { index.etagsByRealPath.set(realPath, etag); - indexRequestPath(index, requestPath, realPath); - for (const alias of verifiedRequestAliases(index, requestPath, realPath)) { - indexRequestPath(index, alias, realPath); - } + indexRequestPath(index, requestPath, realPath, undefined); } -function indexRequestPath( +function indexSymlinkTarget( index: DevPublicFileEtagIndex, requestPath: string, - realPath: string, + realTarget: string, ): void { - const key = requestPathKey(requestPath, index.caseInsensitive); - const existing = index.requestPathRealPaths.get(key); - if (existing === undefined || existing === realPath) { - index.requestPathRealPaths.set(key, realPath); - } else { - index.requestPathRealPaths.set(key, null); - } + index.symlinkTargets.set(requestPath, realTarget); + indexRequestPath(index, requestPath, undefined, realTarget); } -function indexSymlinkTarget( +function indexRequestPath( index: DevPublicFileEtagIndex, requestPath: string, - realTarget: string, + realPath: string | undefined, + redirect: string | undefined, ): void { - index.symlinkTargets.set(requestPath, realTarget); - for (const alias of verifiedRequestAliases(index, requestPath, realTarget)) { - index.symlinkTargets.set(alias, realTarget); + const relativePath = path.relative(index.publicDir, requestPath); + if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) return; + + const segments = relativePath.split("/"); + const variantsBySegment = verifiedSegmentVariants(index, requestPath, realPath ?? redirect!); + let node = index.requestPathRoot; + for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) { + const segment = segments[segmentIndex]!; + let child = node.children.get(segment); + if (child === null) return; + if (!child) { + child = createPathNode(); + node.children.set(segment, child); + } + if (index.caseInsensitive) { + indexPathAlias(node, asciiCaseKey(segment), child); + } + for (const variant of variantsBySegment[segmentIndex]!) { + indexPathAlias(node, requestPathKey(variant, index.caseInsensitive), child); + } + node = child; + } + + if (realPath) { + if (node.realPath === undefined || node.realPath === realPath) node.realPath = realPath; + else node.realPath = null; + } + if (redirect) { + if (node.redirect === undefined || node.redirect === redirect) node.redirect = redirect; + else node.realPath = null; } } -function verifiedRequestAliases( +function verifiedSegmentVariants( index: DevPublicFileEtagIndex, requestPath: string, realPath: string, -): string[] { +): string[][] { const relativePath = path.relative(index.publicDir, requestPath); - if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) return []; - const segments = relativePath.split("/"); - const aliases: string[] = []; - for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) { - const segment = segments[segmentIndex]!; + return segments.map((segment, segmentIndex) => { const candidates = new Set(); if (index.caseInsensitive) { candidates.add(segment.toUpperCase()); @@ -291,15 +281,23 @@ function verifiedRequestAliases( candidates.add(candidate.toLowerCase()); } } + + const verified: string[] = []; for (const candidate of candidates) { - if (candidate === segment) continue; + if (candidate === segment || asciiCaseKey(candidate) === asciiCaseKey(segment)) continue; const aliasSegments = segments.slice(); aliasSegments[segmentIndex] = candidate; const alias = path.join(index.publicDir, aliasSegments.join("/")); - if (resolvesToSamePath(realPath, alias)) aliases.push(alias); + if (resolvesToSamePath(realPath, alias)) verified.push(candidate); } - } - return aliases; + return verified; + }); +} + +function indexPathAlias(parent: DevPublicPathNode, key: string, child: DevPublicPathNode): void { + const existing = parent.aliases.get(key); + if (existing === undefined || existing === child) parent.aliases.set(key, child); + else parent.aliases.set(key, null); } function requestPathsForRealPath( @@ -320,11 +318,72 @@ function requestPathsForRealPath( function resolveRequestPath( index: DevPublicFileEtagIndex, requestPath: string, + useAliases: boolean, ): string | undefined { - const realPath = index.requestPathRealPaths.get( - requestPathKey(requestPath, index.caseInsensitive), - ); - return realPath ?? undefined; + const relativePath = path.relative(index.publicDir, requestPath); + if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) return undefined; + return resolvePathSegments(index, relativePath.split("/"), useAliases, new Set()); +} + +function resolvePathSegments( + index: DevPublicFileEtagIndex, + segments: string[], + useAliases: boolean, + seenRedirects: Set, +): string | undefined { + let node = index.requestPathRoot; + for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) { + const segment = segments[segmentIndex]!; + const exact = node.children.get(segment); + const child = + exact === undefined && useAliases + ? node.aliases.get(requestPathKey(segment, index.caseInsensitive)) + : exact; + if (!child) { + return node.redirect + ? resolveRedirect( + index, + node.redirect, + segments.slice(segmentIndex), + useAliases, + seenRedirects, + ) + : undefined; + } + node = child; + } + if (typeof node.realPath === "string") return node.realPath; + return node.redirect + ? resolveRedirect(index, node.redirect, [], useAliases, seenRedirects) + : undefined; +} + +function resolveRedirect( + index: DevPublicFileEtagIndex, + redirect: string, + remaining: string[], + useAliases: boolean, + seenRedirects: Set, +): string | undefined { + const marker = redirect + "\0" + remaining.join("/"); + if (seenRedirects.has(marker)) return undefined; + seenRedirects.add(marker); + + const relativeTarget = path.relative(index.publicDir, redirect); + if (!relativeTarget.startsWith("..") && !path.isAbsolute(relativeTarget)) { + return resolvePathSegments( + index, + [...relativeTarget.split("/"), ...remaining], + useAliases, + seenRedirects, + ); + } + const physicalPath = path.join(redirect, remaining.join("/")); + return index.etagsByRealPath.has(physicalPath) ? physicalPath : undefined; +} + +function createPathNode(): DevPublicPathNode { + return { children: new Map(), aliases: new Map() }; } function detectCaseInsensitiveDirectory(externalDir: string): boolean { diff --git a/tests/dev-public-etag-vite.test.ts b/tests/dev-public-etag-vite.test.ts index 1ff0d81822..ab4f11c0c2 100644 --- a/tests/dev-public-etag-vite.test.ts +++ b/tests/dev-public-etag-vite.test.ts @@ -77,6 +77,8 @@ describe("dev public ETag Vite configuration", () => { fs.writeFileSync(unicodeFile, "unicode"); fs.writeFileSync(sharpSFile, "sharp-s"); fs.writeFileSync(dotlessIFile, "dotless-i"); + fs.mkdirSync(path.join(publicDir, "Straße")); + fs.writeFileSync(path.join(publicDir, "Straße", "Maße.js"), "nested-sharp-s"); fs.symlinkSync("missing", path.join(publicDir, "0-broken")); const lowerCaseFile = path.join(publicDir, "mixedCase.js"); @@ -93,6 +95,16 @@ describe("dev public ETag Vite configuration", () => { ).status, ).toBe(304); + const nestedEtag = (await fetch(`${baseUrl}/Stra%C3%9Fe/Ma%C3%9Fe.js`)).headers.get("etag"); + expect(nestedEtag).toMatch(/^W\//); + expect( + ( + await fetch(`${baseUrl}/STRASSE/MASSE.js`, { + headers: { "If-None-Match": nestedEtag!.replace(/^W\//, "") }, + }) + ).status, + ).toBe(304); + const dotlessIEtag = (await fetch(`${baseUrl}/%C4%B1.js`)).headers.get("etag"); expect(dotlessIEtag).toMatch(/^W\//); const dottedFallback = await fetch(`${baseUrl}/i.js`, { diff --git a/tests/dev-public-etag.test.ts b/tests/dev-public-etag.test.ts index 1135e7fafb..7d9c314482 100644 --- a/tests/dev-public-etag.test.ts +++ b/tests/dev-public-etag.test.ts @@ -14,7 +14,7 @@ const ETAG = 'W/"90-1234"'; const publicEtags: DevPublicFileEtagIndex = { publicDir: "/public", etagsByRealPath: new Map([["/public/asset.js", ETAG]]), - requestPathRealPaths: new Map(), + requestPathRoot: { children: new Map(), aliases: new Map() }, symlinkTargets: new Map(), hasSymlink: false, viteUsesStatLookup: false, @@ -197,7 +197,7 @@ describe("resolveDevPublicIfNoneMatch", () => { ["/public/A.js", 'W/"5-1"'], ["/public/a.js", 'W/"5-2"'], ]), - requestPathRealPaths: new Map([["/public/a.js", null]]), + requestPathRoot: { children: new Map(), aliases: new Map([["a.js", null]]) }, symlinkTargets: new Map([["/public/alias", "/public/A.js"]]), hasSymlink: true, viteUsesStatLookup: true, @@ -315,7 +315,7 @@ describe("resolveDevPublicIfNoneMatch", () => { const nestedEtags: DevPublicFileEtagIndex = { publicDir: "/public", etagsByRealPath: new Map([["/public/foo/bar.js", ETAG]]), - requestPathRealPaths: new Map(), + requestPathRoot: { children: new Map(), aliases: new Map() }, symlinkTargets: new Map(), hasSymlink: false, viteUsesStatLookup: false, From 99babf412876fa7109d9d2105854bf15c62056a4 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 12:49:05 +0100 Subject: [PATCH 15/23] fix(dev): resolve Unicode public path classes --- packages/vinext/src/server/dev-public-etag.ts | 138 +++++++++++------- tests/dev-public-etag-vite.test.ts | 12 ++ tests/dev-public-etag.test.ts | 10 +- 3 files changed, 105 insertions(+), 55 deletions(-) diff --git a/packages/vinext/src/server/dev-public-etag.ts b/packages/vinext/src/server/dev-public-etag.ts index a433f52a05..17b033c66c 100644 --- a/packages/vinext/src/server/dev-public-etag.ts +++ b/packages/vinext/src/server/dev-public-etag.ts @@ -17,11 +17,26 @@ export type DevPublicFileEtagIndex = { export type DevPublicPathNode = { children: Map; - aliases: Map; + asciiAliases: Map; + unicodeAliases: Map; realPath?: string | null; redirect?: string; }; +type DevPublicAliasCandidate = { + segment: string; + node: DevPublicPathNode; +}; + +const unicodeBaseCollator = new Intl.Collator("und", { + usage: "search", + sensitivity: "base", +}); +const unicodeAccentCollator = new Intl.Collator("und", { + usage: "search", + sensitivity: "accent", +}); + export function createDevPublicFileEtags( externalPublicDir: string, caseInsensitive = detectCaseInsensitiveDirectory(externalPublicDir), @@ -229,10 +244,8 @@ function indexRequestPath( if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) return; const segments = relativePath.split("/"); - const variantsBySegment = verifiedSegmentVariants(index, requestPath, realPath ?? redirect!); let node = index.requestPathRoot; - for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) { - const segment = segments[segmentIndex]!; + for (const segment of segments) { let child = node.children.get(segment); if (child === null) return; if (!child) { @@ -240,10 +253,13 @@ function indexRequestPath( node.children.set(segment, child); } if (index.caseInsensitive) { - indexPathAlias(node, asciiCaseKey(segment), child); + indexAsciiAlias(node, asciiCaseKey(segment), child); } - for (const variant of variantsBySegment[segmentIndex]!) { - indexPathAlias(node, requestPathKey(variant, index.caseInsensitive), child); + if (index.caseInsensitive || index.normalizationInsensitive) { + indexUnicodeAlias(node, unicodeBucketKey(segment, index.normalizationInsensitive), { + segment, + node: child, + }); } node = child; } @@ -258,46 +274,33 @@ function indexRequestPath( } } -function verifiedSegmentVariants( - index: DevPublicFileEtagIndex, - requestPath: string, - realPath: string, -): string[][] { - const relativePath = path.relative(index.publicDir, requestPath); - const segments = relativePath.split("/"); - return segments.map((segment, segmentIndex) => { - const candidates = new Set(); - if (index.caseInsensitive) { - candidates.add(segment.toUpperCase()); - candidates.add(segment.toLowerCase()); - } - if (index.normalizationInsensitive) { - candidates.add(segment.normalize("NFD")); - candidates.add(segment.normalize("NFC")); - } - for (const candidate of candidates) { - if (index.caseInsensitive) { - candidates.add(candidate.toUpperCase()); - candidates.add(candidate.toLowerCase()); - } - } - - const verified: string[] = []; - for (const candidate of candidates) { - if (candidate === segment || asciiCaseKey(candidate) === asciiCaseKey(segment)) continue; - const aliasSegments = segments.slice(); - aliasSegments[segmentIndex] = candidate; - const alias = path.join(index.publicDir, aliasSegments.join("/")); - if (resolvesToSamePath(realPath, alias)) verified.push(candidate); - } - return verified; - }); +function indexAsciiAlias(parent: DevPublicPathNode, key: string, child: DevPublicPathNode): void { + const existing = parent.asciiAliases.get(key); + if (existing === undefined || existing === child) parent.asciiAliases.set(key, child); + else parent.asciiAliases.set(key, null); } -function indexPathAlias(parent: DevPublicPathNode, key: string, child: DevPublicPathNode): void { - const existing = parent.aliases.get(key); - if (existing === undefined || existing === child) parent.aliases.set(key, child); - else parent.aliases.set(key, null); +function indexUnicodeAlias( + parent: DevPublicPathNode, + key: string, + candidate: DevPublicAliasCandidate, +): void { + const existing = parent.unicodeAliases.get(key); + if (existing === null) return; + if (!existing) { + parent.unicodeAliases.set(key, [candidate]); + return; + } + if ( + existing.some((entry) => entry.node === candidate.node && entry.segment === candidate.segment) + ) { + return; + } + if (existing.length === 8) { + parent.unicodeAliases.set(key, null); + return; + } + existing.push(candidate); } function requestPathsForRealPath( @@ -335,10 +338,11 @@ function resolvePathSegments( for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) { const segment = segments[segmentIndex]!; const exact = node.children.get(segment); - const child = - exact === undefined && useAliases - ? node.aliases.get(requestPathKey(segment, index.caseInsensitive)) - : exact; + let child = exact; + if (child === undefined && useAliases) { + if (index.caseInsensitive) child = node.asciiAliases.get(asciiCaseKey(segment)); + if (child === undefined) child = resolveUnicodeAlias(index, node, segment); + } if (!child) { return node.redirect ? resolveRedirect( @@ -358,6 +362,36 @@ function resolvePathSegments( : undefined; } +function resolveUnicodeAlias( + index: DevPublicFileEtagIndex, + parent: DevPublicPathNode, + requestedSegment: string, +): DevPublicPathNode | null | undefined { + const candidates = parent.unicodeAliases.get( + unicodeBucketKey(requestedSegment, index.normalizationInsensitive), + ); + if (!candidates) return candidates; + + let matched: DevPublicPathNode | undefined; + for (const candidate of candidates) { + if (!segmentsEquivalent(index, requestedSegment, candidate.segment)) continue; + if (matched && matched !== candidate.node) return null; + matched = candidate.node; + } + return matched; +} + +function segmentsEquivalent(index: DevPublicFileEtagIndex, left: string, right: string): boolean { + const normalizedLeft = index.normalizationInsensitive ? left.normalize("NFD") : left; + const normalizedRight = index.normalizationInsensitive ? right.normalize("NFD") : right; + if (!index.caseInsensitive) return normalizedLeft === normalizedRight; + + return ( + unicodeAccentCollator.compare(normalizedLeft.toUpperCase(), normalizedRight.toUpperCase()) === + 0 && unicodeBaseCollator.compare(normalizedLeft, normalizedRight) === 0 + ); +} + function resolveRedirect( index: DevPublicFileEtagIndex, redirect: string, @@ -383,7 +417,7 @@ function resolveRedirect( } function createPathNode(): DevPublicPathNode { - return { children: new Map(), aliases: new Map() }; + return { children: new Map(), asciiAliases: new Map(), unicodeAliases: new Map() }; } function detectCaseInsensitiveDirectory(externalDir: string): boolean { @@ -474,8 +508,8 @@ function resolvesToSamePath(left: string, right: string): boolean { } } -function requestPathKey(value: string, caseInsensitive: boolean): string { - return caseInsensitive ? asciiCaseKey(value) : value; +function unicodeBucketKey(value: string, normalizationInsensitive: boolean): string { + return (normalizationInsensitive ? value.normalize("NFD") : value).toUpperCase(); } function asciiCaseKey(value: string): string { diff --git a/tests/dev-public-etag-vite.test.ts b/tests/dev-public-etag-vite.test.ts index ab4f11c0c2..d4dfa7ad54 100644 --- a/tests/dev-public-etag-vite.test.ts +++ b/tests/dev-public-etag-vite.test.ts @@ -73,10 +73,12 @@ describe("dev public ETag Vite configuration", () => { const unicodeFile = path.join(publicDir, "Éclair.js"); const sharpSFile = path.join(publicDir, "Straße.js"); const dotlessIFile = path.join(publicDir, "ı.js"); + const sigmaFile = path.join(publicDir, "Σ.js"); fs.writeFileSync(mixedCaseFile, "mixed"); fs.writeFileSync(unicodeFile, "unicode"); fs.writeFileSync(sharpSFile, "sharp-s"); fs.writeFileSync(dotlessIFile, "dotless-i"); + fs.writeFileSync(sigmaFile, "sigma"); fs.mkdirSync(path.join(publicDir, "Straße")); fs.writeFileSync(path.join(publicDir, "Straße", "Maße.js"), "nested-sharp-s"); fs.symlinkSync("missing", path.join(publicDir, "0-broken")); @@ -95,6 +97,16 @@ describe("dev public ETag Vite configuration", () => { ).status, ).toBe(304); + const sigmaEtag = (await fetch(`${baseUrl}/%CE%A3.js`)).headers.get("etag"); + expect(sigmaEtag).toMatch(/^W\//); + expect( + ( + await fetch(`${baseUrl}/%CF%82.js`, { + headers: { "If-None-Match": sigmaEtag!.replace(/^W\//, "") }, + }) + ).status, + ).toBe(304); + const nestedEtag = (await fetch(`${baseUrl}/Stra%C3%9Fe/Ma%C3%9Fe.js`)).headers.get("etag"); expect(nestedEtag).toMatch(/^W\//); expect( diff --git a/tests/dev-public-etag.test.ts b/tests/dev-public-etag.test.ts index 7d9c314482..2ced4feae4 100644 --- a/tests/dev-public-etag.test.ts +++ b/tests/dev-public-etag.test.ts @@ -14,7 +14,7 @@ const ETAG = 'W/"90-1234"'; const publicEtags: DevPublicFileEtagIndex = { publicDir: "/public", etagsByRealPath: new Map([["/public/asset.js", ETAG]]), - requestPathRoot: { children: new Map(), aliases: new Map() }, + requestPathRoot: { children: new Map(), asciiAliases: new Map(), unicodeAliases: new Map() }, symlinkTargets: new Map(), hasSymlink: false, viteUsesStatLookup: false, @@ -197,7 +197,11 @@ describe("resolveDevPublicIfNoneMatch", () => { ["/public/A.js", 'W/"5-1"'], ["/public/a.js", 'W/"5-2"'], ]), - requestPathRoot: { children: new Map(), aliases: new Map([["a.js", null]]) }, + requestPathRoot: { + children: new Map(), + asciiAliases: new Map([["a.js", null]]), + unicodeAliases: new Map(), + }, symlinkTargets: new Map([["/public/alias", "/public/A.js"]]), hasSymlink: true, viteUsesStatLookup: true, @@ -315,7 +319,7 @@ describe("resolveDevPublicIfNoneMatch", () => { const nestedEtags: DevPublicFileEtagIndex = { publicDir: "/public", etagsByRealPath: new Map([["/public/foo/bar.js", ETAG]]), - requestPathRoot: { children: new Map(), aliases: new Map() }, + requestPathRoot: { children: new Map(), asciiAliases: new Map(), unicodeAliases: new Map() }, symlinkTargets: new Map(), hasSymlink: false, viteUsesStatLookup: false, From bdb00b8a705eb933c49abcd43a843bdd998efdb7 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 12:53:06 +0100 Subject: [PATCH 16/23] fix(dev): use Unicode simple case matching --- packages/vinext/src/server/dev-public-etag.ts | 37 ++++++++++++++----- tests/dev-public-etag-vite.test.ts | 12 ++++++ 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/packages/vinext/src/server/dev-public-etag.ts b/packages/vinext/src/server/dev-public-etag.ts index 17b033c66c..fd3e47257b 100644 --- a/packages/vinext/src/server/dev-public-etag.ts +++ b/packages/vinext/src/server/dev-public-etag.ts @@ -26,12 +26,9 @@ export type DevPublicPathNode = { type DevPublicAliasCandidate = { segment: string; node: DevPublicPathNode; + simpleCasePattern: RegExp; }; -const unicodeBaseCollator = new Intl.Collator("und", { - usage: "search", - sensitivity: "base", -}); const unicodeAccentCollator = new Intl.Collator("und", { usage: "search", sensitivity: "accent", @@ -259,6 +256,10 @@ function indexRequestPath( indexUnicodeAlias(node, unicodeBucketKey(segment, index.normalizationInsensitive), { segment, node: child, + simpleCasePattern: new RegExp( + `^(?:${escapeRegex(index.normalizationInsensitive ? segment.normalize("NFD") : segment)})$`, + "iu", + ), }); } node = child; @@ -374,24 +375,40 @@ function resolveUnicodeAlias( let matched: DevPublicPathNode | undefined; for (const candidate of candidates) { - if (!segmentsEquivalent(index, requestedSegment, candidate.segment)) continue; + if (!segmentsEquivalent(index, requestedSegment, candidate)) continue; if (matched && matched !== candidate.node) return null; matched = candidate.node; } return matched; } -function segmentsEquivalent(index: DevPublicFileEtagIndex, left: string, right: string): boolean { +function segmentsEquivalent( + index: DevPublicFileEtagIndex, + left: string, + candidate: DevPublicAliasCandidate, +): boolean { const normalizedLeft = index.normalizationInsensitive ? left.normalize("NFD") : left; - const normalizedRight = index.normalizationInsensitive ? right.normalize("NFD") : right; + const normalizedRight = index.normalizationInsensitive + ? candidate.segment.normalize("NFD") + : candidate.segment; if (!index.caseInsensitive) return normalizedLeft === normalizedRight; - + if (candidate.simpleCasePattern.test(normalizedLeft)) return true; + if (!hasLengthChangingUppercase(normalizedLeft) && !hasLengthChangingUppercase(normalizedRight)) { + return false; + } return ( - unicodeAccentCollator.compare(normalizedLeft.toUpperCase(), normalizedRight.toUpperCase()) === - 0 && unicodeBaseCollator.compare(normalizedLeft, normalizedRight) === 0 + unicodeAccentCollator.compare(normalizedLeft.toUpperCase(), normalizedRight.toUpperCase()) === 0 ); } +function hasLengthChangingUppercase(value: string): boolean { + return Array.from(value.toUpperCase()).length !== Array.from(value).length; +} + +function escapeRegex(value: string): string { + return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&"); +} + function resolveRedirect( index: DevPublicFileEtagIndex, redirect: string, diff --git a/tests/dev-public-etag-vite.test.ts b/tests/dev-public-etag-vite.test.ts index d4dfa7ad54..5060ff5089 100644 --- a/tests/dev-public-etag-vite.test.ts +++ b/tests/dev-public-etag-vite.test.ts @@ -74,11 +74,13 @@ describe("dev public ETag Vite configuration", () => { const sharpSFile = path.join(publicDir, "Straße.js"); const dotlessIFile = path.join(publicDir, "ı.js"); const sigmaFile = path.join(publicDir, "Σ.js"); + const ypogegrammeniFile = path.join(publicDir, "ͅ.js"); fs.writeFileSync(mixedCaseFile, "mixed"); fs.writeFileSync(unicodeFile, "unicode"); fs.writeFileSync(sharpSFile, "sharp-s"); fs.writeFileSync(dotlessIFile, "dotless-i"); fs.writeFileSync(sigmaFile, "sigma"); + fs.writeFileSync(ypogegrammeniFile, "ypogegrammeni"); fs.mkdirSync(path.join(publicDir, "Straße")); fs.writeFileSync(path.join(publicDir, "Straße", "Maße.js"), "nested-sharp-s"); fs.symlinkSync("missing", path.join(publicDir, "0-broken")); @@ -97,6 +99,16 @@ describe("dev public ETag Vite configuration", () => { ).status, ).toBe(304); + const ypogegrammeniEtag = (await fetch(`${baseUrl}/%CD%85.js`)).headers.get("etag"); + expect(ypogegrammeniEtag).toMatch(/^W\//); + expect( + ( + await fetch(`${baseUrl}/%CE%99.js`, { + headers: { "If-None-Match": ypogegrammeniEtag!.replace(/^W\//, "") }, + }) + ).status, + ).toBe(304); + const sigmaEtag = (await fetch(`${baseUrl}/%CE%A3.js`)).headers.get("etag"); expect(sigmaEtag).toMatch(/^W\//); expect( From a8df55124ab4dfc064258085d3d060ac2914ae0b Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 12:56:11 +0100 Subject: [PATCH 17/23] fix(dev): match Unicode expansion tokens --- packages/vinext/src/server/dev-public-etag.ts | 37 ++++++++++++++----- tests/dev-public-etag-vite.test.ts | 12 ++++++ 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/packages/vinext/src/server/dev-public-etag.ts b/packages/vinext/src/server/dev-public-etag.ts index fd3e47257b..069e9eed64 100644 --- a/packages/vinext/src/server/dev-public-etag.ts +++ b/packages/vinext/src/server/dev-public-etag.ts @@ -27,12 +27,10 @@ type DevPublicAliasCandidate = { segment: string; node: DevPublicPathNode; simpleCasePattern: RegExp; + expandedCaseTokens: string[]; }; -const unicodeAccentCollator = new Intl.Collator("und", { - usage: "search", - sensitivity: "accent", -}); +const simpleCasePatterns = new Map(); export function createDevPublicFileEtags( externalPublicDir: string, @@ -260,6 +258,9 @@ function indexRequestPath( `^(?:${escapeRegex(index.normalizationInsensitive ? segment.normalize("NFD") : segment)})$`, "iu", ), + expandedCaseTokens: expandCaseTokens( + index.normalizationInsensitive ? segment.normalize("NFD") : segment, + ), }); } node = child; @@ -393,16 +394,32 @@ function segmentsEquivalent( : candidate.segment; if (!index.caseInsensitive) return normalizedLeft === normalizedRight; if (candidate.simpleCasePattern.test(normalizedLeft)) return true; - if (!hasLengthChangingUppercase(normalizedLeft) && !hasLengthChangingUppercase(normalizedRight)) { - return false; - } + const leftTokens = expandCaseTokens(normalizedLeft); return ( - unicodeAccentCollator.compare(normalizedLeft.toUpperCase(), normalizedRight.toUpperCase()) === 0 + leftTokens.length === candidate.expandedCaseTokens.length && + leftTokens.every((token, index) => + simpleCaseEquivalent(token, candidate.expandedCaseTokens[index]!), + ) ); } -function hasLengthChangingUppercase(value: string): boolean { - return Array.from(value.toUpperCase()).length !== Array.from(value).length; +function expandCaseTokens(value: string): string[] { + const tokens: string[] = []; + for (const char of value) { + const uppercase = Array.from(char.toUpperCase()); + if (uppercase.length > 1) tokens.push(...uppercase); + else tokens.push(char); + } + return tokens; +} + +function simpleCaseEquivalent(left: string, right: string): boolean { + let pattern = simpleCasePatterns.get(right); + if (!pattern) { + pattern = new RegExp(`^(?:${escapeRegex(right)})$`, "iu"); + simpleCasePatterns.set(right, pattern); + } + return pattern.test(left); } function escapeRegex(value: string): string { diff --git a/tests/dev-public-etag-vite.test.ts b/tests/dev-public-etag-vite.test.ts index 5060ff5089..19592c2100 100644 --- a/tests/dev-public-etag-vite.test.ts +++ b/tests/dev-public-etag-vite.test.ts @@ -75,12 +75,14 @@ describe("dev public ETag Vite configuration", () => { const dotlessIFile = path.join(publicDir, "ı.js"); const sigmaFile = path.join(publicDir, "Σ.js"); const ypogegrammeniFile = path.join(publicDir, "ͅ.js"); + const mixedExpansionFile = path.join(publicDir, "ßı.js"); fs.writeFileSync(mixedCaseFile, "mixed"); fs.writeFileSync(unicodeFile, "unicode"); fs.writeFileSync(sharpSFile, "sharp-s"); fs.writeFileSync(dotlessIFile, "dotless-i"); fs.writeFileSync(sigmaFile, "sigma"); fs.writeFileSync(ypogegrammeniFile, "ypogegrammeni"); + fs.writeFileSync(mixedExpansionFile, "mixed-expansion"); fs.mkdirSync(path.join(publicDir, "Straße")); fs.writeFileSync(path.join(publicDir, "Straße", "Maße.js"), "nested-sharp-s"); fs.symlinkSync("missing", path.join(publicDir, "0-broken")); @@ -99,6 +101,16 @@ describe("dev public ETag Vite configuration", () => { ).status, ).toBe(304); + const mixedExpansionEtag = (await fetch(`${baseUrl}/%C3%9F%C4%B1.js`)).headers.get("etag"); + expect(mixedExpansionEtag).toMatch(/^W\//); + const mixedExpansionFallback = await fetch(`${baseUrl}/SSi.js`, { + headers: { "If-None-Match": mixedExpansionEtag!.replace(/^W\//, "") }, + }); + expect(mixedExpansionFallback.status).toBe(200); + expect(mixedExpansionFallback.headers.get("x-fallback-if-none-match")).toBe( + mixedExpansionEtag!.replace(/^W\//, ""), + ); + const ypogegrammeniEtag = (await fetch(`${baseUrl}/%CD%85.js`)).headers.get("etag"); expect(ypogegrammeniEtag).toMatch(/^W\//); expect( From 4175bc8eff90f45f8d4da75dd7e5fec350d559fe Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 13:05:43 +0100 Subject: [PATCH 18/23] fix(dev): keep public path node private --- packages/vinext/src/server/dev-public-etag.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vinext/src/server/dev-public-etag.ts b/packages/vinext/src/server/dev-public-etag.ts index 069e9eed64..3425dd9735 100644 --- a/packages/vinext/src/server/dev-public-etag.ts +++ b/packages/vinext/src/server/dev-public-etag.ts @@ -15,7 +15,7 @@ export type DevPublicFileEtagIndex = { normalizationInsensitive: boolean; }; -export type DevPublicPathNode = { +type DevPublicPathNode = { children: Map; asciiAliases: Map; unicodeAliases: Map; From 26f315db8f0c39a493fcee7150eb7a492eb5b180 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 13:19:22 +0100 Subject: [PATCH 19/23] fix(dev): resolve public-root symlink redirects --- packages/vinext/src/server/dev-public-etag.ts | 2 +- tests/dev-public-etag.test.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/server/dev-public-etag.ts b/packages/vinext/src/server/dev-public-etag.ts index 3425dd9735..d16b9a438f 100644 --- a/packages/vinext/src/server/dev-public-etag.ts +++ b/packages/vinext/src/server/dev-public-etag.ts @@ -441,7 +441,7 @@ function resolveRedirect( if (!relativeTarget.startsWith("..") && !path.isAbsolute(relativeTarget)) { return resolvePathSegments( index, - [...relativeTarget.split("/"), ...remaining], + [...(relativeTarget ? relativeTarget.split("/") : []), ...remaining], useAliases, seenRedirects, ); diff --git a/tests/dev-public-etag.test.ts b/tests/dev-public-etag.test.ts index 2ced4feae4..2a3667c647 100644 --- a/tests/dev-public-etag.test.ts +++ b/tests/dev-public-etag.test.ts @@ -97,7 +97,9 @@ describe("resolveDevPublicIfNoneMatch", () => { it.runIf(process.platform !== "win32")( "preserves directory symlink aliases without following cycles", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-alias-")); + const root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-alias-")), + ); const publicDir = path.join(root, "public"); try { fs.mkdirSync(path.join(publicDir, "a"), { recursive: true }); From d0d7dbbac50fb4cedb6f7f506494aabd0e40cb7f Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 15:24:52 +0100 Subject: [PATCH 20/23] refactor(dev): leave public etag handling to Vite --- packages/vinext/src/index.ts | 42 -- packages/vinext/src/server/dev-public-etag.ts | 551 ------------------ tests/dev-public-etag-vite.test.ts | 363 ------------ tests/dev-public-etag.test.ts | 335 ----------- tests/e2e/pages-router/script.spec.ts | 37 -- 5 files changed, 1328 deletions(-) delete mode 100644 packages/vinext/src/server/dev-public-etag.ts delete mode 100644 tests/dev-public-etag-vite.test.ts delete mode 100644 tests/dev-public-etag.test.ts diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index ef43d65dcc..919ad9b1be 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -242,11 +242,6 @@ import { createWasmModuleImportPlugin } from "./plugins/wasm-module-import.js"; import { getTypeofWindowReplacement, replaceTypeofWindow } from "./plugins/typeof-window.js"; import { hasMdxFiles } from "./utils/mdx-scan.js"; import { scanPublicFileRoutes } from "./utils/public-routes.js"; -import { - createDevPublicFileEtags, - resolveDevPublicIfNoneMatch, - updateDevPublicFileEtag, -} from "./server/dev-public-etag.js"; import type { Options as VitePluginReactOptions } from "@vitejs/plugin-react"; import MagicString from "magic-string"; import path, { toSlash } from "pathslash"; @@ -4262,48 +4257,11 @@ export const loadServerActionClient = ${ }, configureServer(server: ViteDevServer) { - const devPublicDir = server.config.publicDir || undefined; - let devPublicFileEtags = devPublicDir ? createDevPublicFileEtags(devPublicDir) : undefined; - server.middlewares.use((req, _res, next) => { req.__vinextOriginalEncodedUrl ??= req.url; - if (!devPublicFileEtags) { - next(); - return; - } - const normalizedIfNoneMatch = resolveDevPublicIfNoneMatch( - req.method, - req.url, - req.headers["if-none-match"], - devPublicFileEtags, - nextConfig?.basePath, - ); - if (normalizedIfNoneMatch) { - // sirv compares If-None-Match to its generated weak ETag as an - // exact string. Resolve RFC weak/list/wildcard matching here, then - // give sirv the exact value so its normal 304 path handles the body. - req.headers["if-none-match"] = normalizedIfNoneMatch; - } next(); }); - const updateDevPublicEtag = (filePath: string) => { - if (!devPublicFileEtags || !devPublicDir) return; - if (!updateDevPublicFileEtag(devPublicFileEtags, filePath)) { - devPublicFileEtags = createDevPublicFileEtags( - devPublicDir, - undefined, - undefined, - devPublicFileEtags.viteUsesStatLookup, - ); - } - }; - server.watcher.on("add", updateDevPublicEtag); - server.watcher.on("change", updateDevPublicEtag); - server.watcher.on("unlink", updateDevPublicEtag); - server.watcher.on("addDir", updateDevPublicEtag); - server.watcher.on("unlinkDir", updateDevPublicEtag); - // Watch route files for additions/removals to invalidate route cache. const pageExtensions = fileMatcher.extensionRegex; diff --git a/packages/vinext/src/server/dev-public-etag.ts b/packages/vinext/src/server/dev-public-etag.ts deleted file mode 100644 index d16b9a438f..0000000000 --- a/packages/vinext/src/server/dev-public-etag.ts +++ /dev/null @@ -1,551 +0,0 @@ -import fs from "node:fs"; -import path, { toSlash } from "pathslash"; -import { hasBasePath, stripBasePath } from "../utils/base-path.js"; -import { matchesIfNoneMatch } from "./http-conditional.js"; -import { normalizePath } from "./normalize-path.js"; - -export type DevPublicFileEtagIndex = { - publicDir: string; - etagsByRealPath: Map; - requestPathRoot: DevPublicPathNode; - symlinkTargets: Map; - hasSymlink: boolean; - viteUsesStatLookup: boolean; - caseInsensitive: boolean; - normalizationInsensitive: boolean; -}; - -type DevPublicPathNode = { - children: Map; - asciiAliases: Map; - unicodeAliases: Map; - realPath?: string | null; - redirect?: string; -}; - -type DevPublicAliasCandidate = { - segment: string; - node: DevPublicPathNode; - simpleCasePattern: RegExp; - expandedCaseTokens: string[]; -}; - -const simpleCasePatterns = new Map(); - -export function createDevPublicFileEtags( - externalPublicDir: string, - caseInsensitive = detectCaseInsensitiveDirectory(externalPublicDir), - normalizationInsensitive = detectNormalizationInsensitiveDirectory(externalPublicDir), - viteUsesStatLookup?: boolean, -): DevPublicFileEtagIndex { - const publicDir = toSlash(externalPublicDir); - const index: DevPublicFileEtagIndex = { - publicDir, - etagsByRealPath: new Map(), - requestPathRoot: createPathNode(), - symlinkTargets: new Map(), - hasSymlink: false, - viteUsesStatLookup: false, - caseInsensitive, - normalizationInsensitive, - }; - - const walk = (dir: string, realAncestors: ReadonlySet): void => { - let realDir: string; - try { - realDir = toSlash(fs.realpathSync.native(dir)); - } catch { - return; - } - if (realAncestors.has(realDir)) return; - const nextAncestors = new Set(realAncestors).add(realDir); - - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return; - } - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isSymbolicLink()) { - index.hasSymlink = true; - let realTarget: string; - let stats: fs.Stats; - try { - realTarget = toSlash(fs.realpathSync.native(fullPath)); - stats = fs.statSync(fullPath); - } catch { - continue; - } - indexSymlinkTarget(index, fullPath, realTarget); - if (stats.isDirectory()) { - walk(fullPath, nextAncestors); - } else if (stats.isFile()) { - indexFileEtag(index, realTarget, etagForStats(stats), fullPath); - } - continue; - } - if (entry.isDirectory()) { - walk(fullPath, nextAncestors); - continue; - } - if (!entry.isFile()) continue; - - try { - const realPath = toSlash(fs.realpathSync.native(fullPath)); - indexFileEtag(index, realPath, etagForStats(fs.statSync(fullPath)), fullPath); - } catch { - // Ignore entries removed during the startup scan. - } - } - }; - - if (fs.existsSync(publicDir)) { - const realPublicDir = toSlash(fs.realpathSync.native(publicDir)); - if (realPublicDir !== publicDir) index.symlinkTargets.set(publicDir, realPublicDir); - walk(publicDir, new Set()); - } - index.viteUsesStatLookup = viteUsesStatLookup ?? index.hasSymlink; - return index; -} - -/** - * Update a regular public file without rescanning the directory tree. - * Returns false when a structural change (directory, symlink, or removal) - * requires the caller to rebuild the index off the request path. - */ -export function updateDevPublicFileEtag( - index: DevPublicFileEtagIndex, - externalFilePath: string, -): boolean { - let filePath = toSlash(externalFilePath); - if (!isWithinPublicDir(index.publicDir, filePath)) { - try { - filePath = toSlash(fs.realpathSync.native(filePath)); - } catch { - // A removal behind a symlink requires a structural rebuild only when - // the watcher reports it through the public alias, handled above. - } - if ( - ![...index.symlinkTargets.values()].some( - (target) => filePath === target || filePath.startsWith(target + "/"), - ) - ) { - return true; - } - } - - try { - const lstat = fs.lstatSync(filePath); - if (lstat.isSymbolicLink() || !lstat.isFile()) return false; - if ( - (!index.caseInsensitive && pathHasCaseInsensitiveAlias(filePath)) || - (!index.normalizationInsensitive && pathHasNormalizationInsensitiveAlias(filePath)) - ) { - return false; - } - const realPath = toSlash(fs.realpathSync.native(filePath)); - const requestPaths = requestPathsForRealPath(index, filePath, realPath); - for (const requestPath of requestPaths) { - indexFileEtag(index, realPath, etagForStats(fs.statSync(filePath)), requestPath); - } - return true; - } catch { - return false; - } -} - -export function resolveDevPublicIfNoneMatch( - method: string | undefined, - requestUrl: string | undefined, - ifNoneMatch: string | string[] | undefined, - index: DevPublicFileEtagIndex, - basePath = "", -): string | undefined { - if ((method !== "GET" && method !== "HEAD") || typeof ifNoneMatch !== "string") { - return undefined; - } - - const queryIndex = requestUrl?.indexOf("?") ?? -1; - let pathname = (requestUrl ?? "/").slice(0, queryIndex === -1 ? undefined : queryIndex); - if (basePath) { - if (!hasBasePath(pathname, basePath)) return undefined; - pathname = stripBasePath(pathname, basePath); - } - try { - // Vite converts native separators on Windows before applying POSIX path - // normalization. `toSlash` is platform-gated, so a literal backslash - // remains a valid filename character on POSIX. - pathname = normalizePath(toSlash(decodeURI(pathname))); - } catch { - return undefined; - } - - let filePath = path.join(index.publicDir, pathname); - if (!isWithinPublicDir(index.publicDir, filePath)) return undefined; - // Vite normally guards public requests with an exact-spelling file set. It - // disables that optimization when the public tree contains a symlink and - // lets sirv consult the filesystem, which may then accept case aliases. - const supportsFoldedLookup = - index.viteUsesStatLookup && (index.caseInsensitive || index.normalizationInsensitive); - const realPath = resolveRequestPath(index, filePath, supportsFoldedLookup); - const etag = index.etagsByRealPath.get(realPath ?? filePath); - return etag && matchesIfNoneMatch(ifNoneMatch, etag) ? etag : undefined; -} - -function isWithinPublicDir(publicDir: string, filePath: string): boolean { - const relativePath = path.relative(publicDir, filePath); - return ( - relativePath !== "" && - relativePath !== ".." && - !relativePath.startsWith("../") && - !path.isAbsolute(relativePath) - ); -} - -function etagForStats(stats: fs.Stats): string { - // Vite's public-file middleware delegates to sirv, which uses this - // size/mtime weak validator in development. - return `W/"${stats.size}-${stats.mtime.getTime()}"`; -} - -function indexFileEtag( - index: DevPublicFileEtagIndex, - realPath: string, - etag: string, - requestPath: string, -): void { - index.etagsByRealPath.set(realPath, etag); - indexRequestPath(index, requestPath, realPath, undefined); -} - -function indexSymlinkTarget( - index: DevPublicFileEtagIndex, - requestPath: string, - realTarget: string, -): void { - index.symlinkTargets.set(requestPath, realTarget); - indexRequestPath(index, requestPath, undefined, realTarget); -} - -function indexRequestPath( - index: DevPublicFileEtagIndex, - requestPath: string, - realPath: string | undefined, - redirect: string | undefined, -): void { - const relativePath = path.relative(index.publicDir, requestPath); - if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) return; - - const segments = relativePath.split("/"); - let node = index.requestPathRoot; - for (const segment of segments) { - let child = node.children.get(segment); - if (child === null) return; - if (!child) { - child = createPathNode(); - node.children.set(segment, child); - } - if (index.caseInsensitive) { - indexAsciiAlias(node, asciiCaseKey(segment), child); - } - if (index.caseInsensitive || index.normalizationInsensitive) { - indexUnicodeAlias(node, unicodeBucketKey(segment, index.normalizationInsensitive), { - segment, - node: child, - simpleCasePattern: new RegExp( - `^(?:${escapeRegex(index.normalizationInsensitive ? segment.normalize("NFD") : segment)})$`, - "iu", - ), - expandedCaseTokens: expandCaseTokens( - index.normalizationInsensitive ? segment.normalize("NFD") : segment, - ), - }); - } - node = child; - } - - if (realPath) { - if (node.realPath === undefined || node.realPath === realPath) node.realPath = realPath; - else node.realPath = null; - } - if (redirect) { - if (node.redirect === undefined || node.redirect === redirect) node.redirect = redirect; - else node.realPath = null; - } -} - -function indexAsciiAlias(parent: DevPublicPathNode, key: string, child: DevPublicPathNode): void { - const existing = parent.asciiAliases.get(key); - if (existing === undefined || existing === child) parent.asciiAliases.set(key, child); - else parent.asciiAliases.set(key, null); -} - -function indexUnicodeAlias( - parent: DevPublicPathNode, - key: string, - candidate: DevPublicAliasCandidate, -): void { - const existing = parent.unicodeAliases.get(key); - if (existing === null) return; - if (!existing) { - parent.unicodeAliases.set(key, [candidate]); - return; - } - if ( - existing.some((entry) => entry.node === candidate.node && entry.segment === candidate.segment) - ) { - return; - } - if (existing.length === 8) { - parent.unicodeAliases.set(key, null); - return; - } - existing.push(candidate); -} - -function requestPathsForRealPath( - index: DevPublicFileEtagIndex, - watcherPath: string, - realPath: string, -): string[] { - const paths = new Set(); - if (isWithinPublicDir(index.publicDir, watcherPath)) paths.add(watcherPath); - for (const [source, target] of index.symlinkTargets) { - if (realPath === target || realPath.startsWith(target + "/")) { - paths.add(path.join(source, realPath.slice(target.length))); - } - } - return paths.size > 0 ? [...paths] : [realPath]; -} - -function resolveRequestPath( - index: DevPublicFileEtagIndex, - requestPath: string, - useAliases: boolean, -): string | undefined { - const relativePath = path.relative(index.publicDir, requestPath); - if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) return undefined; - return resolvePathSegments(index, relativePath.split("/"), useAliases, new Set()); -} - -function resolvePathSegments( - index: DevPublicFileEtagIndex, - segments: string[], - useAliases: boolean, - seenRedirects: Set, -): string | undefined { - let node = index.requestPathRoot; - for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) { - const segment = segments[segmentIndex]!; - const exact = node.children.get(segment); - let child = exact; - if (child === undefined && useAliases) { - if (index.caseInsensitive) child = node.asciiAliases.get(asciiCaseKey(segment)); - if (child === undefined) child = resolveUnicodeAlias(index, node, segment); - } - if (!child) { - return node.redirect - ? resolveRedirect( - index, - node.redirect, - segments.slice(segmentIndex), - useAliases, - seenRedirects, - ) - : undefined; - } - node = child; - } - if (typeof node.realPath === "string") return node.realPath; - return node.redirect - ? resolveRedirect(index, node.redirect, [], useAliases, seenRedirects) - : undefined; -} - -function resolveUnicodeAlias( - index: DevPublicFileEtagIndex, - parent: DevPublicPathNode, - requestedSegment: string, -): DevPublicPathNode | null | undefined { - const candidates = parent.unicodeAliases.get( - unicodeBucketKey(requestedSegment, index.normalizationInsensitive), - ); - if (!candidates) return candidates; - - let matched: DevPublicPathNode | undefined; - for (const candidate of candidates) { - if (!segmentsEquivalent(index, requestedSegment, candidate)) continue; - if (matched && matched !== candidate.node) return null; - matched = candidate.node; - } - return matched; -} - -function segmentsEquivalent( - index: DevPublicFileEtagIndex, - left: string, - candidate: DevPublicAliasCandidate, -): boolean { - const normalizedLeft = index.normalizationInsensitive ? left.normalize("NFD") : left; - const normalizedRight = index.normalizationInsensitive - ? candidate.segment.normalize("NFD") - : candidate.segment; - if (!index.caseInsensitive) return normalizedLeft === normalizedRight; - if (candidate.simpleCasePattern.test(normalizedLeft)) return true; - const leftTokens = expandCaseTokens(normalizedLeft); - return ( - leftTokens.length === candidate.expandedCaseTokens.length && - leftTokens.every((token, index) => - simpleCaseEquivalent(token, candidate.expandedCaseTokens[index]!), - ) - ); -} - -function expandCaseTokens(value: string): string[] { - const tokens: string[] = []; - for (const char of value) { - const uppercase = Array.from(char.toUpperCase()); - if (uppercase.length > 1) tokens.push(...uppercase); - else tokens.push(char); - } - return tokens; -} - -function simpleCaseEquivalent(left: string, right: string): boolean { - let pattern = simpleCasePatterns.get(right); - if (!pattern) { - pattern = new RegExp(`^(?:${escapeRegex(right)})$`, "iu"); - simpleCasePatterns.set(right, pattern); - } - return pattern.test(left); -} - -function escapeRegex(value: string): string { - return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&"); -} - -function resolveRedirect( - index: DevPublicFileEtagIndex, - redirect: string, - remaining: string[], - useAliases: boolean, - seenRedirects: Set, -): string | undefined { - const marker = redirect + "\0" + remaining.join("/"); - if (seenRedirects.has(marker)) return undefined; - seenRedirects.add(marker); - - const relativeTarget = path.relative(index.publicDir, redirect); - if (!relativeTarget.startsWith("..") && !path.isAbsolute(relativeTarget)) { - return resolvePathSegments( - index, - [...(relativeTarget ? relativeTarget.split("/") : []), ...remaining], - useAliases, - seenRedirects, - ); - } - const physicalPath = path.join(redirect, remaining.join("/")); - return index.etagsByRealPath.has(physicalPath) ? physicalPath : undefined; -} - -function createPathNode(): DevPublicPathNode { - return { children: new Map(), asciiAliases: new Map(), unicodeAliases: new Map() }; -} - -function detectCaseInsensitiveDirectory(externalDir: string): boolean { - const dir = toSlash(externalDir); - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return false; - } - const entryNames = new Set(entries.map((entry) => entry.name)); - - for (const entry of entries) { - const toggledName = toggleAsciiCase(entry.name); - if (!toggledName) continue; - if (entryNames.has(toggledName)) return false; - if (pathHasCaseInsensitiveAlias(path.join(dir, entry.name))) return true; - } - - const basename = path.basename(dir); - const toggledBasename = toggleAsciiCase(basename); - if (!toggledBasename) return false; - try { - return ( - toSlash(fs.realpathSync.native(dir)) === - toSlash(fs.realpathSync.native(path.join(path.dirname(dir), toggledBasename))) - ); - } catch { - return false; - } -} - -function detectNormalizationInsensitiveDirectory(externalDir: string): boolean { - const dir = toSlash(externalDir); - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return false; - } - const entryNames = new Set(entries.map((entry) => entry.name)); - - for (const entry of entries) { - const alternateName = alternateNormalization(entry.name); - if (!alternateName) continue; - if (entryNames.has(alternateName)) return false; - if (pathHasNormalizationInsensitiveAlias(path.join(dir, entry.name))) { - return true; - } - } - return false; -} - -function toggleAsciiCase(value: string): string | undefined { - const index = value.search(/[A-Za-z]/); - if (index === -1) return undefined; - const char = value[index]!; - const toggled = char === char.toLowerCase() ? char.toUpperCase() : char.toLowerCase(); - return value.slice(0, index) + toggled + value.slice(index + 1); -} - -function alternateNormalization(value: string): string | undefined { - const decomposed = value.normalize("NFD"); - if (decomposed !== value) return decomposed; - const composed = value.normalize("NFC"); - return composed !== value ? composed : undefined; -} - -function pathHasCaseInsensitiveAlias(filePath: string): boolean { - const toggledName = toggleAsciiCase(path.basename(filePath)); - return toggledName - ? resolvesToSamePath(filePath, path.join(path.dirname(filePath), toggledName)) - : false; -} - -function pathHasNormalizationInsensitiveAlias(filePath: string): boolean { - const alternateName = alternateNormalization(path.basename(filePath)); - return alternateName - ? resolvesToSamePath(filePath, path.join(path.dirname(filePath), alternateName)) - : false; -} - -function resolvesToSamePath(left: string, right: string): boolean { - try { - return toSlash(fs.realpathSync.native(left)) === toSlash(fs.realpathSync.native(right)); - } catch { - return false; - } -} - -function unicodeBucketKey(value: string, normalizationInsensitive: boolean): string { - return (normalizationInsensitive ? value.normalize("NFD") : value).toUpperCase(); -} - -function asciiCaseKey(value: string): string { - return value.replace(/[A-Z]/g, (char) => char.toLowerCase()); -} diff --git a/tests/dev-public-etag-vite.test.ts b/tests/dev-public-etag-vite.test.ts deleted file mode 100644 index 19592c2100..0000000000 --- a/tests/dev-public-etag-vite.test.ts +++ /dev/null @@ -1,363 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { createServer, type Plugin, type ViteDevServer } from "vite"; -import vinext from "../packages/vinext/src/index.js"; - -const servers: ViteDevServer[] = []; -const roots: string[] = []; - -afterEach(async () => { - await Promise.all(servers.splice(0).map((server) => server.close())); - for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); -}); - -describe("dev public ETag Vite configuration", () => { - it("does not rewrite validators when publicDir is disabled", async () => { - const root = createRoot(); - const publicDir = path.join(root, "public"); - fs.mkdirSync(publicDir); - const filePath = path.join(publicDir, "asset.js"); - fs.writeFileSync(filePath, "hello"); - const strong = etagForFile(filePath).replace(/^W\//, ""); - - const baseUrl = await startServer(root, false); - const response = await fetch(`${baseUrl}/asset.js`, { - headers: { "If-None-Match": strong }, - }); - - expect(response.status).toBe(200); - expect(response.headers.get("x-fallback-if-none-match")).toBe(strong); - expect(await response.text()).toBe("fallback"); - }); - - it("indexes only Vite's resolved custom publicDir", async () => { - const root = createRoot(); - const defaultPublicDir = path.join(root, "public"); - const customPublicDir = path.join(root, "custom-public"); - fs.mkdirSync(defaultPublicDir); - fs.mkdirSync(customPublicDir); - const defaultFile = path.join(defaultPublicDir, "root-only.js"); - fs.writeFileSync(defaultFile, "wrong"); - fs.writeFileSync(path.join(customPublicDir, "asset.js"), "custom"); - - const baseUrl = await startServer(root, "custom-public"); - const initial = await fetch(`${baseUrl}/asset.js`); - expect(initial.status).toBe(200); - expect(await initial.text()).toBe("custom"); - const etag = initial.headers.get("etag"); - expect(etag).toMatch(/^W\//); - - const conditional = await fetch(`${baseUrl}/asset.js`, { - headers: { "If-None-Match": etag!.replace(/^W\//, "") }, - }); - expect(conditional.status).toBe(304); - - const rootOnlyStrong = etagForFile(defaultFile).replace(/^W\//, ""); - const fallback = await fetch(`${baseUrl}/root-only.js`, { - headers: { "If-None-Match": rootOnlyStrong }, - }); - expect(fallback.status).toBe(200); - expect(fallback.headers.get("x-fallback-if-none-match")).toBe(rootOnlyStrong); - expect(await fallback.text()).toBe("fallback"); - }); - - it.runIf(process.platform === "darwin")( - "matches case and normalization aliases when a dangling symlink enables Vite stat lookup", - async () => { - const root = createRoot(); - const publicDir = path.join(root, "public"); - fs.mkdirSync(publicDir); - const mixedCaseFile = path.join(publicDir, "MixedCase.js"); - const unicodeFile = path.join(publicDir, "Éclair.js"); - const sharpSFile = path.join(publicDir, "Straße.js"); - const dotlessIFile = path.join(publicDir, "ı.js"); - const sigmaFile = path.join(publicDir, "Σ.js"); - const ypogegrammeniFile = path.join(publicDir, "ͅ.js"); - const mixedExpansionFile = path.join(publicDir, "ßı.js"); - fs.writeFileSync(mixedCaseFile, "mixed"); - fs.writeFileSync(unicodeFile, "unicode"); - fs.writeFileSync(sharpSFile, "sharp-s"); - fs.writeFileSync(dotlessIFile, "dotless-i"); - fs.writeFileSync(sigmaFile, "sigma"); - fs.writeFileSync(ypogegrammeniFile, "ypogegrammeni"); - fs.writeFileSync(mixedExpansionFile, "mixed-expansion"); - fs.mkdirSync(path.join(publicDir, "Straße")); - fs.writeFileSync(path.join(publicDir, "Straße", "Maße.js"), "nested-sharp-s"); - fs.symlinkSync("missing", path.join(publicDir, "0-broken")); - - const lowerCaseFile = path.join(publicDir, "mixedCase.js"); - if (fs.realpathSync.native(mixedCaseFile) !== fs.realpathSync.native(lowerCaseFile)) return; - - const baseUrl = await startServer(root, "public"); - const mixedEtag = (await fetch(`${baseUrl}/MixedCase.js`)).headers.get("etag"); - expect(mixedEtag).toMatch(/^W\//); - expect( - ( - await fetch(`${baseUrl}/mixedcase.js`, { - headers: { "If-None-Match": mixedEtag!.replace(/^W\//, "") }, - }) - ).status, - ).toBe(304); - - const mixedExpansionEtag = (await fetch(`${baseUrl}/%C3%9F%C4%B1.js`)).headers.get("etag"); - expect(mixedExpansionEtag).toMatch(/^W\//); - const mixedExpansionFallback = await fetch(`${baseUrl}/SSi.js`, { - headers: { "If-None-Match": mixedExpansionEtag!.replace(/^W\//, "") }, - }); - expect(mixedExpansionFallback.status).toBe(200); - expect(mixedExpansionFallback.headers.get("x-fallback-if-none-match")).toBe( - mixedExpansionEtag!.replace(/^W\//, ""), - ); - - const ypogegrammeniEtag = (await fetch(`${baseUrl}/%CD%85.js`)).headers.get("etag"); - expect(ypogegrammeniEtag).toMatch(/^W\//); - expect( - ( - await fetch(`${baseUrl}/%CE%99.js`, { - headers: { "If-None-Match": ypogegrammeniEtag!.replace(/^W\//, "") }, - }) - ).status, - ).toBe(304); - - const sigmaEtag = (await fetch(`${baseUrl}/%CE%A3.js`)).headers.get("etag"); - expect(sigmaEtag).toMatch(/^W\//); - expect( - ( - await fetch(`${baseUrl}/%CF%82.js`, { - headers: { "If-None-Match": sigmaEtag!.replace(/^W\//, "") }, - }) - ).status, - ).toBe(304); - - const nestedEtag = (await fetch(`${baseUrl}/Stra%C3%9Fe/Ma%C3%9Fe.js`)).headers.get("etag"); - expect(nestedEtag).toMatch(/^W\//); - expect( - ( - await fetch(`${baseUrl}/STRASSE/MASSE.js`, { - headers: { "If-None-Match": nestedEtag!.replace(/^W\//, "") }, - }) - ).status, - ).toBe(304); - - const dotlessIEtag = (await fetch(`${baseUrl}/%C4%B1.js`)).headers.get("etag"); - expect(dotlessIEtag).toMatch(/^W\//); - const dottedFallback = await fetch(`${baseUrl}/i.js`, { - headers: { "If-None-Match": dotlessIEtag!.replace(/^W\//, "") }, - }); - expect(dottedFallback.status).toBe(200); - expect(dottedFallback.headers.get("x-fallback-if-none-match")).toBe( - dotlessIEtag!.replace(/^W\//, ""), - ); - - const unicodeEtag = (await fetch(`${baseUrl}/%C3%89clair.js`)).headers.get("etag"); - expect(unicodeEtag).toMatch(/^W\//); - expect( - ( - await fetch(`${baseUrl}/%C3%A9clair.js`, { - headers: { "If-None-Match": unicodeEtag!.replace(/^W\//, "") }, - }) - ).status, - ).toBe(304); - - const decomposedFile = path.join(publicDir, "E\u0301clair.js"); - if (fs.realpathSync.native(unicodeFile) === fs.realpathSync.native(decomposedFile)) { - expect( - ( - await fetch(`${baseUrl}/E%CC%81clair.js`, { - headers: { "If-None-Match": unicodeEtag!.replace(/^W\//, "") }, - }) - ).status, - ).toBe(304); - } - - const sharpSEtag = (await fetch(`${baseUrl}/Stra%C3%9Fe.js`)).headers.get("etag"); - expect(sharpSEtag).toMatch(/^W\//); - expect( - ( - await fetch(`${baseUrl}/STRASSE.js`, { - headers: { "If-None-Match": sharpSEtag!.replace(/^W\//, "") }, - }) - ).status, - ).toBe(304); - }, - ); - - it.runIf(process.platform === "darwin")( - "expands normalization semantics when a file is added after startup", - async () => { - const root = createRoot(); - const publicDir = path.join(root, "public"); - fs.mkdirSync(publicDir); - fs.writeFileSync(path.join(publicDir, "base.js"), "base"); - fs.symlinkSync("missing", path.join(publicDir, "0-broken")); - - const baseUrl = await startServer(root, "public"); - fs.writeFileSync(path.join(publicDir, "Éclair.js"), "unicode"); - - await expect - .poll( - async () => { - const canonical = await fetch(`${baseUrl}/%C3%89clair.js`); - const etag = canonical.headers.get("etag"); - if (!etag) return canonical.status; - return ( - await fetch(`${baseUrl}/E%CC%81clair.js`, { - headers: { "If-None-Match": etag.replace(/^W\//, "") }, - }) - ).status; - }, - { timeout: 5000 }, - ) - .toBe(304); - }, - ); - - it.runIf(process.platform !== "win32")( - "does not fold or rewrite a wrong-case request for a symlinked public root", - async () => { - const root = createRoot(); - const actualPublicDir = path.join(root, "actual-public"); - const publicDir = path.join(root, "public"); - fs.mkdirSync(actualPublicDir); - fs.writeFileSync(path.join(actualPublicDir, "MixedCase.js"), "content"); - fs.symlinkSync(actualPublicDir, publicDir, "dir"); - - const baseUrl = await startServer(root, "public"); - const initial = await fetch(`${baseUrl}/MixedCase.js`); - const strong = initial.headers.get("etag")!.replace(/^W\//, ""); - const fallback = await fetch(`${baseUrl}/mixedcase.js`, { - headers: { "If-None-Match": strong }, - }); - - expect(fallback.status).toBe(200); - expect(fallback.headers.get("x-fallback-if-none-match")).toBe(strong); - expect(await fallback.text()).toBe("fallback"); - }, - ); - - it.runIf(process.platform === "darwin")( - "keeps Vite's exact public lookup mode when a symlink is added later", - async () => { - const root = createRoot(); - const publicDir = path.join(root, "public"); - fs.mkdirSync(publicDir); - fs.writeFileSync(path.join(publicDir, "MixedCase.js"), "content"); - - const baseUrl = await startServer(root, "public"); - const initial = await fetch(`${baseUrl}/MixedCase.js`); - const strong = initial.headers.get("etag")!.replace(/^W\//, ""); - fs.symlinkSync("MixedCase.js", path.join(publicDir, "alias.js")); - await expect.poll(async () => (await fetch(`${baseUrl}/alias.js`)).status).toBe(200); - - const fallback = await fetch(`${baseUrl}/mixedcase.js`, { - headers: { "If-None-Match": strong }, - }); - expect(fallback.status).toBe(200); - expect(fallback.headers.get("x-fallback-if-none-match")).toBe(strong); - }, - ); - - it.runIf(process.platform === "darwin")( - "keeps Vite's stat lookup mode when its startup symlink is removed", - async () => { - const root = createRoot(); - const publicDir = path.join(root, "public"); - fs.mkdirSync(publicDir); - fs.writeFileSync(path.join(publicDir, "MixedCase.js"), "content"); - const alias = path.join(publicDir, "alias.js"); - fs.symlinkSync("MixedCase.js", alias); - - const baseUrl = await startServer(root, "public"); - const initial = await fetch(`${baseUrl}/MixedCase.js`); - const strong = initial.headers.get("etag")!.replace(/^W\//, ""); - fs.rmSync(alias); - - await expect - .poll( - async () => - ( - await fetch(`${baseUrl}/mixedcase.js`, { - headers: { "If-None-Match": strong }, - }) - ).status, - ) - .toBe(304); - }, - ); - - it.runIf(process.platform === "darwin")( - "keeps unverified Unicode aliases distinct from dotted files and symlinks", - async () => { - const root = createRoot("ı-etag-root-"); - const publicDir = path.join(root, "public"); - fs.mkdirSync(publicDir); - fs.writeFileSync(path.join(publicDir, "MixedCase.js"), "mixed"); - fs.writeFileSync(path.join(publicDir, "target.js"), "target"); - fs.symlinkSync("target.js", path.join(publicDir, "i.js")); - - const baseUrl = await startServer(root, "public"); - const mixedEtag = (await fetch(`${baseUrl}/MixedCase.js`)).headers.get("etag")!; - expect( - ( - await fetch(`${baseUrl}/mixedcase.js`, { - headers: { "If-None-Match": mixedEtag.replace(/^W\//, "") }, - }) - ).status, - ).toBe(304); - - const dottedEtag = (await fetch(`${baseUrl}/i.js`)).headers.get("etag")!; - const dotlessFallback = await fetch(`${baseUrl}/%C4%B1.js`, { - headers: { "If-None-Match": dottedEtag.replace(/^W\//, "") }, - }); - expect(dotlessFallback.status).toBe(200); - expect(dotlessFallback.headers.get("x-fallback-if-none-match")).toBe( - dottedEtag.replace(/^W\//, ""), - ); - }, - ); -}); - -function createRoot(prefix = "vinext-dev-public-config-"): string { - const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - roots.push(root); - return root; -} - -async function startServer(root: string, publicDir: string | false): Promise { - const fallbackPlugin: Plugin = { - name: "test-public-etag-fallback", - configureServer(server) { - return () => { - server.middlewares.use((req, res) => { - const validator = req.headers["if-none-match"]; - if (typeof validator === "string") { - res.setHeader("x-fallback-if-none-match", validator); - } - res.statusCode = 200; - res.end("fallback"); - }); - }; - }, - }; - - const server = await createServer({ - root, - publicDir, - configFile: false, - plugins: [vinext(), fallbackPlugin], - logLevel: "silent", - server: { host: "127.0.0.1", port: 0 }, - }); - servers.push(server); - await server.listen(); - const address = server.httpServer!.address(); - if (!address || typeof address === "string") throw new Error("Expected a TCP dev server"); - return `http://127.0.0.1:${address.port}`; -} - -function etagForFile(filePath: string): string { - const stats = fs.statSync(filePath); - return `W/"${stats.size}-${stats.mtime.getTime()}"`; -} diff --git a/tests/dev-public-etag.test.ts b/tests/dev-public-etag.test.ts deleted file mode 100644 index 2a3667c647..0000000000 --- a/tests/dev-public-etag.test.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { describe, expect, it } from "vitest"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { toSlash } from "pathslash"; -import { - createDevPublicFileEtags, - type DevPublicFileEtagIndex, - resolveDevPublicIfNoneMatch, - updateDevPublicFileEtag, -} from "../packages/vinext/src/server/dev-public-etag.js"; - -const ETAG = 'W/"90-1234"'; -const publicEtags: DevPublicFileEtagIndex = { - publicDir: "/public", - etagsByRealPath: new Map([["/public/asset.js", ETAG]]), - requestPathRoot: { children: new Map(), asciiAliases: new Map(), unicodeAliases: new Map() }, - symlinkTargets: new Map(), - hasSymlink: false, - viteUsesStatLookup: false, - caseInsensitive: false, - normalizationInsensitive: false, -}; - -describe("resolveDevPublicIfNoneMatch", () => { - it("normalizes strong and weak validators to sirv's weak ETag", () => { - expect(resolveDevPublicIfNoneMatch("GET", "/asset.js", '"90-1234"', publicEtags)).toBe(ETAG); - expect(resolveDevPublicIfNoneMatch("GET", "/asset.js", ETAG, publicEtags)).toBe(ETAG); - }); - - it("handles lists and wildcard validators", () => { - expect( - resolveDevPublicIfNoneMatch("GET", "/asset.js?cache=1", '"other", "90-1234"', publicEtags), - ).toBe(ETAG); - expect(resolveDevPublicIfNoneMatch("GET", "/asset.js", "*", publicEtags)).toBe(ETAG); - }); - - it("leaves malformed and non-matching validators untouched", () => { - expect( - resolveDevPublicIfNoneMatch("GET", "/asset.js", '*, "90-1234"', publicEtags), - ).toBeUndefined(); - expect(resolveDevPublicIfNoneMatch("GET", "/asset.js", '"other"', publicEtags)).toBeUndefined(); - }); - - it("supports HEAD and a configured base path", () => { - expect( - resolveDevPublicIfNoneMatch("HEAD", "/base/asset.js", '"90-1234"', publicEtags, "/base"), - ).toBe(ETAG); - expect( - resolveDevPublicIfNoneMatch( - "GET", - "/base/a/%2e%2e//asset.js", - '"90-1234"', - publicEtags, - "/base", - ), - ).toBe(ETAG); - }); - - it("does not affect methods, missing files, or paths outside basePath", () => { - expect( - resolveDevPublicIfNoneMatch("POST", "/asset.js", '"90-1234"', publicEtags), - ).toBeUndefined(); - expect( - resolveDevPublicIfNoneMatch("GET", "/missing.js", '"90-1234"', publicEtags), - ).toBeUndefined(); - expect( - resolveDevPublicIfNoneMatch("GET", "/asset.js", '"90-1234"', publicEtags, "/base"), - ).toBeUndefined(); - }); - - it("tracks the same size/mtime identity that sirv uses", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-")); - const publicDir = path.join(root, "public"); - const filePath = path.join(publicDir, "asset.js"); - try { - fs.mkdirSync(publicDir); - fs.writeFileSync(filePath, "initial"); - - const index = createDevPublicFileEtags(publicDir); - const realPath = toSlash(fs.realpathSync(filePath)); - const initial = index.etagsByRealPath.get(realPath); - expect(initial).toMatch(/^W\/"7-\d+"$/); - - fs.writeFileSync(filePath, "updated content"); - expect(updateDevPublicFileEtag(index, filePath)).toBe(true); - expect(index.etagsByRealPath.get(realPath)).toMatch(/^W\/"15-\d+"$/); - expect(index.etagsByRealPath.get(realPath)).not.toBe(initial); - - fs.rmSync(filePath); - expect(updateDevPublicFileEtag(index, filePath)).toBe(false); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } - }); - - it.runIf(process.platform !== "win32")( - "preserves directory symlink aliases without following cycles", - () => { - const root = fs.realpathSync( - fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-alias-")), - ); - const publicDir = path.join(root, "public"); - try { - fs.mkdirSync(path.join(publicDir, "a"), { recursive: true }); - fs.writeFileSync(path.join(publicDir, "a", "asset.js"), "content"); - fs.symlinkSync("a", path.join(publicDir, "b"), "dir"); - fs.symlinkSync("..", path.join(publicDir, "a", "cycle"), "dir"); - - const index = createDevPublicFileEtags(publicDir); - const etag = index.etagsByRealPath.get( - toSlash(fs.realpathSync(path.join(publicDir, "a/asset.js"))), - ); - expect(etag).toMatch(/^W\//); - expect(resolveDevPublicIfNoneMatch("GET", "/a/asset.js", etag, index)).toBe(etag); - expect(resolveDevPublicIfNoneMatch("GET", "/b/asset.js", etag, index)).toBe(etag); - expect(resolveDevPublicIfNoneMatch("GET", "/a/cycle/a/asset.js", etag, index)).toBe(etag); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } - }, - ); - - it.runIf(process.platform !== "win32")( - "updates files reported through a directory symlink's real path", - () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-real-watch-")); - const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-target-")); - const publicDir = path.join(root, "public"); - const externalFile = path.join(externalDir, "asset.js"); - try { - fs.mkdirSync(publicDir); - fs.writeFileSync(externalFile, "initial"); - fs.symlinkSync(externalDir, path.join(publicDir, "alias"), "dir"); - - const index = createDevPublicFileEtags(publicDir); - const initial = resolveDevPublicIfNoneMatch( - "GET", - "/alias/asset.js", - index.etagsByRealPath.get(toSlash(fs.realpathSync(externalFile))), - index, - ); - expect(initial).toMatch(/^W\//); - - fs.writeFileSync(externalFile, "updated content"); - expect(updateDevPublicFileEtag(index, externalFile)).toBe(true); - const updated = index.etagsByRealPath.get(toSlash(fs.realpathSync(externalFile))); - expect(updated).toMatch(/^W\//); - expect(updated).not.toBe(initial); - expect(resolveDevPublicIfNoneMatch("GET", "/alias/asset.js", updated, index)).toBe(updated); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - fs.rmSync(externalDir, { recursive: true, force: true }); - } - }, - ); - - it.runIf(process.platform === "darwin")( - "folds served names only when the filesystem is case-insensitive", - () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-case-")); - const publicDir = path.join(root, "public"); - try { - fs.mkdirSync(publicDir); - fs.writeFileSync(path.join(publicDir, "MixedCase.js"), "content"); - - const exactSetIndex = createDevPublicFileEtags(publicDir, true); - exactSetIndex.symlinkTargets.clear(); - expect( - resolveDevPublicIfNoneMatch("GET", "/mixedcase.js", "*", exactSetIndex), - ).toBeUndefined(); - - fs.symlinkSync("MixedCase.js", path.join(publicDir, "alias.js")); - - const caseInsensitiveIndex = createDevPublicFileEtags(publicDir, true); - const etag = resolveDevPublicIfNoneMatch("GET", "/mixedcase.js", "*", caseInsensitiveIndex); - expect(etag).toMatch(/^W\//); - expect(resolveDevPublicIfNoneMatch("GET", "/ALIAS.JS", "*", caseInsensitiveIndex)).toBe( - etag, - ); - - const caseSensitiveIndex = createDevPublicFileEtags(publicDir, false); - expect( - resolveDevPublicIfNoneMatch("GET", "/mixedcase.js", "*", caseSensitiveIndex), - ).toBeUndefined(); - expect( - resolveDevPublicIfNoneMatch("GET", "/MixedCase.js", "*", caseSensitiveIndex), - ).toMatch(/^W\//); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } - }, - ); - - it("fails ambiguous folded names closed while preserving exact spellings", () => { - const index: DevPublicFileEtagIndex = { - publicDir: "/public", - etagsByRealPath: new Map([ - ["/public/A.js", 'W/"5-1"'], - ["/public/a.js", 'W/"5-2"'], - ]), - requestPathRoot: { - children: new Map(), - asciiAliases: new Map([["a.js", null]]), - unicodeAliases: new Map(), - }, - symlinkTargets: new Map([["/public/alias", "/public/A.js"]]), - hasSymlink: true, - viteUsesStatLookup: true, - caseInsensitive: true, - normalizationInsensitive: false, - }; - expect(resolveDevPublicIfNoneMatch("GET", "/A.js", "*", index)).toBe('W/"5-1"'); - expect(resolveDevPublicIfNoneMatch("GET", "/a.js", "*", index)).toBe('W/"5-2"'); - expect(resolveDevPublicIfNoneMatch("GET", "/a.JS", "*", index)).toBeUndefined(); - }); - - it.runIf(process.platform === "darwin")( - "uses dangling symlinks to mirror Vite's stat-based public lookup", - () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-dangling-")); - const publicDir = path.join(root, "public"); - try { - fs.mkdirSync(publicDir); - fs.symlinkSync("missing", path.join(publicDir, "0-broken")); - fs.writeFileSync(path.join(publicDir, "MixedCase.js"), "content"); - - const index = createDevPublicFileEtags(publicDir, true); - expect(index.hasSymlink).toBe(true); - expect([...index.symlinkTargets.keys()].some((key) => key.endsWith("/0-broken"))).toBe( - false, - ); - expect(resolveDevPublicIfNoneMatch("GET", "/mixedcase.js", "*", index)).toMatch(/^W\//); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } - }, - ); - - it.runIf(process.platform !== "win32")( - "does not treat a symlinked public root as a child symlink", - () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-root-link-")); - const actualPublicDir = path.join(root, "actual-public"); - const publicDir = path.join(root, "public"); - try { - fs.mkdirSync(actualPublicDir); - fs.writeFileSync(path.join(actualPublicDir, "MixedCase.js"), "content"); - fs.symlinkSync(actualPublicDir, publicDir, "dir"); - - const index = createDevPublicFileEtags(publicDir, true); - expect(index.symlinkTargets.get(toSlash(publicDir))).toBe( - toSlash(fs.realpathSync.native(actualPublicDir)), - ); - expect(index.hasSymlink).toBe(false); - expect(resolveDevPublicIfNoneMatch("GET", "/mixedcase.js", "*", index)).toBeUndefined(); - expect(resolveDevPublicIfNoneMatch("GET", "/MixedCase.js", "*", index)).toMatch(/^W\//); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } - }, - ); - - it.runIf(process.platform === "darwin")( - "folds Unicode case and filesystem normalization aliases", - () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-unicode-")); - const publicDir = path.join(root, "public"); - try { - fs.mkdirSync(publicDir); - fs.writeFileSync(path.join(publicDir, "Éclair.js"), "content"); - fs.writeFileSync(path.join(publicDir, "Straße.js"), "content"); - fs.symlinkSync("Éclair.js", path.join(publicDir, "alias.js")); - - const index = createDevPublicFileEtags(publicDir, true, true); - expect(resolveDevPublicIfNoneMatch("GET", "/%C3%A9clair.js", "*", index)).toMatch(/^W\//); - expect(resolveDevPublicIfNoneMatch("GET", "/e%CC%81clair.js", "*", index)).toMatch(/^W\//); - expect(resolveDevPublicIfNoneMatch("GET", "/STRASSE.js", "*", index)).toMatch(/^W\//); - - const normalizationOnlyIndex = createDevPublicFileEtags(publicDir, false, true); - expect( - resolveDevPublicIfNoneMatch("GET", "/E%CC%81clair.js", "*", normalizationOnlyIndex), - ).toMatch(/^W\//); - expect( - resolveDevPublicIfNoneMatch("GET", "/e%CC%81clair.js", "*", normalizationOnlyIndex), - ).toBeUndefined(); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } - }, - ); - - it.runIf(process.platform !== "win32")( - "does not infer insensitive semantics from distinct aliases to one target", - () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-alias-probe-")); - const publicDir = path.join(root, "public"); - try { - fs.mkdirSync(publicDir); - fs.writeFileSync(path.join(publicDir, "target.js"), "content"); - fs.symlinkSync("target.js", path.join(publicDir, "alias")); - try { - fs.symlinkSync("target.js", path.join(publicDir, "Alias")); - fs.symlinkSync("target.js", path.join(publicDir, "É")); - fs.symlinkSync("target.js", path.join(publicDir, "E\u0301")); - } catch { - return; - } - - const index = createDevPublicFileEtags(publicDir); - expect(index.caseInsensitive).toBe(false); - expect(index.normalizationInsensitive).toBe(false); - expect(resolveDevPublicIfNoneMatch("GET", "/ALIAS", "*", index)).toBeUndefined(); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } - }, - ); - - it.runIf(process.platform === "win32")("normalizes encoded Windows separators like Vite", () => { - const nestedEtags: DevPublicFileEtagIndex = { - publicDir: "/public", - etagsByRealPath: new Map([["/public/foo/bar.js", ETAG]]), - requestPathRoot: { children: new Map(), asciiAliases: new Map(), unicodeAliases: new Map() }, - symlinkTargets: new Map(), - hasSymlink: false, - viteUsesStatLookup: false, - caseInsensitive: false, - normalizationInsensitive: false, - }; - expect(resolveDevPublicIfNoneMatch("GET", "/foo%5Cbar.js", '"90-1234"', nestedEtags)).toBe( - ETAG, - ); - }); -}); diff --git a/tests/e2e/pages-router/script.spec.ts b/tests/e2e/pages-router/script.spec.ts index 26178b3681..5168f54d16 100644 --- a/tests/e2e/pages-router/script.spec.ts +++ b/tests/e2e/pages-router/script.spec.ts @@ -1,45 +1,8 @@ import { test, expect } from "@playwright/test"; -import { request as httpRequest } from "node:http"; const BASE = "http://localhost:4173"; test.describe("next/script", () => { - test("uses weak comparison for public-file If-None-Match requests", async ({ request }) => { - const initial = await request.get(`${BASE}/dedupe-script.js`); - const etag = initial.headers().etag; - expect(etag).toMatch(/^W\//); - - const strong = etag.slice(2); - const strongResponse = await request.get(`${BASE}/dedupe-script.js`, { - headers: { "If-None-Match": strong }, - }); - expect(strongResponse.status()).toBe(304); - - const listResponse = await request.get(`${BASE}/dedupe-script.js`, { - headers: { "If-None-Match": `"other", ${strong}` }, - }); - expect(listResponse.status()).toBe(304); - - const headResponse = await request.head(`${BASE}/dedupe-script.js`, { - headers: { "If-None-Match": strong }, - }); - expect(headResponse.status()).toBe(304); - - const normalizedPathStatus = await new Promise((resolve, reject) => { - const req = httpRequest( - `${BASE}/nested/%2e%2e//dedupe-script.js`, - { headers: { "If-None-Match": strong } }, - (response) => { - response.resume(); - response.on("end", () => resolve(response.statusCode)); - }, - ); - req.on("error", reject); - req.end(); - }); - expect(normalizedPathStatus).toBe(304); - }); - // Ported from Next.js: packages/next/src/client/script.tsx // https://github.com/vercel/next.js/blob/canary/packages/next/src/client/script.tsx // Next.js keeps a ScriptCache for in-flight remote scripts so same-src From 03998b5db2f67aab405fb162153a15e6c1a14342 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 15:30:15 +0100 Subject: [PATCH 21/23] test(server): reject malformed etag list tails --- tests/http-conditional.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/http-conditional.test.ts b/tests/http-conditional.test.ts index fe2cc9c90d..e5e48ba2e6 100644 --- a/tests/http-conditional.test.ts +++ b/tests/http-conditional.test.ts @@ -46,6 +46,7 @@ describe("matchesIfNoneMatch", () => { it("rejects malformed entity tags", () => { expect(matchesIfNoneMatch('asset, "match"', '"match"')).toBe(false); + expect(matchesIfNoneMatch('"match", garbage', '"match"')).toBe(false); expect(matchesIfNoneMatch('"unterminated', '"unterminated"')).toBe(false); expect(matchesIfNoneMatch('W/ "asset"', '"asset"')).toBe(false); expect(matchesIfNoneMatch('"asset" suffix', '"asset"')).toBe(false); From 6b9e0e13b658e71d18e3498e4b5857a58df4e285 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 15:35:28 +0100 Subject: [PATCH 22/23] fix(pages): share conditional etag matching --- .../vinext/src/server/pages-page-response.ts | 18 ++---------------- tests/pages-page-response.test.ts | 2 ++ 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/packages/vinext/src/server/pages-page-response.ts b/packages/vinext/src/server/pages-page-response.ts index b8bfea62b4..6cb925dfa1 100644 --- a/packages/vinext/src/server/pages-page-response.ts +++ b/packages/vinext/src/server/pages-page-response.ts @@ -33,6 +33,7 @@ import { } from "./pages-document-asset-props.js"; import { isBotUserAgent } from "../utils/html-limited-bots.js"; import { NEXTJS_CACHE_HEADER } from "./headers.js"; +import { matchesIfNoneMatch } from "./http-conditional.js"; // --------------------------------------------------------------------------- // Bot / crawler detection for Pages Router edge-runtime SSR @@ -62,24 +63,9 @@ export function generatePagesETag(payload: string): string { /** * Mirrors Next.js `sendEtagResponse` semantics (weak/strong comparison). - * - * A weak ETag `W/"..."` matches both `W/"..."` and `"..."` in `If-None-Match`. - * A strong ETag `"..."` only matches the same strong token. - * `*` always matches. */ export function etagMatches(etag: string, ifNoneMatch: string): boolean { - if (ifNoneMatch === "*") return true; - // Normalise: strip the W/ prefix for comparison. Next.js's - // `sendEtagResponse` (packages/next/src/server/send-payload.ts) uses the - // `fresh` package, which treats a weak token in `If-None-Match` as matching - // the corresponding strong ETag and vice versa (RFC 7232 §2.3.2 weak - // comparison). We replicate that behaviour here. - const normalize = (t: string) => t.replace(/^W\//, ""); - const etagNorm = normalize(etag.trim()); - for (const token of ifNoneMatch.split(",")) { - if (normalize(token.trim()) === etagNorm) return true; - } - return false; + return matchesIfNoneMatch(ifNoneMatch, etag); } /** diff --git a/tests/pages-page-response.test.ts b/tests/pages-page-response.test.ts index f3c739d7ab..a1f2781ffe 100644 --- a/tests/pages-page-response.test.ts +++ b/tests/pages-page-response.test.ts @@ -1099,7 +1099,9 @@ describe("pages page response", () => { expect(etagMatches('"abc123"', 'W/"abc123"')).toBe(true); expect(etagMatches('W/"abc123"', '"abc123"')).toBe(true); expect(etagMatches('"abc123"', '"abc123"')).toBe(true); + expect(etagMatches('"abc,123"', '"other", W/"abc,123"')).toBe(true); expect(etagMatches('"abc123"', '"other"')).toBe(false); + expect(etagMatches('"abc123"', '"abc123", garbage')).toBe(false); expect(etagMatches('"abc123"', "*")).toBe(true); }); From a699cec590d5de67d9197e557d77ffbbf3529ddc Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 15:45:02 +0100 Subject: [PATCH 23/23] refactor(pages): remove reversed etag matcher --- packages/vinext/src/server/pages-page-data.ts | 4 ++-- .../vinext/src/server/pages-page-response.ts | 9 +-------- tests/pages-page-response.test.ts | 17 +++-------------- 3 files changed, 6 insertions(+), 24 deletions(-) diff --git a/packages/vinext/src/server/pages-page-data.ts b/packages/vinext/src/server/pages-page-data.ts index 10dca37dda..8dd5d3dc83 100644 --- a/packages/vinext/src/server/pages-page-data.ts +++ b/packages/vinext/src/server/pages-page-data.ts @@ -16,7 +16,6 @@ import { buildPagesCacheValue, type ISRCacheEntry } from "./isr-cache.js"; import type { PagesPreviewData } from "./pages-preview.js"; import { buildPagesNextDataScript, - etagMatches, generatePagesETag, isPagesStreamingBot, requestsNoCache, @@ -24,6 +23,7 @@ import { type PagesI18nRenderContext, type PagesNextDataExtras, } from "./pages-page-response.js"; +import { matchesIfNoneMatch } from "./http-conditional.js"; import { createPagesGetInitialPropsRouter, hasPagesGetInitialProps, @@ -973,7 +973,7 @@ function applyBotETagAndCheck( const etag = generatePagesETag(html); cachedResponse.headers.set("ETag", etag); const noCacheRequested = requestsNoCache(options.requestCacheControl); - if (!noCacheRequested && options.ifNoneMatch && etagMatches(etag, options.ifNoneMatch)) { + if (!noCacheRequested && options.ifNoneMatch && matchesIfNoneMatch(options.ifNoneMatch, etag)) { return { kind: "response", response: new Response(null, { diff --git a/packages/vinext/src/server/pages-page-response.ts b/packages/vinext/src/server/pages-page-response.ts index 6cb925dfa1..efe0aaf7cf 100644 --- a/packages/vinext/src/server/pages-page-response.ts +++ b/packages/vinext/src/server/pages-page-response.ts @@ -61,13 +61,6 @@ export function generatePagesETag(payload: string): string { return '"' + fnv1a52(payload).toString(36) + payload.length.toString(36) + '"'; } -/** - * Mirrors Next.js `sendEtagResponse` semantics (weak/strong comparison). - */ -export function etagMatches(etag: string, ifNoneMatch: string): boolean { - return matchesIfNoneMatch(ifNoneMatch, etag); -} - /** * Returns true when a request `Cache-Control` header asks to bypass the 304 * short-circuit. Mirrors the `fresh` package's check used by Next.js's @@ -754,7 +747,7 @@ export async function renderPagesPageResponse( const etag = generatePagesETag(fullHtml); responseHeaders.set("ETag", etag); const noCacheRequested = requestsNoCache(options.requestCacheControl); - if (!noCacheRequested && options.ifNoneMatch && etagMatches(etag, options.ifNoneMatch)) { + if (!noCacheRequested && options.ifNoneMatch && matchesIfNoneMatch(options.ifNoneMatch, etag)) { return new Response(null, { status: 304, headers: responseHeaders, diff --git a/tests/pages-page-response.test.ts b/tests/pages-page-response.test.ts index a1f2781ffe..37d47d78c4 100644 --- a/tests/pages-page-response.test.ts +++ b/tests/pages-page-response.test.ts @@ -4,7 +4,6 @@ import { renderPagesPageResponse, isPagesStreamingBot, generatePagesETag, - etagMatches, } from "../packages/vinext/src/server/pages-page-response.js"; import { resolvePagesPageData } from "../packages/vinext/src/server/pages-page-data.js"; @@ -1030,7 +1029,7 @@ describe("pages page response", () => { // If-None-Match / 304 handling and ISR cache-HIT ETag // --------------------------------------------------------------------------- - it("returns 304 when If-None-Match matches ETag on bot response (fresh-MISS path)", async () => { + it("returns 304 for a weak If-None-Match on a strong bot ETag", async () => { const common = createCommonOptions(); // First request: compute the ETag from a full bot render. @@ -1041,12 +1040,12 @@ describe("pages page response", () => { const etag = firstResponse.headers.get("etag"); expect(etag).toBeTruthy(); - // Second request: send If-None-Match matching the ETag. + // Second request: send the strong ETag as a weak validator. const common2 = createCommonOptions(); const notModifiedResponse = await renderPagesPageResponse({ ...common2.options, userAgent: "Googlebot", - ifNoneMatch: etag as string, + ifNoneMatch: `W/${etag}`, }); expect(notModifiedResponse.status).toBe(304); @@ -1095,16 +1094,6 @@ describe("pages page response", () => { expect(browserResponse.headers.get("etag")).toBeNull(); }); - it("ETag weak-comparison: W/ prefix is ignored when matching If-None-Match", async () => { - expect(etagMatches('"abc123"', 'W/"abc123"')).toBe(true); - expect(etagMatches('W/"abc123"', '"abc123"')).toBe(true); - expect(etagMatches('"abc123"', '"abc123"')).toBe(true); - expect(etagMatches('"abc,123"', '"other", W/"abc,123"')).toBe(true); - expect(etagMatches('"abc123"', '"other"')).toBe(false); - expect(etagMatches('"abc123"', '"abc123", garbage')).toBe(false); - expect(etagMatches('"abc123"', "*")).toBe(true); - }); - it("attaches ETag to ISR cache-HIT response for bot UAs", async () => { // Build a fake cached ISR entry and confirm that cache-HIT + bot UA yields ETag. const cachedHtml =