diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 2b6029131b..73d31a8c9a 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 { publicFilePathVariants } from "./utils/public-file-path.js"; import { methodNotAllowedResponse } from "./server/http-error-responses.js"; import type { Options as VitePluginReactOptions } from "@vitejs/plugin-react"; @@ -4279,11 +4284,50 @@ export const loadServerActionClient = ${ }, configureServer(server: ViteDevServer) { + const etagPublicDir = server.config.publicDir || undefined; + let devPublicFileEtags = etagPublicDir + ? createDevPublicFileEtags(etagPublicDir) + : undefined; + server.middlewares.use((req, _res, next) => { req.__vinextOriginalEncodedUrl ??= req.url; + if (!devPublicFileEtags) { + next(); + return; + } + const normalizedIfNoneMatch = resolveDevPublicIfNoneMatch( + req.method, + req.url, + req.headers["if-none-match"], + devPublicFileEtags, + nextConfig?.basePath, + ); + if (normalizedIfNoneMatch) { + // sirv compares If-None-Match to its generated weak ETag as an + // exact string. Resolve RFC weak/list/wildcard matching here, then + // give sirv the exact value so its normal 304 path handles the body. + req.headers["if-none-match"] = normalizedIfNoneMatch; + } next(); }); + const updateDevPublicEtag = (filePath: string) => { + if (!devPublicFileEtags || !etagPublicDir) return; + if (!updateDevPublicFileEtag(devPublicFileEtags, filePath)) { + devPublicFileEtags = createDevPublicFileEtags( + etagPublicDir, + undefined, + undefined, + devPublicFileEtags.viteUsesStatLookup, + ); + } + }; + server.watcher.on("add", updateDevPublicEtag); + server.watcher.on("change", updateDevPublicEtag); + server.watcher.on("unlink", updateDevPublicEtag); + server.watcher.on("addDir", updateDevPublicEtag); + server.watcher.on("unlinkDir", updateDevPublicEtag); + // Watch route files for additions/removals to invalidate route cache. const pageExtensions = fileMatcher.extensionRegex; diff --git a/packages/vinext/src/server/dev-public-etag.ts b/packages/vinext/src/server/dev-public-etag.ts new file mode 100644 index 0000000000..9cf6fc9bba --- /dev/null +++ b/packages/vinext/src/server/dev-public-etag.ts @@ -0,0 +1,551 @@ +import fs from "node:fs"; +import path, { toSlash } from "pathslash"; +import { hasBasePath, stripBasePath } from "../utils/base-path.js"; +import { matchesIfNoneMatch } from "./http-conditional.js"; +import { normalizePath } from "./normalize-path.js"; + +export type DevPublicFileEtagIndex = { + publicDir: string; + etagsByRealPath: Map; + requestPathRoot: DevPublicPathNode; + symlinkTargets: Map; + hasSymlink: boolean; + viteUsesStatLookup: boolean; + caseInsensitive: boolean; + normalizationInsensitive: boolean; +}; + +type DevPublicPathNode = { + children: Map; + asciiAliases: Map; + unicodeAliases: Map; + realPath?: string | null; + redirect?: string | null; +}; + +type DevPublicAliasCandidate = { + segment: string; + node: DevPublicPathNode; + simpleCasePattern: RegExp; + expandedCaseTokens: string[]; +}; + +const simpleCasePatterns = new Map(); + +export function createDevPublicFileEtags( + externalPublicDir: string, + caseInsensitive = detectCaseInsensitiveDirectory(externalPublicDir), + normalizationInsensitive = detectNormalizationInsensitiveDirectory(externalPublicDir), + viteUsesStatLookup?: boolean, +): DevPublicFileEtagIndex { + const publicDir = toSlash(externalPublicDir); + const index: DevPublicFileEtagIndex = { + publicDir, + etagsByRealPath: new Map(), + requestPathRoot: createPathNode(), + symlinkTargets: new Map(), + hasSymlink: false, + viteUsesStatLookup: false, + caseInsensitive, + normalizationInsensitive, + }; + + const walk = (dir: string, realAncestors: ReadonlySet): void => { + let realDir: string; + try { + realDir = toSlash(fs.realpathSync.native(dir)); + } catch { + return; + } + if (realAncestors.has(realDir)) return; + const nextAncestors = new Set(realAncestors).add(realDir); + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isSymbolicLink()) { + index.hasSymlink = true; + let realTarget: string; + let stats: fs.Stats; + try { + realTarget = toSlash(fs.realpathSync.native(fullPath)); + stats = fs.statSync(fullPath); + } catch { + continue; + } + indexSymlinkTarget(index, fullPath, realTarget); + if (stats.isDirectory()) { + walk(fullPath, nextAncestors); + } else if (stats.isFile()) { + indexFileEtag(index, realTarget, etagForStats(stats), fullPath); + } + continue; + } + if (entry.isDirectory()) { + walk(fullPath, nextAncestors); + continue; + } + if (!entry.isFile()) continue; + + try { + const realPath = toSlash(fs.realpathSync.native(fullPath)); + indexFileEtag(index, realPath, etagForStats(fs.statSync(fullPath)), fullPath); + } catch { + // Ignore entries removed during the startup scan. + } + } + }; + + if (fs.existsSync(publicDir)) { + const realPublicDir = toSlash(fs.realpathSync.native(publicDir)); + if (realPublicDir !== publicDir) index.symlinkTargets.set(publicDir, realPublicDir); + walk(publicDir, new Set()); + } + index.viteUsesStatLookup = viteUsesStatLookup ?? index.hasSymlink; + return index; +} + +/** + * Update a regular public file without rescanning the directory tree. + * Returns false when a structural change (directory, symlink, or removal) + * requires the caller to rebuild the index off the request path. + */ +export function updateDevPublicFileEtag( + index: DevPublicFileEtagIndex, + externalFilePath: string, +): boolean { + let filePath = toSlash(externalFilePath); + if (!isWithinPublicDir(index.publicDir, filePath)) { + try { + filePath = toSlash(fs.realpathSync.native(filePath)); + } catch { + // A removal behind a symlink requires a structural rebuild only when + // the watcher reports it through the public alias, handled above. + } + if ( + ![...index.symlinkTargets.values()].some( + (target) => filePath === target || filePath.startsWith(target + "/"), + ) + ) { + return true; + } + } + + try { + const lstat = fs.lstatSync(filePath); + if (lstat.isSymbolicLink() || !lstat.isFile()) return false; + if ( + (!index.caseInsensitive && pathHasCaseInsensitiveAlias(filePath)) || + (!index.normalizationInsensitive && pathHasNormalizationInsensitiveAlias(filePath)) + ) { + return false; + } + const realPath = toSlash(fs.realpathSync.native(filePath)); + const requestPaths = requestPathsForRealPath(index, filePath, realPath); + for (const requestPath of requestPaths) { + indexFileEtag(index, realPath, etagForStats(fs.statSync(filePath)), requestPath); + } + return true; + } catch { + return false; + } +} + +export function resolveDevPublicIfNoneMatch( + method: string | undefined, + requestUrl: string | undefined, + ifNoneMatch: string | string[] | undefined, + index: DevPublicFileEtagIndex, + basePath = "", +): string | undefined { + if ((method !== "GET" && method !== "HEAD") || typeof ifNoneMatch !== "string") { + return undefined; + } + + const queryIndex = requestUrl?.indexOf("?") ?? -1; + let pathname = (requestUrl ?? "/").slice(0, queryIndex === -1 ? undefined : queryIndex); + if (basePath) { + if (!hasBasePath(pathname, basePath)) return undefined; + pathname = stripBasePath(pathname, basePath); + } + try { + // Vite converts native separators on Windows before applying POSIX path + // normalization. `toSlash` is platform-gated, so a literal backslash + // remains a valid filename character on POSIX. + pathname = normalizePath(toSlash(decodeURI(pathname))); + } catch { + return undefined; + } + + let filePath = path.join(index.publicDir, pathname); + if (!isWithinPublicDir(index.publicDir, filePath)) return undefined; + // Vite normally guards public requests with an exact-spelling file set. It + // disables that optimization when the public tree contains a symlink and + // lets sirv consult the filesystem, which may then accept case aliases. + const supportsFoldedLookup = + index.viteUsesStatLookup && (index.caseInsensitive || index.normalizationInsensitive); + const realPath = resolveRequestPath(index, filePath, supportsFoldedLookup); + const etag = index.etagsByRealPath.get(realPath ?? filePath); + return etag && matchesIfNoneMatch(ifNoneMatch, etag) ? etag : undefined; +} + +function isWithinPublicDir(publicDir: string, filePath: string): boolean { + const relativePath = path.relative(publicDir, filePath); + return ( + relativePath !== "" && + relativePath !== ".." && + !relativePath.startsWith("../") && + !path.isAbsolute(relativePath) + ); +} + +function etagForStats(stats: fs.Stats): string { + // Vite's public-file middleware delegates to sirv, which uses this + // size/mtime weak validator in development. + return `W/"${stats.size}-${stats.mtime.getTime()}"`; +} + +function indexFileEtag( + index: DevPublicFileEtagIndex, + realPath: string, + etag: string, + requestPath: string, +): void { + index.etagsByRealPath.set(realPath, etag); + indexRequestPath(index, requestPath, realPath, undefined); +} + +function indexSymlinkTarget( + index: DevPublicFileEtagIndex, + requestPath: string, + realTarget: string, +): void { + index.symlinkTargets.set(requestPath, realTarget); + indexRequestPath(index, requestPath, undefined, realTarget); +} + +function indexRequestPath( + index: DevPublicFileEtagIndex, + requestPath: string, + realPath: string | undefined, + redirect: string | undefined, +): void { + const relativePath = path.relative(index.publicDir, requestPath); + if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) return; + + const segments = relativePath.split("/"); + let node = index.requestPathRoot; + for (const segment of segments) { + let child = node.children.get(segment); + if (child === null) return; + if (!child) { + child = createPathNode(); + node.children.set(segment, child); + } + if (index.caseInsensitive) { + indexAsciiAlias(node, asciiCaseKey(segment), child); + } + if (index.caseInsensitive || index.normalizationInsensitive) { + indexUnicodeAlias(node, unicodeBucketKey(segment, index.normalizationInsensitive), { + segment, + node: child, + simpleCasePattern: new RegExp( + `^(?:${escapeRegex(index.normalizationInsensitive ? segment.normalize("NFD") : segment)})$`, + "iu", + ), + expandedCaseTokens: expandCaseTokens( + index.normalizationInsensitive ? segment.normalize("NFD") : segment, + ), + }); + } + node = child; + } + + if (realPath) { + if (node.realPath === undefined || node.realPath === realPath) node.realPath = realPath; + else node.realPath = null; + } + if (redirect) { + if (node.redirect === undefined || node.redirect === redirect) node.redirect = redirect; + else node.redirect = null; + } +} + +function indexAsciiAlias(parent: DevPublicPathNode, key: string, child: DevPublicPathNode): void { + const existing = parent.asciiAliases.get(key); + if (existing === undefined || existing === child) parent.asciiAliases.set(key, child); + else parent.asciiAliases.set(key, null); +} + +function indexUnicodeAlias( + parent: DevPublicPathNode, + key: string, + candidate: DevPublicAliasCandidate, +): void { + const existing = parent.unicodeAliases.get(key); + if (existing === null) return; + if (!existing) { + parent.unicodeAliases.set(key, [candidate]); + return; + } + if ( + existing.some((entry) => entry.node === candidate.node && entry.segment === candidate.segment) + ) { + return; + } + if (existing.length === 8) { + parent.unicodeAliases.set(key, null); + return; + } + existing.push(candidate); +} + +function requestPathsForRealPath( + index: DevPublicFileEtagIndex, + watcherPath: string, + realPath: string, +): string[] { + const paths = new Set(); + if (isWithinPublicDir(index.publicDir, watcherPath)) paths.add(watcherPath); + for (const [source, target] of index.symlinkTargets) { + if (realPath === target || realPath.startsWith(target + "/")) { + paths.add(path.join(source, realPath.slice(target.length))); + } + } + return paths.size > 0 ? [...paths] : [realPath]; +} + +function resolveRequestPath( + index: DevPublicFileEtagIndex, + requestPath: string, + useAliases: boolean, +): string | undefined { + const relativePath = path.relative(index.publicDir, requestPath); + if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) return undefined; + return resolvePathSegments(index, relativePath.split("/"), useAliases, new Set()); +} + +function resolvePathSegments( + index: DevPublicFileEtagIndex, + segments: string[], + useAliases: boolean, + seenRedirects: Set, +): string | undefined { + let node = index.requestPathRoot; + for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) { + const segment = segments[segmentIndex]!; + const exact = node.children.get(segment); + let child = exact; + if (child === undefined && useAliases) { + if (index.caseInsensitive) child = node.asciiAliases.get(asciiCaseKey(segment)); + if (child === undefined) child = resolveUnicodeAlias(index, node, segment); + } + if (!child) { + return node.redirect + ? resolveRedirect( + index, + node.redirect, + segments.slice(segmentIndex), + useAliases, + seenRedirects, + ) + : undefined; + } + node = child; + } + if (typeof node.realPath === "string") return node.realPath; + return node.redirect + ? resolveRedirect(index, node.redirect, [], useAliases, seenRedirects) + : undefined; +} + +function resolveUnicodeAlias( + index: DevPublicFileEtagIndex, + parent: DevPublicPathNode, + requestedSegment: string, +): DevPublicPathNode | null | undefined { + const candidates = parent.unicodeAliases.get( + unicodeBucketKey(requestedSegment, index.normalizationInsensitive), + ); + if (!candidates) return candidates; + + let matched: DevPublicPathNode | undefined; + for (const candidate of candidates) { + if (!segmentsEquivalent(index, requestedSegment, candidate)) continue; + if (matched && matched !== candidate.node) return null; + matched = candidate.node; + } + return matched; +} + +function segmentsEquivalent( + index: DevPublicFileEtagIndex, + left: string, + candidate: DevPublicAliasCandidate, +): boolean { + const normalizedLeft = index.normalizationInsensitive ? left.normalize("NFD") : left; + const normalizedRight = index.normalizationInsensitive + ? candidate.segment.normalize("NFD") + : candidate.segment; + if (!index.caseInsensitive) return normalizedLeft === normalizedRight; + if (candidate.simpleCasePattern.test(normalizedLeft)) return true; + const leftTokens = expandCaseTokens(normalizedLeft); + return ( + leftTokens.length === candidate.expandedCaseTokens.length && + leftTokens.every((token, index) => + simpleCaseEquivalent(token, candidate.expandedCaseTokens[index]!), + ) + ); +} + +function expandCaseTokens(value: string): string[] { + const tokens: string[] = []; + for (const char of value) { + const uppercase = Array.from(char.toUpperCase()); + if (uppercase.length > 1) tokens.push(...uppercase); + else tokens.push(char); + } + return tokens; +} + +function simpleCaseEquivalent(left: string, right: string): boolean { + let pattern = simpleCasePatterns.get(right); + if (!pattern) { + pattern = new RegExp(`^(?:${escapeRegex(right)})$`, "iu"); + simpleCasePatterns.set(right, pattern); + } + return pattern.test(left); +} + +function escapeRegex(value: string): string { + return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&"); +} + +function resolveRedirect( + index: DevPublicFileEtagIndex, + redirect: string, + remaining: string[], + useAliases: boolean, + seenRedirects: Set, +): string | undefined { + const marker = redirect + "\0" + remaining.join("/"); + if (seenRedirects.has(marker)) return undefined; + seenRedirects.add(marker); + + const relativeTarget = path.relative(index.publicDir, redirect); + if (!relativeTarget.startsWith("..") && !path.isAbsolute(relativeTarget)) { + return resolvePathSegments( + index, + [...(relativeTarget ? relativeTarget.split("/") : []), ...remaining], + useAliases, + seenRedirects, + ); + } + const physicalPath = path.join(redirect, remaining.join("/")); + return index.etagsByRealPath.has(physicalPath) ? physicalPath : undefined; +} + +function createPathNode(): DevPublicPathNode { + return { children: new Map(), asciiAliases: new Map(), unicodeAliases: new Map() }; +} + +function detectCaseInsensitiveDirectory(externalDir: string): boolean { + const dir = toSlash(externalDir); + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return false; + } + const entryNames = new Set(entries.map((entry) => entry.name)); + + for (const entry of entries) { + const toggledName = toggleAsciiCase(entry.name); + if (!toggledName) continue; + if (entryNames.has(toggledName)) return false; + if (pathHasCaseInsensitiveAlias(path.join(dir, entry.name))) return true; + } + + const basename = path.basename(dir); + const toggledBasename = toggleAsciiCase(basename); + if (!toggledBasename) return false; + try { + return ( + toSlash(fs.realpathSync.native(dir)) === + toSlash(fs.realpathSync.native(path.join(path.dirname(dir), toggledBasename))) + ); + } catch { + return false; + } +} + +function detectNormalizationInsensitiveDirectory(externalDir: string): boolean { + const dir = toSlash(externalDir); + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return false; + } + const entryNames = new Set(entries.map((entry) => entry.name)); + + for (const entry of entries) { + const alternateName = alternateNormalization(entry.name); + if (!alternateName) continue; + if (entryNames.has(alternateName)) return false; + if (pathHasNormalizationInsensitiveAlias(path.join(dir, entry.name))) { + return true; + } + } + return false; +} + +function toggleAsciiCase(value: string): string | undefined { + const index = value.search(/[A-Za-z]/); + if (index === -1) return undefined; + const char = value[index]!; + const toggled = char === char.toLowerCase() ? char.toUpperCase() : char.toLowerCase(); + return value.slice(0, index) + toggled + value.slice(index + 1); +} + +function alternateNormalization(value: string): string | undefined { + const decomposed = value.normalize("NFD"); + if (decomposed !== value) return decomposed; + const composed = value.normalize("NFC"); + return composed !== value ? composed : undefined; +} + +function pathHasCaseInsensitiveAlias(filePath: string): boolean { + const toggledName = toggleAsciiCase(path.basename(filePath)); + return toggledName + ? resolvesToSamePath(filePath, path.join(path.dirname(filePath), toggledName)) + : false; +} + +function pathHasNormalizationInsensitiveAlias(filePath: string): boolean { + const alternateName = alternateNormalization(path.basename(filePath)); + return alternateName + ? resolvesToSamePath(filePath, path.join(path.dirname(filePath), alternateName)) + : false; +} + +function resolvesToSamePath(left: string, right: string): boolean { + try { + return toSlash(fs.realpathSync.native(left)) === toSlash(fs.realpathSync.native(right)); + } catch { + return false; + } +} + +function unicodeBucketKey(value: string, normalizationInsensitive: boolean): string { + return (normalizationInsensitive ? value.normalize("NFD") : value).toUpperCase(); +} + +function asciiCaseKey(value: string): string { + return value.replace(/[A-Z]/g, (char) => char.toLowerCase()); +} diff --git a/tests/dev-public-etag-vite.test.ts b/tests/dev-public-etag-vite.test.ts new file mode 100644 index 0000000000..19592c2100 --- /dev/null +++ b/tests/dev-public-etag-vite.test.ts @@ -0,0 +1,363 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createServer, type Plugin, type ViteDevServer } from "vite"; +import vinext from "../packages/vinext/src/index.js"; + +const servers: ViteDevServer[] = []; +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => server.close())); + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe("dev public ETag Vite configuration", () => { + it("does not rewrite validators when publicDir is disabled", async () => { + const root = createRoot(); + const publicDir = path.join(root, "public"); + fs.mkdirSync(publicDir); + const filePath = path.join(publicDir, "asset.js"); + fs.writeFileSync(filePath, "hello"); + const strong = etagForFile(filePath).replace(/^W\//, ""); + + const baseUrl = await startServer(root, false); + const response = await fetch(`${baseUrl}/asset.js`, { + headers: { "If-None-Match": strong }, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("x-fallback-if-none-match")).toBe(strong); + expect(await response.text()).toBe("fallback"); + }); + + it("indexes only Vite's resolved custom publicDir", async () => { + const root = createRoot(); + const defaultPublicDir = path.join(root, "public"); + const customPublicDir = path.join(root, "custom-public"); + fs.mkdirSync(defaultPublicDir); + fs.mkdirSync(customPublicDir); + const defaultFile = path.join(defaultPublicDir, "root-only.js"); + fs.writeFileSync(defaultFile, "wrong"); + fs.writeFileSync(path.join(customPublicDir, "asset.js"), "custom"); + + const baseUrl = await startServer(root, "custom-public"); + const initial = await fetch(`${baseUrl}/asset.js`); + expect(initial.status).toBe(200); + expect(await initial.text()).toBe("custom"); + const etag = initial.headers.get("etag"); + expect(etag).toMatch(/^W\//); + + const conditional = await fetch(`${baseUrl}/asset.js`, { + headers: { "If-None-Match": etag!.replace(/^W\//, "") }, + }); + expect(conditional.status).toBe(304); + + const rootOnlyStrong = etagForFile(defaultFile).replace(/^W\//, ""); + const fallback = await fetch(`${baseUrl}/root-only.js`, { + headers: { "If-None-Match": rootOnlyStrong }, + }); + expect(fallback.status).toBe(200); + expect(fallback.headers.get("x-fallback-if-none-match")).toBe(rootOnlyStrong); + expect(await fallback.text()).toBe("fallback"); + }); + + it.runIf(process.platform === "darwin")( + "matches case and normalization aliases when a dangling symlink enables Vite stat lookup", + async () => { + const root = createRoot(); + const publicDir = path.join(root, "public"); + fs.mkdirSync(publicDir); + const mixedCaseFile = path.join(publicDir, "MixedCase.js"); + const unicodeFile = path.join(publicDir, "Éclair.js"); + const sharpSFile = path.join(publicDir, "Straße.js"); + const dotlessIFile = path.join(publicDir, "ı.js"); + const sigmaFile = path.join(publicDir, "Σ.js"); + const ypogegrammeniFile = path.join(publicDir, "ͅ.js"); + const mixedExpansionFile = path.join(publicDir, "ßı.js"); + fs.writeFileSync(mixedCaseFile, "mixed"); + fs.writeFileSync(unicodeFile, "unicode"); + fs.writeFileSync(sharpSFile, "sharp-s"); + fs.writeFileSync(dotlessIFile, "dotless-i"); + fs.writeFileSync(sigmaFile, "sigma"); + fs.writeFileSync(ypogegrammeniFile, "ypogegrammeni"); + fs.writeFileSync(mixedExpansionFile, "mixed-expansion"); + fs.mkdirSync(path.join(publicDir, "Straße")); + fs.writeFileSync(path.join(publicDir, "Straße", "Maße.js"), "nested-sharp-s"); + fs.symlinkSync("missing", path.join(publicDir, "0-broken")); + + const lowerCaseFile = path.join(publicDir, "mixedCase.js"); + if (fs.realpathSync.native(mixedCaseFile) !== fs.realpathSync.native(lowerCaseFile)) return; + + const baseUrl = await startServer(root, "public"); + const mixedEtag = (await fetch(`${baseUrl}/MixedCase.js`)).headers.get("etag"); + expect(mixedEtag).toMatch(/^W\//); + expect( + ( + await fetch(`${baseUrl}/mixedcase.js`, { + headers: { "If-None-Match": mixedEtag!.replace(/^W\//, "") }, + }) + ).status, + ).toBe(304); + + const mixedExpansionEtag = (await fetch(`${baseUrl}/%C3%9F%C4%B1.js`)).headers.get("etag"); + expect(mixedExpansionEtag).toMatch(/^W\//); + const mixedExpansionFallback = await fetch(`${baseUrl}/SSi.js`, { + headers: { "If-None-Match": mixedExpansionEtag!.replace(/^W\//, "") }, + }); + expect(mixedExpansionFallback.status).toBe(200); + expect(mixedExpansionFallback.headers.get("x-fallback-if-none-match")).toBe( + mixedExpansionEtag!.replace(/^W\//, ""), + ); + + const ypogegrammeniEtag = (await fetch(`${baseUrl}/%CD%85.js`)).headers.get("etag"); + expect(ypogegrammeniEtag).toMatch(/^W\//); + expect( + ( + await fetch(`${baseUrl}/%CE%99.js`, { + headers: { "If-None-Match": ypogegrammeniEtag!.replace(/^W\//, "") }, + }) + ).status, + ).toBe(304); + + const sigmaEtag = (await fetch(`${baseUrl}/%CE%A3.js`)).headers.get("etag"); + expect(sigmaEtag).toMatch(/^W\//); + expect( + ( + await fetch(`${baseUrl}/%CF%82.js`, { + headers: { "If-None-Match": sigmaEtag!.replace(/^W\//, "") }, + }) + ).status, + ).toBe(304); + + const nestedEtag = (await fetch(`${baseUrl}/Stra%C3%9Fe/Ma%C3%9Fe.js`)).headers.get("etag"); + expect(nestedEtag).toMatch(/^W\//); + expect( + ( + await fetch(`${baseUrl}/STRASSE/MASSE.js`, { + headers: { "If-None-Match": nestedEtag!.replace(/^W\//, "") }, + }) + ).status, + ).toBe(304); + + const dotlessIEtag = (await fetch(`${baseUrl}/%C4%B1.js`)).headers.get("etag"); + expect(dotlessIEtag).toMatch(/^W\//); + const dottedFallback = await fetch(`${baseUrl}/i.js`, { + headers: { "If-None-Match": dotlessIEtag!.replace(/^W\//, "") }, + }); + expect(dottedFallback.status).toBe(200); + expect(dottedFallback.headers.get("x-fallback-if-none-match")).toBe( + dotlessIEtag!.replace(/^W\//, ""), + ); + + const unicodeEtag = (await fetch(`${baseUrl}/%C3%89clair.js`)).headers.get("etag"); + expect(unicodeEtag).toMatch(/^W\//); + expect( + ( + await fetch(`${baseUrl}/%C3%A9clair.js`, { + headers: { "If-None-Match": unicodeEtag!.replace(/^W\//, "") }, + }) + ).status, + ).toBe(304); + + const decomposedFile = path.join(publicDir, "E\u0301clair.js"); + if (fs.realpathSync.native(unicodeFile) === fs.realpathSync.native(decomposedFile)) { + expect( + ( + await fetch(`${baseUrl}/E%CC%81clair.js`, { + headers: { "If-None-Match": unicodeEtag!.replace(/^W\//, "") }, + }) + ).status, + ).toBe(304); + } + + const sharpSEtag = (await fetch(`${baseUrl}/Stra%C3%9Fe.js`)).headers.get("etag"); + expect(sharpSEtag).toMatch(/^W\//); + expect( + ( + await fetch(`${baseUrl}/STRASSE.js`, { + headers: { "If-None-Match": sharpSEtag!.replace(/^W\//, "") }, + }) + ).status, + ).toBe(304); + }, + ); + + it.runIf(process.platform === "darwin")( + "expands normalization semantics when a file is added after startup", + async () => { + const root = createRoot(); + const publicDir = path.join(root, "public"); + fs.mkdirSync(publicDir); + fs.writeFileSync(path.join(publicDir, "base.js"), "base"); + fs.symlinkSync("missing", path.join(publicDir, "0-broken")); + + const baseUrl = await startServer(root, "public"); + fs.writeFileSync(path.join(publicDir, "Éclair.js"), "unicode"); + + await expect + .poll( + async () => { + const canonical = await fetch(`${baseUrl}/%C3%89clair.js`); + const etag = canonical.headers.get("etag"); + if (!etag) return canonical.status; + return ( + await fetch(`${baseUrl}/E%CC%81clair.js`, { + headers: { "If-None-Match": etag.replace(/^W\//, "") }, + }) + ).status; + }, + { timeout: 5000 }, + ) + .toBe(304); + }, + ); + + it.runIf(process.platform !== "win32")( + "does not fold or rewrite a wrong-case request for a symlinked public root", + async () => { + const root = createRoot(); + const actualPublicDir = path.join(root, "actual-public"); + const publicDir = path.join(root, "public"); + fs.mkdirSync(actualPublicDir); + fs.writeFileSync(path.join(actualPublicDir, "MixedCase.js"), "content"); + fs.symlinkSync(actualPublicDir, publicDir, "dir"); + + const baseUrl = await startServer(root, "public"); + const initial = await fetch(`${baseUrl}/MixedCase.js`); + const strong = initial.headers.get("etag")!.replace(/^W\//, ""); + const fallback = await fetch(`${baseUrl}/mixedcase.js`, { + headers: { "If-None-Match": strong }, + }); + + expect(fallback.status).toBe(200); + expect(fallback.headers.get("x-fallback-if-none-match")).toBe(strong); + expect(await fallback.text()).toBe("fallback"); + }, + ); + + it.runIf(process.platform === "darwin")( + "keeps Vite's exact public lookup mode when a symlink is added later", + async () => { + const root = createRoot(); + const publicDir = path.join(root, "public"); + fs.mkdirSync(publicDir); + fs.writeFileSync(path.join(publicDir, "MixedCase.js"), "content"); + + const baseUrl = await startServer(root, "public"); + const initial = await fetch(`${baseUrl}/MixedCase.js`); + const strong = initial.headers.get("etag")!.replace(/^W\//, ""); + fs.symlinkSync("MixedCase.js", path.join(publicDir, "alias.js")); + await expect.poll(async () => (await fetch(`${baseUrl}/alias.js`)).status).toBe(200); + + const fallback = await fetch(`${baseUrl}/mixedcase.js`, { + headers: { "If-None-Match": strong }, + }); + expect(fallback.status).toBe(200); + expect(fallback.headers.get("x-fallback-if-none-match")).toBe(strong); + }, + ); + + it.runIf(process.platform === "darwin")( + "keeps Vite's stat lookup mode when its startup symlink is removed", + async () => { + const root = createRoot(); + const publicDir = path.join(root, "public"); + fs.mkdirSync(publicDir); + fs.writeFileSync(path.join(publicDir, "MixedCase.js"), "content"); + const alias = path.join(publicDir, "alias.js"); + fs.symlinkSync("MixedCase.js", alias); + + const baseUrl = await startServer(root, "public"); + const initial = await fetch(`${baseUrl}/MixedCase.js`); + const strong = initial.headers.get("etag")!.replace(/^W\//, ""); + fs.rmSync(alias); + + await expect + .poll( + async () => + ( + await fetch(`${baseUrl}/mixedcase.js`, { + headers: { "If-None-Match": strong }, + }) + ).status, + ) + .toBe(304); + }, + ); + + it.runIf(process.platform === "darwin")( + "keeps unverified Unicode aliases distinct from dotted files and symlinks", + async () => { + const root = createRoot("ı-etag-root-"); + const publicDir = path.join(root, "public"); + fs.mkdirSync(publicDir); + fs.writeFileSync(path.join(publicDir, "MixedCase.js"), "mixed"); + fs.writeFileSync(path.join(publicDir, "target.js"), "target"); + fs.symlinkSync("target.js", path.join(publicDir, "i.js")); + + const baseUrl = await startServer(root, "public"); + const mixedEtag = (await fetch(`${baseUrl}/MixedCase.js`)).headers.get("etag")!; + expect( + ( + await fetch(`${baseUrl}/mixedcase.js`, { + headers: { "If-None-Match": mixedEtag.replace(/^W\//, "") }, + }) + ).status, + ).toBe(304); + + const dottedEtag = (await fetch(`${baseUrl}/i.js`)).headers.get("etag")!; + const dotlessFallback = await fetch(`${baseUrl}/%C4%B1.js`, { + headers: { "If-None-Match": dottedEtag.replace(/^W\//, "") }, + }); + expect(dotlessFallback.status).toBe(200); + expect(dotlessFallback.headers.get("x-fallback-if-none-match")).toBe( + dottedEtag.replace(/^W\//, ""), + ); + }, + ); +}); + +function createRoot(prefix = "vinext-dev-public-config-"): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + roots.push(root); + return root; +} + +async function startServer(root: string, publicDir: string | false): Promise { + const fallbackPlugin: Plugin = { + name: "test-public-etag-fallback", + configureServer(server) { + return () => { + server.middlewares.use((req, res) => { + const validator = req.headers["if-none-match"]; + if (typeof validator === "string") { + res.setHeader("x-fallback-if-none-match", validator); + } + res.statusCode = 200; + res.end("fallback"); + }); + }; + }, + }; + + const server = await createServer({ + root, + publicDir, + configFile: false, + plugins: [vinext(), fallbackPlugin], + logLevel: "silent", + server: { host: "127.0.0.1", port: 0 }, + }); + servers.push(server); + await server.listen(); + const address = server.httpServer!.address(); + if (!address || typeof address === "string") throw new Error("Expected a TCP dev server"); + return `http://127.0.0.1:${address.port}`; +} + +function etagForFile(filePath: string): string { + const stats = fs.statSync(filePath); + return `W/"${stats.size}-${stats.mtime.getTime()}"`; +} diff --git a/tests/dev-public-etag.test.ts b/tests/dev-public-etag.test.ts new file mode 100644 index 0000000000..92687ff3f5 --- /dev/null +++ b/tests/dev-public-etag.test.ts @@ -0,0 +1,390 @@ +import { describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { toSlash } from "pathslash"; +import { + createDevPublicFileEtags, + type DevPublicFileEtagIndex, + resolveDevPublicIfNoneMatch, + updateDevPublicFileEtag, +} from "../packages/vinext/src/server/dev-public-etag.js"; + +const ETAG = 'W/"90-1234"'; +const publicEtags: DevPublicFileEtagIndex = { + publicDir: "/public", + etagsByRealPath: new Map([["/public/asset.js", ETAG]]), + requestPathRoot: { children: new Map(), asciiAliases: new Map(), unicodeAliases: new Map() }, + symlinkTargets: new Map(), + hasSymlink: false, + viteUsesStatLookup: false, + caseInsensitive: false, + normalizationInsensitive: false, +}; + +describe("resolveDevPublicIfNoneMatch", () => { + it("normalizes strong and weak validators to sirv's weak ETag", () => { + expect(resolveDevPublicIfNoneMatch("GET", "/asset.js", '"90-1234"', publicEtags)).toBe(ETAG); + expect(resolveDevPublicIfNoneMatch("GET", "/asset.js", ETAG, publicEtags)).toBe(ETAG); + }); + + it("handles lists and wildcard validators", () => { + expect( + resolveDevPublicIfNoneMatch("GET", "/asset.js?cache=1", '"other", "90-1234"', publicEtags), + ).toBe(ETAG); + expect(resolveDevPublicIfNoneMatch("GET", "/asset.js", "*", publicEtags)).toBe(ETAG); + }); + + it("leaves malformed and non-matching validators untouched", () => { + expect( + resolveDevPublicIfNoneMatch("GET", "/asset.js", '*, "90-1234"', publicEtags), + ).toBeUndefined(); + expect(resolveDevPublicIfNoneMatch("GET", "/asset.js", '"other"', publicEtags)).toBeUndefined(); + }); + + it("supports HEAD and a configured base path", () => { + expect( + resolveDevPublicIfNoneMatch("HEAD", "/base/asset.js", '"90-1234"', publicEtags, "/base"), + ).toBe(ETAG); + expect( + resolveDevPublicIfNoneMatch( + "GET", + "/base/a/%2e%2e//asset.js", + '"90-1234"', + publicEtags, + "/base", + ), + ).toBe(ETAG); + }); + + it("does not affect methods, missing files, or paths outside basePath", () => { + expect( + resolveDevPublicIfNoneMatch("POST", "/asset.js", '"90-1234"', publicEtags), + ).toBeUndefined(); + expect( + resolveDevPublicIfNoneMatch("GET", "/missing.js", '"90-1234"', publicEtags), + ).toBeUndefined(); + expect( + resolveDevPublicIfNoneMatch("GET", "/asset.js", '"90-1234"', publicEtags, "/base"), + ).toBeUndefined(); + }); + + it("tracks the same size/mtime identity that sirv uses", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-")); + const publicDir = path.join(root, "public"); + const filePath = path.join(publicDir, "asset.js"); + try { + fs.mkdirSync(publicDir); + fs.writeFileSync(filePath, "initial"); + + const index = createDevPublicFileEtags(publicDir); + const realPath = toSlash(fs.realpathSync(filePath)); + const initial = index.etagsByRealPath.get(realPath); + expect(initial).toMatch(/^W\/"7-\d+"$/); + + fs.writeFileSync(filePath, "updated content"); + expect(updateDevPublicFileEtag(index, filePath)).toBe(true); + expect(index.etagsByRealPath.get(realPath)).toMatch(/^W\/"15-\d+"$/); + expect(index.etagsByRealPath.get(realPath)).not.toBe(initial); + + fs.rmSync(filePath); + expect(updateDevPublicFileEtag(index, filePath)).toBe(false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it.runIf(process.platform !== "win32")( + "preserves directory symlink aliases without following cycles", + () => { + const root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-alias-")), + ); + const publicDir = path.join(root, "public"); + try { + fs.mkdirSync(path.join(publicDir, "a"), { recursive: true }); + fs.writeFileSync(path.join(publicDir, "a", "asset.js"), "content"); + fs.symlinkSync("a", path.join(publicDir, "b"), "dir"); + fs.symlinkSync("..", path.join(publicDir, "a", "cycle"), "dir"); + + const index = createDevPublicFileEtags(publicDir); + const etag = index.etagsByRealPath.get( + toSlash(fs.realpathSync(path.join(publicDir, "a/asset.js"))), + ); + expect(etag).toMatch(/^W\//); + expect(resolveDevPublicIfNoneMatch("GET", "/a/asset.js", etag, index)).toBe(etag); + expect(resolveDevPublicIfNoneMatch("GET", "/b/asset.js", etag, index)).toBe(etag); + expect(resolveDevPublicIfNoneMatch("GET", "/a/cycle/a/asset.js", etag, index)).toBe(etag); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, + ); + + it("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", + () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-real-watch-")); + const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-target-")); + const publicDir = path.join(root, "public"); + const externalFile = path.join(externalDir, "asset.js"); + try { + fs.mkdirSync(publicDir); + fs.writeFileSync(externalFile, "initial"); + fs.symlinkSync(externalDir, path.join(publicDir, "alias"), "dir"); + + const index = createDevPublicFileEtags(publicDir); + const initial = resolveDevPublicIfNoneMatch( + "GET", + "/alias/asset.js", + index.etagsByRealPath.get(toSlash(fs.realpathSync(externalFile))), + index, + ); + expect(initial).toMatch(/^W\//); + + fs.writeFileSync(externalFile, "updated content"); + expect(updateDevPublicFileEtag(index, externalFile)).toBe(true); + const updated = index.etagsByRealPath.get(toSlash(fs.realpathSync(externalFile))); + expect(updated).toMatch(/^W\//); + expect(updated).not.toBe(initial); + expect(resolveDevPublicIfNoneMatch("GET", "/alias/asset.js", updated, index)).toBe(updated); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(externalDir, { recursive: true, force: true }); + } + }, + ); + + it.runIf(process.platform === "darwin")( + "folds served names only when the filesystem is case-insensitive", + () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-case-")); + const publicDir = path.join(root, "public"); + try { + fs.mkdirSync(publicDir); + fs.writeFileSync(path.join(publicDir, "MixedCase.js"), "content"); + + const exactSetIndex = createDevPublicFileEtags(publicDir, true); + exactSetIndex.symlinkTargets.clear(); + expect( + resolveDevPublicIfNoneMatch("GET", "/mixedcase.js", "*", exactSetIndex), + ).toBeUndefined(); + + fs.symlinkSync("MixedCase.js", path.join(publicDir, "alias.js")); + + const caseInsensitiveIndex = createDevPublicFileEtags(publicDir, true); + const etag = resolveDevPublicIfNoneMatch("GET", "/mixedcase.js", "*", caseInsensitiveIndex); + expect(etag).toMatch(/^W\//); + expect(resolveDevPublicIfNoneMatch("GET", "/ALIAS.JS", "*", caseInsensitiveIndex)).toBe( + etag, + ); + + const caseSensitiveIndex = createDevPublicFileEtags(publicDir, false); + expect( + resolveDevPublicIfNoneMatch("GET", "/mixedcase.js", "*", caseSensitiveIndex), + ).toBeUndefined(); + expect( + resolveDevPublicIfNoneMatch("GET", "/MixedCase.js", "*", caseSensitiveIndex), + ).toMatch(/^W\//); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, + ); + + it("fails ambiguous folded names closed while preserving exact spellings", () => { + const index: DevPublicFileEtagIndex = { + publicDir: "/public", + etagsByRealPath: new Map([ + ["/public/A.js", 'W/"5-1"'], + ["/public/a.js", 'W/"5-2"'], + ]), + requestPathRoot: { + children: new Map(), + asciiAliases: new Map([["a.js", null]]), + unicodeAliases: new Map(), + }, + symlinkTargets: new Map([["/public/alias", "/public/A.js"]]), + hasSymlink: true, + viteUsesStatLookup: true, + caseInsensitive: true, + normalizationInsensitive: false, + }; + expect(resolveDevPublicIfNoneMatch("GET", "/A.js", "*", index)).toBe('W/"5-1"'); + expect(resolveDevPublicIfNoneMatch("GET", "/a.js", "*", index)).toBe('W/"5-2"'); + expect(resolveDevPublicIfNoneMatch("GET", "/a.JS", "*", index)).toBeUndefined(); + }); + + it.runIf(process.platform === "darwin")( + "uses dangling symlinks to mirror Vite's stat-based public lookup", + () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-dangling-")); + const publicDir = path.join(root, "public"); + try { + fs.mkdirSync(publicDir); + fs.symlinkSync("missing", path.join(publicDir, "0-broken")); + fs.writeFileSync(path.join(publicDir, "MixedCase.js"), "content"); + + const index = createDevPublicFileEtags(publicDir, true); + expect(index.hasSymlink).toBe(true); + expect([...index.symlinkTargets.keys()].some((key) => key.endsWith("/0-broken"))).toBe( + false, + ); + expect(resolveDevPublicIfNoneMatch("GET", "/mixedcase.js", "*", index)).toMatch(/^W\//); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, + ); + + it.runIf(process.platform !== "win32")( + "does not treat a symlinked public root as a child symlink", + () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-root-link-")); + const actualPublicDir = path.join(root, "actual-public"); + const publicDir = path.join(root, "public"); + try { + fs.mkdirSync(actualPublicDir); + fs.writeFileSync(path.join(actualPublicDir, "MixedCase.js"), "content"); + fs.symlinkSync(actualPublicDir, publicDir, "dir"); + + const index = createDevPublicFileEtags(publicDir, true); + expect(index.symlinkTargets.get(toSlash(publicDir))).toBe( + toSlash(fs.realpathSync.native(actualPublicDir)), + ); + expect(index.hasSymlink).toBe(false); + expect(resolveDevPublicIfNoneMatch("GET", "/mixedcase.js", "*", index)).toBeUndefined(); + expect(resolveDevPublicIfNoneMatch("GET", "/MixedCase.js", "*", index)).toMatch(/^W\//); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, + ); + + it.runIf(process.platform === "darwin")( + "folds Unicode case and filesystem normalization aliases", + () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-unicode-")); + const publicDir = path.join(root, "public"); + try { + fs.mkdirSync(publicDir); + fs.writeFileSync(path.join(publicDir, "Éclair.js"), "content"); + fs.writeFileSync(path.join(publicDir, "Straße.js"), "content"); + fs.symlinkSync("Éclair.js", path.join(publicDir, "alias.js")); + + const index = createDevPublicFileEtags(publicDir, true, true); + expect(resolveDevPublicIfNoneMatch("GET", "/%C3%A9clair.js", "*", index)).toMatch(/^W\//); + expect(resolveDevPublicIfNoneMatch("GET", "/e%CC%81clair.js", "*", index)).toMatch(/^W\//); + expect(resolveDevPublicIfNoneMatch("GET", "/STRASSE.js", "*", index)).toMatch(/^W\//); + + const normalizationOnlyIndex = createDevPublicFileEtags(publicDir, false, true); + expect( + resolveDevPublicIfNoneMatch("GET", "/E%CC%81clair.js", "*", normalizationOnlyIndex), + ).toMatch(/^W\//); + expect( + resolveDevPublicIfNoneMatch("GET", "/e%CC%81clair.js", "*", normalizationOnlyIndex), + ).toBeUndefined(); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, + ); + + it.runIf(process.platform !== "win32")( + "does not infer insensitive semantics from distinct aliases to one target", + () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-public-etag-alias-probe-")); + const publicDir = path.join(root, "public"); + try { + fs.mkdirSync(publicDir); + fs.writeFileSync(path.join(publicDir, "target.js"), "content"); + fs.symlinkSync("target.js", path.join(publicDir, "alias")); + try { + fs.symlinkSync("target.js", path.join(publicDir, "Alias")); + fs.symlinkSync("target.js", path.join(publicDir, "É")); + fs.symlinkSync("target.js", path.join(publicDir, "E\u0301")); + } catch { + return; + } + + const index = createDevPublicFileEtags(publicDir); + expect(index.caseInsensitive).toBe(false); + expect(index.normalizationInsensitive).toBe(false); + expect(resolveDevPublicIfNoneMatch("GET", "/ALIAS", "*", index)).toBeUndefined(); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, + ); + + it.runIf(process.platform === "win32")("normalizes encoded Windows separators like Vite", () => { + const nestedEtags: DevPublicFileEtagIndex = { + publicDir: "/public", + etagsByRealPath: new Map([["/public/foo/bar.js", ETAG]]), + requestPathRoot: { children: new Map(), asciiAliases: new Map(), unicodeAliases: new Map() }, + symlinkTargets: new Map(), + hasSymlink: false, + viteUsesStatLookup: false, + caseInsensitive: false, + normalizationInsensitive: false, + }; + expect(resolveDevPublicIfNoneMatch("GET", "/foo%5Cbar.js", '"90-1234"', nestedEtags)).toBe( + ETAG, + ); + }); +}); diff --git a/tests/e2e/pages-router/script.spec.ts b/tests/e2e/pages-router/script.spec.ts index 5168f54d16..26178b3681 100644 --- a/tests/e2e/pages-router/script.spec.ts +++ b/tests/e2e/pages-router/script.spec.ts @@ -1,8 +1,45 @@ import { test, expect } from "@playwright/test"; +import { request as httpRequest } from "node:http"; const BASE = "http://localhost:4173"; test.describe("next/script", () => { + test("uses weak comparison for public-file If-None-Match requests", async ({ request }) => { + const initial = await request.get(`${BASE}/dedupe-script.js`); + const etag = initial.headers().etag; + expect(etag).toMatch(/^W\//); + + const strong = etag.slice(2); + const strongResponse = await request.get(`${BASE}/dedupe-script.js`, { + headers: { "If-None-Match": strong }, + }); + expect(strongResponse.status()).toBe(304); + + const listResponse = await request.get(`${BASE}/dedupe-script.js`, { + headers: { "If-None-Match": `"other", ${strong}` }, + }); + expect(listResponse.status()).toBe(304); + + const headResponse = await request.head(`${BASE}/dedupe-script.js`, { + headers: { "If-None-Match": strong }, + }); + expect(headResponse.status()).toBe(304); + + const normalizedPathStatus = await new Promise((resolve, reject) => { + const req = httpRequest( + `${BASE}/nested/%2e%2e//dedupe-script.js`, + { headers: { "If-None-Match": strong } }, + (response) => { + response.resume(); + response.on("end", () => resolve(response.statusCode)); + }, + ); + req.on("error", reject); + req.end(); + }); + expect(normalizedPathStatus).toBe(304); + }); + // Ported from Next.js: packages/next/src/client/script.tsx // https://github.com/vercel/next.js/blob/canary/packages/next/src/client/script.tsx // Next.js keeps a ScriptCache for in-flight remote scripts so same-src