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/30] 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 e5271927a5a16ec1dfaa2b73b6214bec417e9238 Mon Sep 17 00:00:00 2001 From: Boy Steven Benaya Aritonang Date: Sun, 26 Jul 2026 23:11:19 +0700 Subject: [PATCH 02/30] feat(server): support static asset byte ranges --- packages/vinext/src/server/http-range.ts | 66 +++++++ packages/vinext/src/server/prod-server.ts | 174 +++++++++++++----- .../vinext/src/server/static-file-cache.ts | 11 +- tests/app-router-production-server.test.ts | 35 ++++ tests/http-range.test.ts | 58 ++++++ tests/static-file-cache.test.ts | 1 + 6 files changed, 303 insertions(+), 42 deletions(-) create mode 100644 packages/vinext/src/server/http-range.ts create mode 100644 tests/http-range.test.ts diff --git a/packages/vinext/src/server/http-range.ts b/packages/vinext/src/server/http-range.ts new file mode 100644 index 0000000000..08fb72db1c --- /dev/null +++ b/packages/vinext/src/server/http-range.ts @@ -0,0 +1,66 @@ +export type ByteRange = + | { kind: "ignore" } + | { kind: "unsatisfiable" } + | { kind: "range"; start: number; end: number }; + +/** + * Parse a single bytes range. Unsupported range units, malformed values, and + * multipart ranges are ignored, which RFC 9110 permits servers to do. + */ +export function parseByteRange(value: string | undefined, size: number): ByteRange { + if (!value || value.slice(0, 6).toLowerCase() !== "bytes=") return { kind: "ignore" }; + + const spec = value.slice("bytes=".length).trim(); + if (spec.includes(",")) return { kind: "ignore" }; + + const match = /^(\d*)-(\d*)$/.exec(spec); + if (!match || (!match[1] && !match[2])) return { kind: "ignore" }; + + if (!match[1]) { + const suffixLength = parseDecimalInteger(match[2]); + if (suffixLength === null) return { kind: "ignore" }; + if (suffixLength === 0 || size === 0) return { kind: "unsatisfiable" }; + return { + kind: "range", + start: Math.max(size - suffixLength, 0), + end: size - 1, + }; + } + + const start = parseDecimalInteger(match[1]); + const requestedEnd = match[2] ? parseDecimalInteger(match[2]) : size - 1; + if (start === null || requestedEnd === null) return { kind: "ignore" }; + if (start >= size || requestedEnd < start) return { kind: "unsatisfiable" }; + + return { + kind: "range", + start, + end: Math.min(requestedEnd, size - 1), + }; +} + +/** + * If-Range requires strong entity-tag comparison. Date validators are matched + * at HTTP-date (whole-second) precision because filesystem mtimes are finer. + */ +export function ifRangeAllowsRange( + value: string | undefined, + etag: string, + mtimeMs: number, +): boolean { + if (!value) return true; + + const trimmed = value.trim(); + if (trimmed.startsWith('"') || trimmed.startsWith("W/")) { + return !trimmed.startsWith("W/") && !etag.startsWith("W/") && trimmed === etag; + } + + const timestamp = Date.parse(trimmed); + return Number.isFinite(timestamp) && Math.floor(mtimeMs / 1000) * 1000 <= timestamp; +} + +function parseDecimalInteger(value: string): number | null { + if (!/^\d+$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : null; +} diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 44ceda6060..550985f2f3 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 { ifRangeAllowsRange, parseByteRange, type ByteRange } from "./http-range.js"; import type { NextI18nConfig } from "../config/next-config.js"; import { readTrustedRevalidationHostname } from "./revalidation-host.js"; @@ -576,6 +577,11 @@ async function tryServeStatic( if (pathname === "/") return false; const responseStatus = statusCode ?? 200; const omitBody = isNoBodyResponseStatus(responseStatus); + // RFC 9110 defines Range for GET. A Range field on HEAD or another method + // must not change the selected response status or representation metadata. + const requestRange = req.method === "GET" ? req.headers.range : undefined; + const rawIfRange = req.headers["if-range"]; + const ifRange = typeof rawIfRange === "string" ? rawIfRange : undefined; // ── Fast path: pre-computed headers, minimal per-request work ── // When a cache is provided, all path validation happened at startup. @@ -600,6 +606,15 @@ async function tryServeStatic( const entry = cache.lookup(lookupPath); if (!entry) return false; + const range = resolveRequestedRange( + requestRange, + ifRange, + entry.original.size, + entry.etag, + entry.mtimeMs, + responseStatus, + ); + // Pick the best precompressed variant: zstd → br → gzip → original. // Each variant has pre-computed headers — zero string building. // Encoding tokens are case-insensitive per RFC 9110, and we honor q=0 @@ -644,7 +659,39 @@ async function tryServeStatic( return true; } - const responseHeaders = { ...variant.headers, ...extraHeaders }; + if (range.kind === "unsatisfiable") { + res.writeHead(416, { + ...entry.notModifiedHeaders, + ...extraHeaders, + "Accept-Ranges": "bytes", + "Content-Range": `bytes */${entry.original.size}`, + }); + res.end(); + return true; + } + + if (range.kind === "range") { + const length = range.end - range.start + 1; + res.writeHead(206, { + ...entry.original.headers, + ...extraHeaders, + "Accept-Ranges": "bytes", + "Content-Length": String(length), + "Content-Range": `bytes ${range.start}-${range.end}/${entry.original.size}`, + }); + if (entry.original.buffer) { + res.end(entry.original.buffer.subarray(range.start, range.end + 1)); + } else { + pipeStaticFileRange(entry.original.path, range, res); + } + return true; + } + + const responseHeaders = { + ...variant.headers, + ...extraHeaders, + "Accept-Ranges": "bytes", + }; res.writeHead( responseStatus, variesByEncoding ? mergeVaryHeader(responseHeaders, "Accept-Encoding") : responseHeaders, @@ -712,58 +759,76 @@ async function tryServeStatic( "Content-Type": ct, "Cache-Control": cacheControl, ETag: etag, + "Last-Modified": new Date(resolved.mtimeMs).toUTCString(), + "Accept-Ranges": "bytes", ...extraHeaders, }; - if (isCompressible) { - const encoding = negotiateEncoding(req); - const ifNoneMatch = req.headers["if-none-match"]; - if ( - responseStatus === 200 && - typeof ifNoneMatch === "string" && - matchesIfNoneMatchHeader(ifNoneMatch, etag) - ) { - const notModifiedHeaders = mergeVaryHeader(baseHeaders, "Accept-Encoding"); - if (encoding !== "identity") notModifiedHeaders["Content-Encoding"] = encoding; - res.writeHead(304, notModifiedHeaders); - res.end(); - return true; - } - if (encoding !== "identity") { - // Content-Length omitted intentionally: compressed size isn't known - // ahead of time, so Node.js uses chunked transfer encoding. - res.writeHead( - responseStatus, - mergeVaryHeader({ ...baseHeaders, "Content-Encoding": encoding }, "Accept-Encoding"), - ); - if (omitBody || req.method === "HEAD") { - res.end(); - return true; - } - const compressor = createCompressor(encoding); - pipeline(fs.createReadStream(resolved.path), compressor, res, (err) => { - if (err) { - // Headers already sent — can't write a 500. Destroy the connection - // so the client sees a reset instead of a truncated response. - console.warn(`[vinext] Static file stream error for ${resolved.path}:`, err.message); - res.destroy(err); - } - }); - return true; - } - } + const range = resolveRequestedRange( + requestRange, + ifRange, + resolved.size, + etag, + resolved.mtimeMs, + responseStatus, + ); + const encoding = isCompressible ? negotiateEncoding(req) : "identity"; const ifNoneMatch = req.headers["if-none-match"]; if ( responseStatus === 200 && typeof ifNoneMatch === "string" && matchesIfNoneMatchHeader(ifNoneMatch, etag) ) { + const notModifiedHeaders = isCompressible + ? mergeVaryHeader(baseHeaders, "Accept-Encoding") + : baseHeaders; + if (encoding !== "identity") notModifiedHeaders["Content-Encoding"] = encoding; + res.writeHead(304, notModifiedHeaders); + res.end(); + return true; + } + + if (range.kind === "unsatisfiable") { + res.writeHead(416, { + ...baseHeaders, + "Content-Range": `bytes */${resolved.size}`, + }); + res.end(); + return true; + } + + if (range.kind === "range") { + const length = range.end - range.start + 1; + res.writeHead(206, { + ...baseHeaders, + "Content-Length": String(length), + "Content-Range": `bytes ${range.start}-${range.end}/${resolved.size}`, + }); + pipeStaticFileRange(resolved.path, range, res); + return true; + } + + if (isCompressible && encoding !== "identity") { + // Content-Length omitted intentionally: compressed size isn't known + // ahead of time, so Node.js uses chunked transfer encoding. res.writeHead( - 304, - isCompressible ? mergeVaryHeader(baseHeaders, "Accept-Encoding") : baseHeaders, + responseStatus, + mergeVaryHeader({ ...baseHeaders, "Content-Encoding": encoding }, "Accept-Encoding"), ); - res.end(); + if (omitBody || req.method === "HEAD") { + res.end(); + return true; + } + const compressor = createCompressor(encoding); + pipeline(fs.createReadStream(resolved.path), compressor, res, (err) => { + if (err) { + // Headers already sent — can't write a 500. Destroy the connection + // so the client sees a reset instead of a truncated response. + console.warn(`[vinext] Static file stream error for ${resolved.path}:`, err.message); + res.destroy(err); + } + }); return true; } @@ -790,6 +855,33 @@ async function tryServeStatic( return true; } +function resolveRequestedRange( + rangeHeader: string | undefined, + ifRangeHeader: string | undefined, + size: number, + etag: string, + mtimeMs: number, + responseStatus: number, +): ByteRange { + if (responseStatus !== 200 || !ifRangeAllowsRange(ifRangeHeader, etag, mtimeMs)) { + return { kind: "ignore" }; + } + return parseByteRange(rangeHeader, size); +} + +function pipeStaticFileRange( + filePath: string, + range: Extract, + res: ServerResponse, +): void { + pipeline(fs.createReadStream(filePath, { start: range.start, end: range.end }), res, (err) => { + if (err) { + console.warn(`[vinext] Static file range stream error for ${filePath}:`, err.message); + res.destroy(err); + } + }); +} + type ResolvedFile = { path: string; size: number; diff --git a/packages/vinext/src/server/static-file-cache.ts b/packages/vinext/src/server/static-file-cache.ts index c769e86721..7aca065dea 100644 --- a/packages/vinext/src/server/static-file-cache.ts +++ b/packages/vinext/src/server/static-file-cache.ts @@ -62,6 +62,8 @@ type FileVariant = { type StaticFileEntry = { /** Weak ETag for conditional request matching. */ etag: string; + /** Original file modification time for If-Range date validation. */ + mtimeMs: number; /** Pre-computed headers for 304 Not Modified response. */ notModifiedHeaders: Record; /** Original file variant (uncompressed). */ @@ -143,10 +145,12 @@ export class StaticFileCache { `W/"${fileInfo.size}-${Math.floor(fileInfo.mtimeMs / 1000)}"`; // Base headers shared by all variants (Content-Type, Cache-Control, ETag) + const lastModified = new Date(fileInfo.mtimeMs).toUTCString(); const baseHeaders = { "Content-Type": contentType, "Cache-Control": cacheControl, ETag: etag, + "Last-Modified": lastModified, }; // Pre-compute original variant headers @@ -158,7 +162,12 @@ export class StaticFileCache { const entry: StaticFileEntry = { etag, - notModifiedHeaders: { ETag: etag, "Cache-Control": cacheControl }, + mtimeMs: fileInfo.mtimeMs, + notModifiedHeaders: { + ETag: etag, + "Cache-Control": cacheControl, + "Last-Modified": lastModified, + }, original, }; diff --git a/tests/app-router-production-server.test.ts b/tests/app-router-production-server.test.ts index d35b65a78a..f902fc9703 100644 --- a/tests/app-router-production-server.test.ts +++ b/tests/app-router-production-server.test.ts @@ -330,6 +330,41 @@ describe("App Router Production server (startProdServer)", () => { expect(html).toContain(" { + const html = await (await fetch(`${baseUrl}/`)).text(); + const href = html.match(/["'](\/_next\/static\/[^"']+\.(?:js|css))["']/)?.[1]; + if (!href) throw new Error("Expected the production HTML to reference a static asset"); + + const assetUrl = new URL(href, baseUrl); + const full = await fetch(assetUrl); + const fullBody = new Uint8Array(await full.arrayBuffer()); + expect(fullBody.byteLength).toBeGreaterThan(10); + expect(full.headers.get("accept-ranges")).toBe("bytes"); + expect(full.headers.get("last-modified")).not.toBeNull(); + + const partial = await fetch(assetUrl, { + headers: { Range: "bytes=2-9", "Accept-Encoding": "br, gzip" }, + }); + expect(partial.status).toBe(206); + expect(partial.headers.get("content-range")).toBe(`bytes 2-9/${fullBody.byteLength}`); + expect(partial.headers.get("content-length")).toBe("8"); + expect(partial.headers.get("content-encoding")).toBeNull(); + expect(new Uint8Array(await partial.arrayBuffer())).toEqual(fullBody.subarray(2, 10)); + + const unsatisfiable = await fetch(assetUrl, { + headers: { Range: `bytes=${fullBody.byteLength}-` }, + }); + expect(unsatisfiable.status).toBe(416); + expect(unsatisfiable.headers.get("content-range")).toBe(`bytes */${fullBody.byteLength}`); + + const head = await fetch(assetUrl, { + method: "HEAD", + headers: { Range: "bytes=2-9" }, + }); + expect(head.status).toBe(200); + expect(head.headers.get("content-length")).toBe(String(fullBody.byteLength)); + }); + // Ported from Next.js: test/e2e/app-dir/app-static/app-static.test.ts // https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/app-static/app-static.test.ts it("contains dynamic = 'error' failures without terminating later App Router requests", async () => { diff --git a/tests/http-range.test.ts b/tests/http-range.test.ts new file mode 100644 index 0000000000..ad7b594e0c --- /dev/null +++ b/tests/http-range.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { ifRangeAllowsRange, parseByteRange } from "../packages/vinext/src/server/http-range.js"; + +describe("parseByteRange", () => { + it("parses bounded and open-ended ranges", () => { + expect(parseByteRange("bytes=2-5", 10)).toEqual({ kind: "range", start: 2, end: 5 }); + expect(parseByteRange("bytes=7-", 10)).toEqual({ kind: "range", start: 7, end: 9 }); + }); + + it("parses suffix ranges and clamps them to the representation", () => { + expect(parseByteRange("bytes=-3", 10)).toEqual({ kind: "range", start: 7, end: 9 }); + expect(parseByteRange("bytes=-20", 10)).toEqual({ kind: "range", start: 0, end: 9 }); + }); + + it("parses case-insensitive range units and clamps an oversized end", () => { + expect(parseByteRange("BYTES=8-99", 10)).toEqual({ kind: "range", start: 8, end: 9 }); + }); + + it("marks valid but impossible ranges unsatisfiable", () => { + expect(parseByteRange("bytes=10-", 10)).toEqual({ kind: "unsatisfiable" }); + expect(parseByteRange("bytes=7-3", 10)).toEqual({ kind: "unsatisfiable" }); + expect(parseByteRange("bytes=-0", 10)).toEqual({ kind: "unsatisfiable" }); + expect(parseByteRange("bytes=0-", 0)).toEqual({ kind: "unsatisfiable" }); + }); + + it("ignores malformed, unsupported, and multipart ranges", () => { + expect(parseByteRange(undefined, 10)).toEqual({ kind: "ignore" }); + expect(parseByteRange("items=0-2", 10)).toEqual({ kind: "ignore" }); + expect(parseByteRange("bytes=abc", 10)).toEqual({ kind: "ignore" }); + expect(parseByteRange("bytes=0-1,4-5", 10)).toEqual({ kind: "ignore" }); + expect(parseByteRange("bytes=9007199254740992-", 10)).toEqual({ kind: "ignore" }); + }); +}); + +describe("ifRangeAllowsRange", () => { + const mtimeMs = Date.parse("2026-01-01T00:00:00.500Z"); + + it("accepts an absent validator and an identical strong ETag", () => { + expect(ifRangeAllowsRange(undefined, '"asset"', mtimeMs)).toBe(true); + expect(ifRangeAllowsRange('"asset"', '"asset"', mtimeMs)).toBe(true); + }); + + it("rejects weak or different entity tags", () => { + expect(ifRangeAllowsRange('W/"asset"', '"asset"', mtimeMs)).toBe(false); + expect(ifRangeAllowsRange('"asset"', 'W/"asset"', mtimeMs)).toBe(false); + expect(ifRangeAllowsRange('"other"', '"asset"', mtimeMs)).toBe(false); + }); + + it("accepts dates at or after the whole-second filesystem mtime", () => { + expect(ifRangeAllowsRange("Thu, 01 Jan 2026 00:00:00 GMT", '"asset"', mtimeMs)).toBe(true); + expect(ifRangeAllowsRange("Thu, 01 Jan 2026 00:00:01 GMT", '"asset"', mtimeMs)).toBe(true); + }); + + it("rejects stale or invalid dates", () => { + expect(ifRangeAllowsRange("Wed, 31 Dec 2025 23:59:59 GMT", '"asset"', mtimeMs)).toBe(false); + expect(ifRangeAllowsRange("not-a-date", '"asset"', mtimeMs)).toBe(false); + }); +}); diff --git a/tests/static-file-cache.test.ts b/tests/static-file-cache.test.ts index e3587b9cf7..31bd88f300 100644 --- a/tests/static-file-cache.test.ts +++ b/tests/static-file-cache.test.ts @@ -76,6 +76,7 @@ describe("StaticFileCache", () => { expect(entry).toBeDefined(); expect(entry!.original.headers["Content-Type"]).toBe("application/javascript"); expect(entry!.original.headers["Content-Length"]).toBe("12"); // "const x = 1;" + expect(Date.parse(entry!.original.headers["Last-Modified"])).not.toBeNaN(); expect(entry!.original.path).toBe( toSlash(path.join(clientDir, "_next/static/index-abc123.js")), ); From 4db2e69062f7f57b08f1572f8524b9f32a29c069 Mon Sep 17 00:00:00 2001 From: Boy Steven Benaya Aritonang Date: Sun, 26 Jul 2026 23:37:39 +0700 Subject: [PATCH 03/30] fix(server): validate If-Range HTTP dates --- packages/vinext/src/server/http-range.ts | 22 +++++++++++++++++++++- tests/http-range.test.ts | 7 +++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/server/http-range.ts b/packages/vinext/src/server/http-range.ts index 08fb72db1c..cd29c24d56 100644 --- a/packages/vinext/src/server/http-range.ts +++ b/packages/vinext/src/server/http-range.ts @@ -55,10 +55,30 @@ export function ifRangeAllowsRange( return !trimmed.startsWith("W/") && !etag.startsWith("W/") && trimmed === etag; } - const timestamp = Date.parse(trimmed); + const timestamp = parseHttpDate(trimmed); return Number.isFinite(timestamp) && Math.floor(mtimeMs / 1000) * 1000 <= timestamp; } +const HTTP_WEEKDAY = "(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)"; +const HTTP_WEEKDAY_LONG = "(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)"; +const HTTP_MONTH = "(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)"; +const IMF_FIXDATE_RE = new RegExp( + `^${HTTP_WEEKDAY}, \\d{2} ${HTTP_MONTH} \\d{4} \\d{2}:\\d{2}:\\d{2} GMT$`, +); +const RFC850_DATE_RE = new RegExp( + `^${HTTP_WEEKDAY_LONG}, \\d{2}-${HTTP_MONTH}-\\d{2} \\d{2}:\\d{2}:\\d{2} GMT$`, +); +const ASCTIME_DATE_RE = new RegExp( + `^${HTTP_WEEKDAY} ${HTTP_MONTH} (?: \\d|\\d{2}) \\d{2}:\\d{2}:\\d{2} \\d{4}$`, +); + +/** Parse only the three HTTP-date wire formats accepted by RFC 9110. */ +function parseHttpDate(value: string): number { + if (ASCTIME_DATE_RE.test(value)) return Date.parse(`${value} GMT`); + if (!IMF_FIXDATE_RE.test(value) && !RFC850_DATE_RE.test(value)) return Number.NaN; + return Date.parse(value); +} + function parseDecimalInteger(value: string): number | null { if (!/^\d+$/.test(value)) return null; const parsed = Number(value); diff --git a/tests/http-range.test.ts b/tests/http-range.test.ts index ad7b594e0c..3b915ffc1d 100644 --- a/tests/http-range.test.ts +++ b/tests/http-range.test.ts @@ -54,5 +54,12 @@ describe("ifRangeAllowsRange", () => { it("rejects stale or invalid dates", () => { expect(ifRangeAllowsRange("Wed, 31 Dec 2025 23:59:59 GMT", '"asset"', mtimeMs)).toBe(false); expect(ifRangeAllowsRange("not-a-date", '"asset"', mtimeMs)).toBe(false); + expect(ifRangeAllowsRange("12/31/2099", '"asset"', mtimeMs)).toBe(false); + expect(ifRangeAllowsRange("2099-12-31", '"asset"', mtimeMs)).toBe(false); + }); + + it("accepts obsolete HTTP-date formats required for recipients", () => { + expect(ifRangeAllowsRange("Thursday, 01-Jan-26 00:00:01 GMT", '"asset"', mtimeMs)).toBe(true); + expect(ifRangeAllowsRange("Thu Jan 1 00:00:01 2026", '"asset"', mtimeMs)).toBe(true); }); }); 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 04/30] chore: retrigger CI From a61586f32b99b0d483f57b823cbbfd39273b5d0c Mon Sep 17 00:00:00 2001 From: Boy Steven Benaya Aritonang Date: Sun, 26 Jul 2026 23:44:18 +0700 Subject: [PATCH 05/30] chore: retrigger CI From 08a4f0036520c001a6d8c097821c77505fd377f0 Mon Sep 17 00:00:00 2001 From: Boy Steven Benaya Aritonang Date: Sun, 26 Jul 2026 23:49:16 +0700 Subject: [PATCH 06/30] chore: retrigger flaky E2E From 9ce704a7c88689e5b90b04a5b2b1c5d95b31eab1 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 11:39:27 +0100 Subject: [PATCH 07/30] 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 08/30] 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 09/30] 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 10/30] 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 11/30] 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 7f52c55220141f763fe4363422257488797d975d Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 12:03:17 +0100 Subject: [PATCH 12/30] fix(server): preserve byte range wire semantics --- packages/vinext/src/server/http-range.ts | 139 ++++++++++++++++++--- tests/app-router-production-server.test.ts | 21 +++- tests/http-range.test.ts | 18 ++- tests/serve-static.test.ts | 76 +++++++++++ 4 files changed, 232 insertions(+), 22 deletions(-) diff --git a/packages/vinext/src/server/http-range.ts b/packages/vinext/src/server/http-range.ts index cd29c24d56..8347cd1b1a 100644 --- a/packages/vinext/src/server/http-range.ts +++ b/packages/vinext/src/server/http-range.ts @@ -18,7 +18,10 @@ export function parseByteRange(value: string | undefined, size: number): ByteRan if (!match[1]) { const suffixLength = parseDecimalInteger(match[2]); - if (suffixLength === null) return { kind: "ignore" }; + if (suffixLength === "overflow") { + if (size === 0) return { kind: "unsatisfiable" }; + return { kind: "range", start: 0, end: size - 1 }; + } if (suffixLength === 0 || size === 0) return { kind: "unsatisfiable" }; return { kind: "range", @@ -29,7 +32,14 @@ export function parseByteRange(value: string | undefined, size: number): ByteRan const start = parseDecimalInteger(match[1]); const requestedEnd = match[2] ? parseDecimalInteger(match[2]) : size - 1; - if (start === null || requestedEnd === null) return { kind: "ignore" }; + // The wire grammar has no JavaScript-safe-integer limit. A start beyond + // that limit is necessarily beyond any file size Node can address, while an + // overflowing end still denotes the remainder of the representation. + if (start === "overflow") return { kind: "unsatisfiable" }; + if (requestedEnd === "overflow") { + if (start >= size) return { kind: "unsatisfiable" }; + return { kind: "range", start, end: size - 1 }; + } if (start >= size || requestedEnd < start) return { kind: "unsatisfiable" }; return { @@ -59,28 +69,117 @@ export function ifRangeAllowsRange( return Number.isFinite(timestamp) && Math.floor(mtimeMs / 1000) * 1000 <= timestamp; } -const HTTP_WEEKDAY = "(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)"; -const HTTP_WEEKDAY_LONG = "(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)"; -const HTTP_MONTH = "(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)"; -const IMF_FIXDATE_RE = new RegExp( - `^${HTTP_WEEKDAY}, \\d{2} ${HTTP_MONTH} \\d{4} \\d{2}:\\d{2}:\\d{2} GMT$`, -); -const RFC850_DATE_RE = new RegExp( - `^${HTTP_WEEKDAY_LONG}, \\d{2}-${HTTP_MONTH}-\\d{2} \\d{2}:\\d{2}:\\d{2} GMT$`, -); -const ASCTIME_DATE_RE = new RegExp( - `^${HTTP_WEEKDAY} ${HTTP_MONTH} (?: \\d|\\d{2}) \\d{2}:\\d{2}:\\d{2} \\d{4}$`, -); +const IMF_FIXDATE_RE = + /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/; +const RFC850_DATE_RE = + /^(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\d{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/; +const ASCTIME_DATE_RE = + /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (?: (\d)|(\d{2})) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/; + +const WEEKDAY_INDEX: Record = { + Sun: 0, + Sunday: 0, + Mon: 1, + Monday: 1, + Tue: 2, + Tuesday: 2, + Wed: 3, + Wednesday: 3, + Thu: 4, + Thursday: 4, + Fri: 5, + Friday: 5, + Sat: 6, + Saturday: 6, +}; +const MONTH_INDEX: Record = { + Jan: 0, + Feb: 1, + Mar: 2, + Apr: 3, + May: 4, + Jun: 5, + Jul: 6, + Aug: 7, + Sep: 8, + Oct: 9, + Nov: 10, + Dec: 11, +}; /** Parse only the three HTTP-date wire formats accepted by RFC 9110. */ function parseHttpDate(value: string): number { - if (ASCTIME_DATE_RE.test(value)) return Date.parse(`${value} GMT`); - if (!IMF_FIXDATE_RE.test(value) && !RFC850_DATE_RE.test(value)) return Number.NaN; - return Date.parse(value); + const imf = IMF_FIXDATE_RE.exec(value); + if (imf) { + return timestampFromHttpDateParts(imf[1], imf[2], imf[3], imf[4], imf[5], imf[6], imf[7]); + } + + const rfc850 = RFC850_DATE_RE.exec(value); + if (rfc850) { + const currentYear = new Date().getUTCFullYear(); + let year = currentYear - (currentYear % 100) + Number(rfc850[4]); + if (year > currentYear + 50) year -= 100; + return timestampFromHttpDateParts( + rfc850[1], + rfc850[2], + rfc850[3], + String(year), + rfc850[5], + rfc850[6], + rfc850[7], + ); + } + + const asctime = ASCTIME_DATE_RE.exec(value); + if (asctime) { + return timestampFromHttpDateParts( + asctime[1], + asctime[3] || asctime[4], + asctime[2], + asctime[8], + asctime[5], + asctime[6], + asctime[7], + ); + } + + return Number.NaN; +} + +function timestampFromHttpDateParts( + weekday: string, + dayValue: string, + monthValue: string, + yearValue: string, + hourValue: string, + minuteValue: string, + secondValue: string, +): number { + const day = Number(dayValue); + const month = MONTH_INDEX[monthValue]; + const year = Number(yearValue); + const hour = Number(hourValue); + const minute = Number(minuteValue); + const second = Number(secondValue); + + const date = new Date(0); + date.setUTCFullYear(year, month, day); + date.setUTCHours(hour, minute, second, 0); + if ( + date.getUTCFullYear() !== year || + date.getUTCMonth() !== month || + date.getUTCDate() !== day || + date.getUTCHours() !== hour || + date.getUTCMinutes() !== minute || + date.getUTCSeconds() !== second || + date.getUTCDay() !== WEEKDAY_INDEX[weekday] + ) { + return Number.NaN; + } + return date.getTime(); } -function parseDecimalInteger(value: string): number | null { - if (!/^\d+$/.test(value)) return null; +function parseDecimalInteger(value: string): number | "overflow" { const parsed = Number(value); - return Number.isSafeInteger(parsed) ? parsed : null; + return Number.isSafeInteger(parsed) ? parsed : "overflow"; } diff --git a/tests/app-router-production-server.test.ts b/tests/app-router-production-server.test.ts index f902fc9703..886b3a74b9 100644 --- a/tests/app-router-production-server.test.ts +++ b/tests/app-router-production-server.test.ts @@ -351,8 +351,27 @@ describe("App Router Production server (startProdServer)", () => { expect(partial.headers.get("content-encoding")).toBeNull(); expect(new Uint8Array(await partial.arrayBuffer())).toEqual(fullBody.subarray(2, 10)); + const hugeEnd = await fetch(assetUrl, { + headers: { Range: "bytes=2-9007199254740992" }, + }); + expect(hugeEnd.status).toBe(206); + expect(hugeEnd.headers.get("content-range")).toBe( + `bytes 2-${fullBody.length - 1}/${fullBody.length}`, + ); + expect(new Uint8Array(await hugeEnd.arrayBuffer())).toEqual(fullBody.subarray(2)); + + const invalidIfRange = await fetch(assetUrl, { + headers: { + Range: "bytes=2-9", + "If-Range": "Sun, 31 Feb 2099 00:00:00 GMT", + }, + }); + expect(invalidIfRange.status).toBe(200); + expect(invalidIfRange.headers.get("content-range")).toBeNull(); + expect(new Uint8Array(await invalidIfRange.arrayBuffer())).toEqual(fullBody); + const unsatisfiable = await fetch(assetUrl, { - headers: { Range: `bytes=${fullBody.byteLength}-` }, + headers: { Range: "bytes=9007199254740992-" }, }); expect(unsatisfiable.status).toBe(416); expect(unsatisfiable.headers.get("content-range")).toBe(`bytes */${fullBody.byteLength}`); diff --git a/tests/http-range.test.ts b/tests/http-range.test.ts index 3b915ffc1d..169419e45e 100644 --- a/tests/http-range.test.ts +++ b/tests/http-range.test.ts @@ -28,7 +28,21 @@ describe("parseByteRange", () => { expect(parseByteRange("items=0-2", 10)).toEqual({ kind: "ignore" }); expect(parseByteRange("bytes=abc", 10)).toEqual({ kind: "ignore" }); expect(parseByteRange("bytes=0-1,4-5", 10)).toEqual({ kind: "ignore" }); - expect(parseByteRange("bytes=9007199254740992-", 10)).toEqual({ kind: "ignore" }); + }); + + it("preserves valid range semantics above Number.MAX_SAFE_INTEGER", () => { + expect(parseByteRange("bytes=9007199254740992-", 10)).toEqual({ kind: "unsatisfiable" }); + expect(parseByteRange("bytes=2-9007199254740992", 10)).toEqual({ + kind: "range", + start: 2, + end: 9, + }); + expect(parseByteRange("bytes=-9007199254740992", 10)).toEqual({ + kind: "range", + start: 0, + end: 9, + }); + expect(parseByteRange("bytes=-9007199254740992", 0)).toEqual({ kind: "unsatisfiable" }); }); }); @@ -56,6 +70,8 @@ describe("ifRangeAllowsRange", () => { expect(ifRangeAllowsRange("not-a-date", '"asset"', mtimeMs)).toBe(false); expect(ifRangeAllowsRange("12/31/2099", '"asset"', mtimeMs)).toBe(false); expect(ifRangeAllowsRange("2099-12-31", '"asset"', mtimeMs)).toBe(false); + expect(ifRangeAllowsRange("Sun, 31 Feb 2099 00:00:00 GMT", '"asset"', mtimeMs)).toBe(false); + expect(ifRangeAllowsRange("Fri, 01 Jan 2026 00:00:00 GMT", '"asset"', mtimeMs)).toBe(false); }); it("accepts obsolete HTTP-date formats required for recipients", () => { diff --git a/tests/serve-static.test.ts b/tests/serve-static.test.ts index f38b75850a..2333865c91 100644 --- a/tests/serve-static.test.ts +++ b/tests/serve-static.test.ts @@ -710,6 +710,60 @@ describe("tryServeStatic (with StaticFileCache)", () => { expect(captured.headers.Vary).toBeUndefined(); }); + it("serves cached ranges with conditional precedence and lossless large integers", async () => { + const relativePath = "_next/static/range-aaa111.js"; + const content = "0123456789"; + await writeFile(clientDir, relativePath, content); + const cache = await StaticFileCache.create(clientDir); + + const initialReq = mockReq(); + const { res: initialRes, captured: initial } = mockRes(); + await tryServeStatic(initialReq, initialRes, clientDir, `/${relativePath}`, true, cache); + await initial.ended; + + const conditionalReq = mockReq(undefined, { + range: "bytes=2-9007199254740992", + "if-none-match": initial.headers.ETag as string, + }); + const { res: conditionalRes, captured: conditional } = mockRes(); + await tryServeStatic( + conditionalReq, + conditionalRes, + clientDir, + `/${relativePath}`, + true, + cache, + ); + await conditional.ended; + expect(conditional.status).toBe(304); + expect(conditional.body).toHaveLength(0); + + const rangeReq = mockReq("br, gzip", { range: "bytes=2-9007199254740992" }); + const { res: rangeRes, captured: range } = mockRes(); + await tryServeStatic(rangeReq, rangeRes, clientDir, `/${relativePath}`, true, cache); + await range.ended; + expect(range.status).toBe(206); + expect(range.headers["Content-Range"]).toBe("bytes 2-9/10"); + expect(range.headers["Content-Length"]).toBe("8"); + expect(range.headers["Content-Encoding"]).toBeUndefined(); + expect(range.body.toString()).toBe("23456789"); + + const unsatisfiableReq = mockReq(undefined, { range: "bytes=9007199254740992-" }); + const { res: unsatisfiableRes, captured: unsatisfiable } = mockRes(); + await tryServeStatic( + unsatisfiableReq, + unsatisfiableRes, + clientDir, + `/${relativePath}`, + true, + cache, + ); + await unsatisfiable.ended; + expect(unsatisfiable.status).toBe(416); + expect(unsatisfiable.headers["Content-Range"]).toBe("bytes */10"); + expect(unsatisfiable.body).toHaveLength(0); + }); + // ── Slow path (no cache) ─────────────────────────────────────── it("slow path serves static file without cache", async () => { @@ -733,6 +787,28 @@ describe("tryServeStatic (with StaticFileCache)", () => { expect(captured.body.toString()).toBe("slow path content"); }); + it("slow path rejects ranges for empty files and ignores invalid If-Range dates", async () => { + await writeFile(clientDir, "empty.txt", ""); + const emptyReq = mockReq(undefined, { range: "bytes=-9007199254740992" }); + const { res: emptyRes, captured: empty } = mockRes(); + await tryServeStatic(emptyReq, emptyRes, clientDir, "/empty.txt", false); + await empty.ended; + expect(empty.status).toBe(416); + expect(empty.headers["Content-Range"]).toBe("bytes */0"); + + await writeFile(clientDir, "if-range.txt", "0123456789"); + const invalidDateReq = mockReq(undefined, { + range: "bytes=2-5", + "if-range": "Sun, 31 Feb 2099 00:00:00 GMT", + }); + const { res: invalidDateRes, captured: invalidDate } = mockRes(); + await tryServeStatic(invalidDateReq, invalidDateRes, clientDir, "/if-range.txt", false); + await invalidDate.ended; + expect(invalidDate.status).toBe(200); + expect(invalidDate.headers["Content-Range"]).toBeUndefined(); + expect(invalidDate.body.toString()).toBe("0123456789"); + }); + it("slow path does not vary non-compressible files", async () => { await writeFile(clientDir, "uncached-photo.png", Buffer.alloc(100)); const req = mockReq("gzip"); From 0d30107118809e2eb857db9c9900c88036e730eb Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 12:09:29 +0100 Subject: [PATCH 13/30] fix(server): preserve partial response validators --- packages/vinext/src/server/http-range.ts | 2 +- packages/vinext/src/server/prod-server.ts | 16 ++++++++++++---- tests/app-router-production-server.test.ts | 19 +++++++++++++++++++ tests/http-range.test.ts | 8 ++++---- tests/serve-static.test.ts | 11 +++++++++++ 5 files changed, 47 insertions(+), 9 deletions(-) diff --git a/packages/vinext/src/server/http-range.ts b/packages/vinext/src/server/http-range.ts index 8347cd1b1a..0acb77f2df 100644 --- a/packages/vinext/src/server/http-range.ts +++ b/packages/vinext/src/server/http-range.ts @@ -66,7 +66,7 @@ export function ifRangeAllowsRange( } const timestamp = parseHttpDate(trimmed); - return Number.isFinite(timestamp) && Math.floor(mtimeMs / 1000) * 1000 <= timestamp; + return Number.isFinite(timestamp) && Math.floor(mtimeMs / 1000) * 1000 === timestamp; } const IMF_FIXDATE_RE = diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 550985f2f3..616e54ca6d 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -672,13 +672,17 @@ async function tryServeStatic( if (range.kind === "range") { const length = range.end - range.start + 1; - res.writeHead(206, { + const rangeHeaders = { ...entry.original.headers, ...extraHeaders, "Accept-Ranges": "bytes", "Content-Length": String(length), "Content-Range": `bytes ${range.start}-${range.end}/${entry.original.size}`, - }); + }; + res.writeHead( + 206, + variesByEncoding ? mergeVaryHeader(rangeHeaders, "Accept-Encoding") : rangeHeaders, + ); if (entry.original.buffer) { res.end(entry.original.buffer.subarray(range.start, range.end + 1)); } else { @@ -800,11 +804,15 @@ async function tryServeStatic( if (range.kind === "range") { const length = range.end - range.start + 1; - res.writeHead(206, { + const rangeHeaders = { ...baseHeaders, "Content-Length": String(length), "Content-Range": `bytes ${range.start}-${range.end}/${resolved.size}`, - }); + }; + res.writeHead( + 206, + isCompressible ? mergeVaryHeader(rangeHeaders, "Accept-Encoding") : rangeHeaders, + ); pipeStaticFileRange(resolved.path, range, res); return true; } diff --git a/tests/app-router-production-server.test.ts b/tests/app-router-production-server.test.ts index 886b3a74b9..e927b0334b 100644 --- a/tests/app-router-production-server.test.ts +++ b/tests/app-router-production-server.test.ts @@ -360,6 +360,25 @@ describe("App Router Production server (startProdServer)", () => { ); expect(new Uint8Array(await hugeEnd.arrayBuffer())).toEqual(fullBody.subarray(2)); + const matchingIfRange = await fetch(assetUrl, { + headers: { + Range: "bytes=2-9", + "If-Range": full.headers.get("last-modified")!, + }, + }); + expect(matchingIfRange.status).toBe(206); + expect(new Uint8Array(await matchingIfRange.arrayBuffer())).toEqual(fullBody.subarray(2, 10)); + + const futureIfRange = await fetch(assetUrl, { + headers: { + Range: "bytes=2-9", + "If-Range": "Thu, 01 Jan 2099 00:00:00 GMT", + }, + }); + expect(futureIfRange.status).toBe(200); + expect(futureIfRange.headers.get("content-range")).toBeNull(); + expect(new Uint8Array(await futureIfRange.arrayBuffer())).toEqual(fullBody); + const invalidIfRange = await fetch(assetUrl, { headers: { Range: "bytes=2-9", diff --git a/tests/http-range.test.ts b/tests/http-range.test.ts index 169419e45e..302264c113 100644 --- a/tests/http-range.test.ts +++ b/tests/http-range.test.ts @@ -60,9 +60,9 @@ describe("ifRangeAllowsRange", () => { expect(ifRangeAllowsRange('"other"', '"asset"', mtimeMs)).toBe(false); }); - it("accepts dates at or after the whole-second filesystem mtime", () => { + it("accepts only an exact whole-second Last-Modified date", () => { expect(ifRangeAllowsRange("Thu, 01 Jan 2026 00:00:00 GMT", '"asset"', mtimeMs)).toBe(true); - expect(ifRangeAllowsRange("Thu, 01 Jan 2026 00:00:01 GMT", '"asset"', mtimeMs)).toBe(true); + expect(ifRangeAllowsRange("Thu, 01 Jan 2026 00:00:01 GMT", '"asset"', mtimeMs)).toBe(false); }); it("rejects stale or invalid dates", () => { @@ -75,7 +75,7 @@ describe("ifRangeAllowsRange", () => { }); it("accepts obsolete HTTP-date formats required for recipients", () => { - expect(ifRangeAllowsRange("Thursday, 01-Jan-26 00:00:01 GMT", '"asset"', mtimeMs)).toBe(true); - expect(ifRangeAllowsRange("Thu Jan 1 00:00:01 2026", '"asset"', mtimeMs)).toBe(true); + expect(ifRangeAllowsRange("Thursday, 01-Jan-26 00:00:00 GMT", '"asset"', mtimeMs)).toBe(true); + expect(ifRangeAllowsRange("Thu Jan 1 00:00:00 2026", '"asset"', mtimeMs)).toBe(true); }); }); diff --git a/tests/serve-static.test.ts b/tests/serve-static.test.ts index 2333865c91..e51d0e71bd 100644 --- a/tests/serve-static.test.ts +++ b/tests/serve-static.test.ts @@ -714,6 +714,7 @@ describe("tryServeStatic (with StaticFileCache)", () => { const relativePath = "_next/static/range-aaa111.js"; const content = "0123456789"; await writeFile(clientDir, relativePath, content); + await writeFile(clientDir, `${relativePath}.br`, zlib.brotliCompressSync(content)); const cache = await StaticFileCache.create(clientDir); const initialReq = mockReq(); @@ -746,6 +747,7 @@ describe("tryServeStatic (with StaticFileCache)", () => { expect(range.headers["Content-Range"]).toBe("bytes 2-9/10"); expect(range.headers["Content-Length"]).toBe("8"); expect(range.headers["Content-Encoding"]).toBeUndefined(); + expect(range.headers.Vary).toBe("Accept-Encoding"); expect(range.body.toString()).toBe("23456789"); const unsatisfiableReq = mockReq(undefined, { range: "bytes=9007199254740992-" }); @@ -807,6 +809,15 @@ describe("tryServeStatic (with StaticFileCache)", () => { expect(invalidDate.status).toBe(200); expect(invalidDate.headers["Content-Range"]).toBeUndefined(); expect(invalidDate.body.toString()).toBe("0123456789"); + + const rangeReq = mockReq("br", { range: "bytes=2-5" }); + const { res: rangeRes, captured: range } = mockRes(); + await tryServeStatic(rangeReq, rangeRes, clientDir, "/if-range.txt", true); + await range.ended; + expect(range.status).toBe(206); + expect(range.headers.Vary).toBe("Accept-Encoding"); + expect(range.headers["Content-Encoding"]).toBeUndefined(); + expect(range.body.toString()).toBe("2345"); }); it("slow path does not vary non-compressible files", async () => { From 4c958134889d59bfc270d17b2f8ab381f7dd0b1a Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 12:10:09 +0100 Subject: [PATCH 14/30] 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 ea7dbd533413025086abb5b517a4d0c9c346281e Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 12:10:39 +0100 Subject: [PATCH 15/30] fix(server): resolve obsolete HTTP date years --- packages/vinext/src/server/http-range.ts | 21 +++++++++++++++----- tests/http-range.test.ts | 25 +++++++++++++++++++++++- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/packages/vinext/src/server/http-range.ts b/packages/vinext/src/server/http-range.ts index 0acb77f2df..641f8906f4 100644 --- a/packages/vinext/src/server/http-range.ts +++ b/packages/vinext/src/server/http-range.ts @@ -116,9 +116,20 @@ function parseHttpDate(value: string): number { const rfc850 = RFC850_DATE_RE.exec(value); if (rfc850) { - const currentYear = new Date().getUTCFullYear(); - let year = currentYear - (currentYear % 100) + Number(rfc850[4]); - if (year > currentYear + 50) year -= 100; + const now = new Date(); + let year = now.getUTCFullYear() - (now.getUTCFullYear() % 100) + Number(rfc850[4]); + const candidateTimestamp = timestampFromHttpDateParts( + undefined, + rfc850[2], + rfc850[3], + String(year), + rfc850[5], + rfc850[6], + rfc850[7], + ); + const fiftyYearsFromNow = new Date(now); + fiftyYearsFromNow.setUTCFullYear(now.getUTCFullYear() + 50); + if (candidateTimestamp > fiftyYearsFromNow.getTime()) year -= 100; return timestampFromHttpDateParts( rfc850[1], rfc850[2], @@ -147,7 +158,7 @@ function parseHttpDate(value: string): number { } function timestampFromHttpDateParts( - weekday: string, + weekday: string | undefined, dayValue: string, monthValue: string, yearValue: string, @@ -172,7 +183,7 @@ function timestampFromHttpDateParts( date.getUTCHours() !== hour || date.getUTCMinutes() !== minute || date.getUTCSeconds() !== second || - date.getUTCDay() !== WEEKDAY_INDEX[weekday] + (weekday !== undefined && date.getUTCDay() !== WEEKDAY_INDEX[weekday]) ) { return Number.NaN; } diff --git a/tests/http-range.test.ts b/tests/http-range.test.ts index 302264c113..7c71609ced 100644 --- a/tests/http-range.test.ts +++ b/tests/http-range.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { ifRangeAllowsRange, parseByteRange } from "../packages/vinext/src/server/http-range.js"; describe("parseByteRange", () => { @@ -78,4 +78,27 @@ describe("ifRangeAllowsRange", () => { expect(ifRangeAllowsRange("Thursday, 01-Jan-26 00:00:00 GMT", '"asset"', mtimeMs)).toBe(true); expect(ifRangeAllowsRange("Thu Jan 1 00:00:00 2026", '"asset"', mtimeMs)).toBe(true); }); + + it("resolves an RFC 850 year from the full 50-year timestamp boundary", () => { + vi.useFakeTimers(); + vi.setSystemTime("2026-07-27T12:00:00Z"); + try { + expect( + ifRangeAllowsRange( + "Thursday, 31-Dec-76 00:00:00 GMT", + '"asset"', + Date.parse("2076-12-31T00:00:00Z"), + ), + ).toBe(false); + expect( + ifRangeAllowsRange( + "Friday, 31-Dec-76 00:00:00 GMT", + '"asset"', + Date.parse("1976-12-31T00:00:00Z"), + ), + ).toBe(true); + } finally { + vi.useRealTimers(); + } + }); }); From 510f8e0d7dd63d8363c545f1ad7ffb83ad05fce9 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 12:21:05 +0100 Subject: [PATCH 16/30] 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 17/30] 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 18/30] 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 19/30] 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 9e13f247210cf6999fb9eb86c698c32610b3f8db Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 12:34:11 +0100 Subject: [PATCH 20/30] refactor(server): share strict HTTP date parsing --- packages/vinext/src/server/http-date.ts | 120 ++++++++++++++++++++++ packages/vinext/src/server/http-range.ts | 123 +---------------------- tests/http-date.test.ts | 31 ++++++ 3 files changed, 153 insertions(+), 121 deletions(-) create mode 100644 packages/vinext/src/server/http-date.ts create mode 100644 tests/http-date.test.ts diff --git a/packages/vinext/src/server/http-date.ts b/packages/vinext/src/server/http-date.ts new file mode 100644 index 0000000000..1d477e7797 --- /dev/null +++ b/packages/vinext/src/server/http-date.ts @@ -0,0 +1,120 @@ +const IMF_FIXDATE_RE = + /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/; +const RFC850_DATE_RE = + /^(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\d{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/; +const ASCTIME_DATE_RE = + /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (?: (\d)|(\d{2})) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/; + +const WEEKDAY_INDEX: Record = { + Sun: 0, + Sunday: 0, + Mon: 1, + Monday: 1, + Tue: 2, + Tuesday: 2, + Wed: 3, + Wednesday: 3, + Thu: 4, + Thursday: 4, + Fri: 5, + Friday: 5, + Sat: 6, + Saturday: 6, +}; +const MONTH_INDEX: Record = { + Jan: 0, + Feb: 1, + Mar: 2, + Apr: 3, + May: 4, + Jun: 5, + Jul: 6, + Aug: 7, + Sep: 8, + Oct: 9, + Nov: 10, + Dec: 11, +}; + +/** Parse only the three HTTP-date wire formats accepted by RFC 9110. */ +export function parseHttpDate(value: string): number { + const imf = IMF_FIXDATE_RE.exec(value); + if (imf) { + return timestampFromHttpDateParts(imf[1], imf[2], imf[3], imf[4], imf[5], imf[6], imf[7]); + } + + const rfc850 = RFC850_DATE_RE.exec(value); + if (rfc850) { + const now = new Date(); + let year = now.getUTCFullYear() - (now.getUTCFullYear() % 100) + Number(rfc850[4]); + const candidateTimestamp = timestampFromHttpDateParts( + undefined, + rfc850[2], + rfc850[3], + String(year), + rfc850[5], + rfc850[6], + rfc850[7], + ); + const fiftyYearsFromNow = new Date(now); + fiftyYearsFromNow.setUTCFullYear(now.getUTCFullYear() + 50); + if (candidateTimestamp > fiftyYearsFromNow.getTime()) year -= 100; + return timestampFromHttpDateParts( + rfc850[1], + rfc850[2], + rfc850[3], + String(year), + rfc850[5], + rfc850[6], + rfc850[7], + ); + } + + const asctime = ASCTIME_DATE_RE.exec(value); + if (asctime) { + return timestampFromHttpDateParts( + asctime[1], + asctime[3] || asctime[4], + asctime[2], + asctime[8], + asctime[5], + asctime[6], + asctime[7], + ); + } + + return Number.NaN; +} + +function timestampFromHttpDateParts( + weekday: string | undefined, + dayValue: string, + monthValue: string, + yearValue: string, + hourValue: string, + minuteValue: string, + secondValue: string, +): number { + const day = Number(dayValue); + const month = MONTH_INDEX[monthValue]; + const year = Number(yearValue); + const hour = Number(hourValue); + const minute = Number(minuteValue); + const second = Number(secondValue); + + const date = new Date(0); + date.setUTCFullYear(year, month, day); + date.setUTCHours(hour, minute, second, 0); + if ( + date.getUTCFullYear() !== year || + date.getUTCMonth() !== month || + date.getUTCDate() !== day || + date.getUTCHours() !== hour || + date.getUTCMinutes() !== minute || + date.getUTCSeconds() !== second || + (weekday !== undefined && date.getUTCDay() !== WEEKDAY_INDEX[weekday]) + ) { + return Number.NaN; + } + return date.getTime(); +} diff --git a/packages/vinext/src/server/http-range.ts b/packages/vinext/src/server/http-range.ts index 641f8906f4..789e8bd900 100644 --- a/packages/vinext/src/server/http-range.ts +++ b/packages/vinext/src/server/http-range.ts @@ -1,3 +1,5 @@ +import { parseHttpDate } from "./http-date.js"; + export type ByteRange = | { kind: "ignore" } | { kind: "unsatisfiable" } @@ -69,127 +71,6 @@ export function ifRangeAllowsRange( return Number.isFinite(timestamp) && Math.floor(mtimeMs / 1000) * 1000 === timestamp; } -const IMF_FIXDATE_RE = - /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/; -const RFC850_DATE_RE = - /^(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\d{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/; -const ASCTIME_DATE_RE = - /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (?: (\d)|(\d{2})) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/; - -const WEEKDAY_INDEX: Record = { - Sun: 0, - Sunday: 0, - Mon: 1, - Monday: 1, - Tue: 2, - Tuesday: 2, - Wed: 3, - Wednesday: 3, - Thu: 4, - Thursday: 4, - Fri: 5, - Friday: 5, - Sat: 6, - Saturday: 6, -}; -const MONTH_INDEX: Record = { - Jan: 0, - Feb: 1, - Mar: 2, - Apr: 3, - May: 4, - Jun: 5, - Jul: 6, - Aug: 7, - Sep: 8, - Oct: 9, - Nov: 10, - Dec: 11, -}; - -/** Parse only the three HTTP-date wire formats accepted by RFC 9110. */ -function parseHttpDate(value: string): number { - const imf = IMF_FIXDATE_RE.exec(value); - if (imf) { - return timestampFromHttpDateParts(imf[1], imf[2], imf[3], imf[4], imf[5], imf[6], imf[7]); - } - - const rfc850 = RFC850_DATE_RE.exec(value); - if (rfc850) { - const now = new Date(); - let year = now.getUTCFullYear() - (now.getUTCFullYear() % 100) + Number(rfc850[4]); - const candidateTimestamp = timestampFromHttpDateParts( - undefined, - rfc850[2], - rfc850[3], - String(year), - rfc850[5], - rfc850[6], - rfc850[7], - ); - const fiftyYearsFromNow = new Date(now); - fiftyYearsFromNow.setUTCFullYear(now.getUTCFullYear() + 50); - if (candidateTimestamp > fiftyYearsFromNow.getTime()) year -= 100; - return timestampFromHttpDateParts( - rfc850[1], - rfc850[2], - rfc850[3], - String(year), - rfc850[5], - rfc850[6], - rfc850[7], - ); - } - - const asctime = ASCTIME_DATE_RE.exec(value); - if (asctime) { - return timestampFromHttpDateParts( - asctime[1], - asctime[3] || asctime[4], - asctime[2], - asctime[8], - asctime[5], - asctime[6], - asctime[7], - ); - } - - return Number.NaN; -} - -function timestampFromHttpDateParts( - weekday: string | undefined, - dayValue: string, - monthValue: string, - yearValue: string, - hourValue: string, - minuteValue: string, - secondValue: string, -): number { - const day = Number(dayValue); - const month = MONTH_INDEX[monthValue]; - const year = Number(yearValue); - const hour = Number(hourValue); - const minute = Number(minuteValue); - const second = Number(secondValue); - - const date = new Date(0); - date.setUTCFullYear(year, month, day); - date.setUTCHours(hour, minute, second, 0); - if ( - date.getUTCFullYear() !== year || - date.getUTCMonth() !== month || - date.getUTCDate() !== day || - date.getUTCHours() !== hour || - date.getUTCMinutes() !== minute || - date.getUTCSeconds() !== second || - (weekday !== undefined && date.getUTCDay() !== WEEKDAY_INDEX[weekday]) - ) { - return Number.NaN; - } - return date.getTime(); -} - function parseDecimalInteger(value: string): number | "overflow" { const parsed = Number(value); return Number.isSafeInteger(parsed) ? parsed : "overflow"; diff --git a/tests/http-date.test.ts b/tests/http-date.test.ts new file mode 100644 index 0000000000..a1fa350adf --- /dev/null +++ b/tests/http-date.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from "vitest"; +import { parseHttpDate } from "../packages/vinext/src/server/http-date.js"; + +describe("parseHttpDate", () => { + const timestamp = Date.parse("2026-01-01T00:00:00Z"); + + it("parses all three RFC 9110 HTTP-date formats", () => { + expect(parseHttpDate("Thu, 01 Jan 2026 00:00:00 GMT")).toBe(timestamp); + expect(parseHttpDate("Thursday, 01-Jan-26 00:00:00 GMT")).toBe(timestamp); + expect(parseHttpDate("Thu Jan 1 00:00:00 2026")).toBe(timestamp); + }); + + it("rejects non-HTTP dates and normalized calendar or weekday mistakes", () => { + expect(parseHttpDate("2026-01-01T00:00:00Z")).toBeNaN(); + expect(parseHttpDate("Sun, 31 Feb 2099 00:00:00 GMT")).toBeNaN(); + expect(parseHttpDate("Fri, 01 Jan 2026 00:00:00 GMT")).toBeNaN(); + }); + + it("resolves an RFC 850 year using the full 50-year timestamp boundary", () => { + vi.useFakeTimers(); + vi.setSystemTime("2026-07-27T12:00:00Z"); + try { + expect(parseHttpDate("Thursday, 31-Dec-76 00:00:00 GMT")).toBeNaN(); + expect(parseHttpDate("Friday, 31-Dec-76 00:00:00 GMT")).toBe( + Date.parse("1976-12-31T00:00:00Z"), + ); + } finally { + vi.useRealTimers(); + } + }); +}); From ecc9fad1b977e06eefa0c1a5873cac7e4c53e103 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 12:40:14 +0100 Subject: [PATCH 21/30] 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 22/30] 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 23/30] 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 24/30] 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 25/30] 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 a8a36f3e6fd187d73405b072ce03211f9bde98f8 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 13:03:16 +0100 Subject: [PATCH 26/30] fix(dev): keep public ETag trie internal --- 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 4175bc8eff90f45f8d4da75dd7e5fec350d559fe Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 13:05:43 +0100 Subject: [PATCH 27/30] 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 1eb997d580c103ab19e5d4f1291c6b8c6cbc006e Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 13:15:01 +0100 Subject: [PATCH 28/30] 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 26f315db8f0c39a493fcee7150eb7a492eb5b180 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 13:19:22 +0100 Subject: [PATCH 29/30] 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 a256627c29f57ce69428e122217f8c0ef94bcb56 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 27 Jul 2026 15:42:50 +0100 Subject: [PATCH 30/30] fix(dev): fail closed on conflicting public redirects --- packages/vinext/src/server/dev-public-etag.ts | 4 +- tests/dev-public-etag.test.ts | 57 ++++++++++++++++++- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/server/dev-public-etag.ts b/packages/vinext/src/server/dev-public-etag.ts index d16b9a438f..9cf6fc9bba 100644 --- a/packages/vinext/src/server/dev-public-etag.ts +++ b/packages/vinext/src/server/dev-public-etag.ts @@ -20,7 +20,7 @@ type DevPublicPathNode = { asciiAliases: Map; unicodeAliases: Map; realPath?: string | null; - redirect?: string; + redirect?: string | null; }; type DevPublicAliasCandidate = { @@ -272,7 +272,7 @@ function indexRequestPath( } if (redirect) { if (node.redirect === undefined || node.redirect === redirect) node.redirect = redirect; - else node.realPath = null; + else node.redirect = null; } } diff --git a/tests/dev-public-etag.test.ts b/tests/dev-public-etag.test.ts index 2a3667c647..92687ff3f5 100644 --- a/tests/dev-public-etag.test.ts +++ b/tests/dev-public-etag.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -121,6 +121,61 @@ describe("resolveDevPublicIfNoneMatch", () => { }, ); + it("fails closed when one request path has conflicting symlink targets", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-redirect-conflict-")); + const publicDir = path.join(root, "public"); + const firstTarget = path.join(root, "first.js"); + const secondTarget = path.join(root, "second.js"); + const aliasPath = path.join(publicDir, "alias.js"); + try { + fs.mkdirSync(publicDir); + fs.writeFileSync(firstTarget, "first"); + fs.writeFileSync(secondTarget, "second"); + + // Model a filesystem race where the same directory entry resolves to + // different symlink targets across observations during the startup scan. + const originalReaddirSync = fs.readdirSync; + const readdirSpy = vi.spyOn(fs, "readdirSync").mockImplementation(((dir, options) => { + if (toSlash(String(dir)) !== toSlash(publicDir)) { + return originalReaddirSync(dir, options as never); + } + const entry = { + name: "alias.js", + isSymbolicLink: () => true, + isDirectory: () => false, + isFile: () => false, + }; + return [entry, entry]; + }) as typeof fs.readdirSync); + const originalRealpathNative = fs.realpathSync.native; + let aliasResolutionCount = 0; + const realpathSpy = vi.spyOn(fs.realpathSync, "native").mockImplementation((filePath) => { + if (toSlash(String(filePath)) === toSlash(aliasPath)) { + return aliasResolutionCount++ === 0 ? firstTarget : secondTarget; + } + return originalRealpathNative(filePath); + }); + const originalStatSync = fs.statSync; + const statSpy = vi + .spyOn(fs, "statSync") + .mockImplementation((filePath, options) => + toSlash(String(filePath)) === toSlash(aliasPath) + ? originalStatSync(firstTarget, options) + : originalStatSync(filePath, options), + ); + + const index = createDevPublicFileEtags(publicDir, false, false, true); + statSpy.mockRestore(); + realpathSpy.mockRestore(); + readdirSpy.mockRestore(); + + expect(resolveDevPublicIfNoneMatch("GET", "/alias.js", "*", index)).toBeUndefined(); + } finally { + vi.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + it.runIf(process.platform !== "win32")( "updates files reported through a directory symlink's real path", () => {