diff --git a/examples/pages-router-cloudflare/public/hello copy.txt b/examples/pages-router-cloudflare/public/hello copy.txt new file mode 100644 index 0000000000..fc37472d75 --- /dev/null +++ b/examples/pages-router-cloudflare/public/hello copy.txt @@ -0,0 +1 @@ +Hello from an encoded Pages Router public asset. diff --git a/examples/pages-router-cloudflare/public/static.txt b/examples/pages-router-cloudflare/public/static.txt new file mode 100644 index 0000000000..60773ae895 --- /dev/null +++ b/examples/pages-router-cloudflare/public/static.txt @@ -0,0 +1 @@ +Hello from a Pages Router public asset on Workers. diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index a9a12b769a..46cfffd764 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -45,6 +45,7 @@ export async function generateServerEntry( fileMatcher: ReturnType, middlewarePath: string | null, instrumentationPath: string | null, + publicFiles: string[] = [], ): Promise { const pageRoutes = await pagesRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher); const apiRoutes = await apiRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher); @@ -309,6 +310,7 @@ ${errorImportCode} export const pageRoutes = [ ${pageRouteEntries.join(",\n")} ]; +export const publicFiles = new Set(${JSON.stringify(publicFiles)}); const _pageRouteTrie = _buildRouteTrie(pageRoutes); const _errorPageRoute = { pattern: "/_error", diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 919ad9b1be..2b6029131b 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -242,6 +242,8 @@ 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 { publicFilePathVariants } from "./utils/public-file-path.js"; +import { methodNotAllowedResponse } from "./server/http-error-responses.js"; import type { Options as VitePluginReactOptions } from "@vitejs/plugin-react"; import MagicString from "magic-string"; import path, { toSlash } from "pathslash"; @@ -1458,6 +1460,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { let pagesOptimizeEntries: string[] = []; const pagesClientAssetsOutputDirs = new Set(); let pagesClientAssetsModule: string | null = null; + // Dev-only public route inventory. Vite's watcher keeps this synchronized, + // so request handling can use O(1) membership checks without filesystem I/O. + // Production builds leave it null and scan the configured public directory + // once while generating the RSC entry. + let devPublicFileRoutes: Set | null = null; let rscCompatibilityId: string | undefined; let draftModeSecret = getPagesPreviewModeId(); let previewBuildCredentials: PreviewBuildCredentials | undefined; @@ -1515,13 +1522,18 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { * Generate the virtual SSR server entry module. * This is the entry point for `vite build --ssr`. */ - async function generateServerEntry(): Promise { + async function generateServerEntry(configuredPublicDir: string | false): Promise { + const publicFiles = + isServeCommand && devPublicFileRoutes + ? [...devPublicFileRoutes].sort() + : scanPublicFileRoutes(root, configuredPublicDir === "" ? false : configuredPublicDir); return _generateServerEntry( pagesDir, nextConfig, fileMatcher, middlewarePath, instrumentationPath, + publicFiles, ); } @@ -3697,7 +3709,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } // Pages Router virtual modules if (id === RESOLVED_SERVER_ENTRY) { - return await generateServerEntry(); + return await generateServerEntry( + this.environment.config.publicDir === "" ? false : this.environment.config.publicDir, + ); } if (id === RESOLVED_CLIENT_ENTRY) { return await generateClientEntry(); @@ -3795,7 +3809,15 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { contentSecurityPolicy: nextConfig?.images?.contentSecurityPolicy, }, hasPagesDir, - publicFiles: scanPublicFileRoutes(root), + publicFiles: + isServeCommand && devPublicFileRoutes + ? [...devPublicFileRoutes].sort() + : scanPublicFileRoutes( + root, + this.environment.config.publicDir === "" + ? false + : this.environment.config.publicDir, + ), globalNotFoundPath, draftModeSecret, }, @@ -4499,7 +4521,46 @@ export const loadServerActionClient = ${ socket.on("error", () => {}); }); + const configuredPublicDir = + server.config.publicDir === "" ? false : (server.config.publicDir as string | false); + const devPublicDir = + configuredPublicDir === false + ? null + : path.resolve(toSlash(server.config.root), toSlash(configuredPublicDir)); + devPublicFileRoutes = new Set(scanPublicFileRoutes(root, configuredPublicDir)); + const publicRoutesForFile = (filePath: string): string[] | null => { + if (devPublicDir === null) return null; + const cleanPath = toSlash(stripViteModuleQuery(filePath)); + const relativePath = path.relative(devPublicDir, cleanPath); + if ( + relativePath === "" || + relativePath.startsWith("../") || + path.isAbsolute(relativePath) + ) { + return null; + } + return publicFilePathVariants(`/${relativePath}`); + }; + const updatePublicFileRoute = (filePath: string, present: boolean): void => { + const routes = publicRoutesForFile(filePath); + if (routes === null || devPublicFileRoutes === null) return; + let changed = false; + for (const route of routes) { + const routeChanged = present + ? !devPublicFileRoutes.has(route) + : devPublicFileRoutes.has(route); + if (!routeChanged) continue; + changed = true; + if (present) devPublicFileRoutes.add(route); + else devPublicFileRoutes.delete(route); + } + if (!changed) return; + if (hasAppDir) invalidateRscEntryModule(); + if (hasCloudflarePlugin && hasPagesDir && !hasAppDir) invalidatePagesServerEntry(); + }; + server.watcher.on("add", (filePath: string) => { + updatePublicFileRoute(filePath, true); let routeChanged = false; const pagesAppChanged = isPagesAppFile(filePath); const pagesAssetGraphScriptChanged = isPotentialPagesAssetGraphScript(filePath); @@ -4541,6 +4602,7 @@ export const loadServerActionClient = ${ } }); server.watcher.on("unlink", (filePath: string) => { + updatePublicFileRoute(filePath, false); let routeChanged = false; const pagesAppChanged = isPagesAppFile(filePath); const pagesAssetGraphScriptChanged = isPotentialPagesAssetGraphScript(filePath); @@ -4572,6 +4634,35 @@ export const loadServerActionClient = ${ } }); + type DevPagesMiddleware = ( + req: import("node:http").IncomingMessage, + res: import("node:http").ServerResponse, + next: (error?: unknown) => void, + ) => Promise; + let handlePagesMiddleware: DevPagesMiddleware | null = null; + const isExistingDevPublicFile = (requestPathname: string): boolean => + devPublicFileRoutes?.has(requestPathname) === true; + const isUnsupportedDevPublicRequest = ( + req: import("node:http").IncomingMessage, + pathnameState: "pre-base" | "post-base" = "pre-base", + ): boolean => { + if (req.method === "GET" || req.method === "HEAD") return false; + let pathname: string; + try { + pathname = normalizePathnameForRouteMatchStrict( + new URL(req.url ?? "/", "http://vinext.invalid").pathname, + ); + } catch { + return false; + } + const basePath = nextConfig?.basePath ?? ""; + if (pathnameState === "pre-base" && basePath) { + if (!hasBasePath(pathname, basePath)) return false; + pathname = stripBasePath(pathname, basePath); + } + return isExistingDevPublicFile(pathname); + }; + // ── Dev request origin check ───────────────────────────────────── // Registered directly (not in the returned function) so it runs // BEFORE Vite's built-in middleware. This ensures all requests @@ -4597,6 +4688,16 @@ export const loadServerActionClient = ${ next(); }); + // Vite serves public files for every method. Intercept only mutations + // to known public files before Vite's internals, then run the canonical + // Pages pipeline so config/middleware ordering and headers are retained. + server.middlewares.use((req, res, next) => { + if (!hasPagesDir || !handlePagesMiddleware || !isUnsupportedDevPublicRequest(req)) { + return next(); + } + void handlePagesMiddleware(req, res, next); + }); + installDevStackSourcemapMiddleware(server); // Return a function to register middleware AFTER Vite's built-in middleware @@ -4612,6 +4713,34 @@ export const loadServerActionClient = ${ typeof handle === "function", ); + // The patched Vite sirv middleware serves public files for every + // method. In App-only and Cloudflare dev projects, let mutations pass + // through to the framework request handler. App Router applies the + // canonical middleware -> beforeFiles -> filesystem ordering; the + // Cloudflare plugin delegates to the Worker/miniflare request path. + if ((hasAppDir && !hasPagesDir) || hasCloudflarePlugin) { + const publicMiddlewareEntry = server.middlewares.stack.find( + ({ handle }) => + typeof handle === "function" && handle.name === "viteServePublicMiddleware", + ); + const viteServePublic = publicMiddlewareEntry?.handle as + | import("vite").Connect.NextHandleFunction + | undefined; + if (publicMiddlewareEntry && viteServePublic) { + const vinextServePublicMiddleware: import("vite").Connect.NextHandleFunction = ( + req, + res, + next, + ) => { + // Vite's base middleware runs before its public middleware, + // so req.url may already have had nextConfig.basePath removed. + if (isUnsupportedDevPublicRequest(req, "post-base")) return next(); + return viteServePublic(req, res, next); + }; + publicMiddlewareEntry.handle = vinextServePublicMiddleware; + } + } + const serveRewrittenViteFilesystemRoute = async ( req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse, @@ -4826,7 +4955,7 @@ export const loadServerActionClient = ${ }); } - const handlePagesMiddleware = async ( + handlePagesMiddleware = async ( req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse, next: (err?: unknown) => void, @@ -5126,6 +5255,8 @@ export const loadServerActionClient = ${ }), ); const isFilePathRequest = pathname.includes(".") && !pathname.endsWith(".html"); + const isExistingPublicMutation = + req.method !== "GET" && req.method !== "HEAD" && isExistingDevPublicFile(pathname); let filePathMatchesPagesRoute = false; const requestHostname = getUrlHostname(requestOrigin); if (isFilePathRequest && !filePathMatchesRewrite) { @@ -5150,7 +5281,12 @@ export const loadServerActionClient = ${ matchRoute(pageRouteUrl, pageRoutes) !== null || matchRoute(apiRouteUrl, apiRoutes) !== null; } - if (isFilePathRequest && !filePathMatchesRewrite && !filePathMatchesPagesRoute) { + if ( + isFilePathRequest && + !filePathMatchesRewrite && + !filePathMatchesPagesRoute && + !isExistingPublicMutation + ) { return next(); } @@ -5356,15 +5492,20 @@ export const loadServerActionClient = ${ return proxyExternalRequest(reqWithBody, externalUrl); }, serveFilesystemRoute: async (requestPathname, stagedHeaders, phase) => { + const isRetrievalMethod = req.method === "GET" || req.method === "HEAD"; if ( - phase === "direct" || - (req.method !== "GET" && req.method !== "HEAD") || + (phase === "direct" && isRetrievalMethod) || requestPathname === "/" || requestPathname === "/api" || requestPathname.startsWith("/api/") ) { return false; } + if (!isRetrievalMethod) { + return isExistingDevPublicFile(requestPathname) + ? methodNotAllowedResponse("GET, HEAD") + : false; + } return serveRewrittenViteFilesystemRoute( req, res, @@ -5544,7 +5685,7 @@ export const loadServerActionClient = ${ }; server.middlewares.use((req, res, next) => { - void handlePagesMiddleware(req, res, next); + void handlePagesMiddleware!(req, res, next); }); }; }, diff --git a/packages/vinext/src/server/app-rsc-response-finalizer.ts b/packages/vinext/src/server/app-rsc-response-finalizer.ts index 43021c29b7..fe9e88c7bb 100644 --- a/packages/vinext/src/server/app-rsc-response-finalizer.ts +++ b/packages/vinext/src/server/app-rsc-response-finalizer.ts @@ -6,6 +6,7 @@ import { VINEXT_RSC_VARY_HEADER } from "./app-rsc-cache-busting.js"; import { mergeVaryHeader } from "./middleware-response-headers.js"; import { hasBasePath, stripBasePath } from "../utils/base-path.js"; import { normalizeDefaultLocalePathname } from "./pages-i18n.js"; +import { sanitizeMethodNotAllowedHeaders } from "./http-error-responses.js"; type FinalizeAppRscResponseOptions = { basePath: string; @@ -102,5 +103,12 @@ export async function finalizeAppRscResponse( basePathState: { basePath: options.basePath, hadBasePath }, }); + // Static-file 405 responses are synthesized before config headers run. + // Reassert their body metadata afterward so a matching headers() rule cannot + // describe a different body or replace the canonical Allow value. + if (response.status === 405 && response.headers.get("Allow") === "GET, HEAD") { + sanitizeMethodNotAllowedHeaders(response.headers, "GET, HEAD"); + } + return response; } diff --git a/packages/vinext/src/server/http-error-responses.ts b/packages/vinext/src/server/http-error-responses.ts index 734cb34052..dbe99f2139 100644 --- a/packages/vinext/src/server/http-error-responses.ts +++ b/packages/vinext/src/server/http-error-responses.ts @@ -20,6 +20,20 @@ type ErrorResponseInit = { headers?: HeadersInit; }; +const METHOD_NOT_ALLOWED_BODY_HEADERS = [ + "content-encoding", + "content-length", + "content-range", + "content-type", + "transfer-encoding", +] as const; + +export function sanitizeMethodNotAllowedHeaders(headers: Headers, allowedMethods: string): void { + for (const name of METHOD_NOT_ALLOWED_BODY_HEADERS) headers.delete(name); + headers.set("Allow", allowedMethods); + headers.set("Content-Type", "text/plain; charset=utf-8"); +} + /** * Build a 400 Bad Request plain-text response. * @@ -96,7 +110,7 @@ export function methodNotAllowedResponse( init?: ErrorResponseInit, ): Response { const headers = new Headers(init?.headers); - headers.set("Allow", allowedMethods); + sanitizeMethodNotAllowedHeaders(headers, allowedMethods); return new Response("Method Not Allowed", { status: 405, headers }); } diff --git a/packages/vinext/src/server/pages-request-pipeline.ts b/packages/vinext/src/server/pages-request-pipeline.ts index b8eaecb897..8a462f2902 100644 --- a/packages/vinext/src/server/pages-request-pipeline.ts +++ b/packages/vinext/src/server/pages-request-pipeline.ts @@ -39,6 +39,10 @@ import { mergeRewriteQuery } from "../utils/query.js"; import { addBasePathToPathname, hasBasePath } from "../utils/base-path.js"; import { patternToNextFormat } from "../routing/route-validation.js"; import { isOnDemandRevalidateRequest, PRERENDER_REVALIDATE_HEADER } from "./isr-cache.js"; +import { + methodNotAllowedResponse, + sanitizeMethodNotAllowedHeaders, +} from "./http-error-responses.js"; // All "render options" that are passed through to the renderPage callback export type PagesRenderOptions = { @@ -58,10 +62,16 @@ export async function fetchWorkerFilesystemRoute( requestPathname: string, phase: FilesystemRoutePhase, fetchAsset: (request: Request) => Promise, + publicFiles?: ReadonlySet, + isDirectBuildAsset = false, ): Promise { + const isRetrievalMethod = request.method === "GET" || request.method === "HEAD"; if ( - phase === "direct" || - (request.method !== "GET" && request.method !== "HEAD") || + (phase === "direct" && isRetrievalMethod) || + (phase === "direct" && + publicFiles !== undefined && + !isDirectBuildAsset && + !publicFiles.has(requestPathname)) || requestPathname === "/api" || requestPathname.startsWith("/api/") ) { @@ -70,8 +80,23 @@ export async function fetchWorkerFilesystemRoute( const assetUrl = new URL(request.url); assetUrl.pathname = requestPathname; assetUrl.search = ""; - const response = await fetchAsset(new Request(assetUrl, request)); - return response.status === 404 ? false : response; + // Never forward a mutating method or body to the asset binding. A HEAD probe + // establishes existence without reading the asset body; only a real asset is + // then converted to the framework's deterministic 405 response. + const assetRequest = isRetrievalMethod + ? new Request(assetUrl, request) + : new Request(assetUrl, { method: "HEAD", headers: request.headers }); + const response = await fetchAsset(assetRequest); + if (response.status === 404) return false; + if (!isRetrievalMethod) { + if (response.body && !response.body.locked) { + void response.body.cancel().catch(() => { + // Ignore cancellation failures for the discarded existence probe. + }); + } + return methodNotAllowedResponse("GET, HEAD"); + } + return response; } export type MiddlewareResult = { @@ -341,9 +366,19 @@ export async function runPagesRequest( if (!deps.serveFilesystemRoute) return null; const served = await deps.serveFilesystemRoute(requestPathname, middlewareHeaders, phase); if (served instanceof Response) { + const isStaticMethodNotAllowed = + served.status === 405 && served.headers.get("allow") === "GET, HEAD"; + const response = mergeHeaders( + served, + middlewareHeaders, + isStaticMethodNotAllowed ? undefined : middlewareStatus, + ); + if (isStaticMethodNotAllowed) { + sanitizeMethodNotAllowedHeaders(response.headers, "GET, HEAD"); + } return { type: "response", - response: mergeHeaders(served, middlewareHeaders, middlewareStatus), + response, }; } return served ? { type: "handled" } : null; @@ -516,14 +551,6 @@ export async function runPagesRequest( }; } - // Step 8b: Public-directory static files (post-middleware). - // Served after middleware so middleware can intercept/redirect public files, and - // before rewrites so a real public file wins over a fallback rewrite — matching the - // pre-refactor prod-server ordering. Adapter callbacks own their path guards; - // a true result means Node already wrote the response. - const directFilesystemResult = await serveFilesystemRoute(pathname, "direct"); - if (directFilesystemResult) return directFilesystemResult; - // Step 9: beforeFiles rewrites // Next.js server-utils.ts applies every beforeFiles rule in sequence and // continues afterFiles/fallback rules until a destination resolves. @@ -547,13 +574,17 @@ export async function runPagesRequest( } } - // beforeFiles destinations re-enter filesystem matching before API/page - // routing. afterFiles and fallback rewrites repeat the same checkpoint in - // their phase-specific loops below. - if (configRewriteFired) { - const beforeFilesResult = await serveFilesystemRoute(resolvedPathname, "beforeFiles"); - if (beforeFilesResult) return beforeFilesResult; - } + // Next.js resolves middleware and every beforeFiles rewrite before checking + // the filesystem. This matters when a rewrite moves an existing public-file + // pathname to a page or API route: the rewritten destination wins rather + // than the original file producing a static response (or method-level 405). + // afterFiles and fallback rewrites repeat the same checkpoint in their + // phase-specific loops below. + const initialFilesystemResult = await serveFilesystemRoute( + resolvedPathname, + resolvedPathnameIsRequestPathname ? "direct" : "beforeFiles", + ); + if (initialFilesystemResult) return initialFilesystemResult; const isOutsideBasePathUnclaimed = () => basePath && !hadBasePath && !configRewriteFired; const outOfBasePathNotFound = (): PagesPipelineResult => ({ diff --git a/packages/vinext/src/server/pages-router-entry.ts b/packages/vinext/src/server/pages-router-entry.ts index 8edd412749..734f8dfb02 100644 --- a/packages/vinext/src/server/pages-router-entry.ts +++ b/packages/vinext/src/server/pages-router-entry.ts @@ -68,6 +68,7 @@ const { hasMiddleware, matchPageRoute, normalizeDataRequest, + publicFiles, renderPage, runMiddleware, vinextConfig, @@ -216,8 +217,13 @@ async function handleRequest( : null, serveFilesystemRoute: async (requestPathname, _stagedHeaders, phase) => { if (!env?.ASSETS) return false; - return fetchWorkerFilesystemRoute(request, requestPathname, phase, (assetRequest) => - Promise.resolve(env.ASSETS!.fetch(assetRequest)), + return fetchWorkerFilesystemRoute( + request, + requestPathname, + phase, + (assetRequest) => Promise.resolve(env.ASSETS!.fetch(assetRequest)), + publicFiles, + missingBuildAsset, ); }, }; diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 421d1f9643..cdb7e05774 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -374,6 +374,7 @@ const OMIT_STATIC_RESPONSE_HEADERS: ReadonlySet = new Set([ VINEXT_STATIC_FILE_HEADER, "content-encoding", "content-length", + "content-range", "content-type", ]); @@ -413,6 +414,15 @@ function mergeVaryHeader( return merged; } +const OMIT_METHOD_NOT_ALLOWED_HEADERS: ReadonlySet = new Set([ + "allow", + "content-encoding", + "content-length", + "content-range", + "content-type", + "transfer-encoding", +]); + function installClientBuildManifestGlobals( clientDir: string, assetBase: string, @@ -916,7 +926,7 @@ function sendStaticMethodNotAllowed( ): void { const body = "Method Not Allowed"; res.writeHead(405, { - ...extraHeaders, + ...omitHeadersCaseInsensitive(extraHeaders ?? {}, OMIT_METHOD_NOT_ALLOWED_HEADERS), Allow: "GET, HEAD", "Content-Type": "text/plain; charset=utf-8", "Content-Length": String(Buffer.byteLength(body)), @@ -2190,7 +2200,6 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { // Set-Cookie / security headers from middleware are included in the response. serveFilesystemRoute: async (requestPathname, stagedHeaders, phase) => { if ( - (req.method !== "GET" && req.method !== "HEAD") || requestPathname === "/" || requestPathname === "/api" || requestPathname.startsWith("/api/") || diff --git a/packages/vinext/src/server/request-pipeline.ts b/packages/vinext/src/server/request-pipeline.ts index b20bcb7eaf..b76f4339c2 100644 --- a/packages/vinext/src/server/request-pipeline.ts +++ b/packages/vinext/src/server/request-pipeline.ts @@ -6,7 +6,11 @@ import { VINEXT_STATIC_FILE_HEADER, } from "./headers.js"; import { MIDDLEWARE_CACHE_HEADER } from "../utils/protocol-headers.js"; -import { forbiddenResponse, notFoundResponse } from "./http-error-responses.js"; +import { + forbiddenResponse, + methodNotAllowedResponse, + notFoundResponse, +} from "./http-error-responses.js"; import { isOpenRedirectShaped } from "./open-redirect.js"; export { isOpenRedirectShaped } from "./open-redirect.js"; @@ -139,12 +143,17 @@ export function createStaticFileSignal( * * Public files are checked after middleware and before afterFiles/fallback * rewrites. The generated App Router entry provides the public-file set; this - * helper owns the request-method and RSC exclusions plus static-file signaling. + * helper owns the RSC exclusion, existence-first method enforcement, and + * static-file signaling. Missing mutation targets continue through routing. */ export function resolvePublicFileRoute(options: ResolvePublicFileRouteOptions): Response | null { - if (options.request.method !== "GET" && options.request.method !== "HEAD") return null; if (options.pathname.endsWith(".rsc")) return null; if (!options.publicFiles.has(options.cleanPathname)) return null; + if (options.request.method !== "GET" && options.request.method !== "HEAD") { + return methodNotAllowedResponse("GET, HEAD", { + headers: options.middlewareContext.headers ?? undefined, + }); + } return createStaticFileSignal(options.cleanPathname, options.middlewareContext); } diff --git a/packages/vinext/src/utils/public-file-path.ts b/packages/vinext/src/utils/public-file-path.ts new file mode 100644 index 0000000000..cdb2d109c4 --- /dev/null +++ b/packages/vinext/src/utils/public-file-path.ts @@ -0,0 +1,13 @@ +/** Encode each pathname segment without encoding `/` separators. */ +function encodePublicFilePath(pathname: string): string { + return pathname + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); +} + +/** Precompute keys for both encoded requests and decoded proxy/dev variants. */ +export function publicFilePathVariants(pathname: string): string[] { + const encoded = encodePublicFilePath(pathname); + return encoded === pathname ? [pathname] : [pathname, encoded]; +} diff --git a/packages/vinext/src/utils/public-routes.ts b/packages/vinext/src/utils/public-routes.ts index 75b17a494d..c9b7abde1f 100644 --- a/packages/vinext/src/utils/public-routes.ts +++ b/packages/vinext/src/utils/public-routes.ts @@ -1,8 +1,15 @@ import fs from "node:fs"; import path from "pathslash"; +import { publicFilePathVariants } from "./public-file-path.js"; -export function scanPublicFileRoutes(root: string): string[] { - const publicDir = path.join(root, "public"); +export function scanPublicFileRoutes( + root: string, + configuredPublicDir: string | false = "public", +): string[] { + // Vite normalizes `publicDir: false` to an empty string in resolved config. + // Treat both representations as disabled rather than scanning the project root. + if (configuredPublicDir === false || configuredPublicDir === "") return []; + const publicDir = path.resolve(root, configuredPublicDir); const routes: string[] = []; const visitedDirs = new Set(); @@ -39,7 +46,7 @@ export function scanPublicFileRoutes(root: string): string[] { continue; } const relativePath = path.relative(publicDir, fullPath); - routes.push("/" + relativePath); + routes.push(...publicFilePathVariants("/" + relativePath)); } } @@ -51,6 +58,5 @@ export function scanPublicFileRoutes(root: string): string[] { } } - routes.sort(); - return routes; + return [...new Set(routes)].sort(); } diff --git a/tests/app-router-dev-server.test.ts b/tests/app-router-dev-server.test.ts index 2da36b07e6..0b41578d2f 100644 --- a/tests/app-router-dev-server.test.ts +++ b/tests/app-router-dev-server.test.ts @@ -2291,6 +2291,38 @@ describe("App Router route-miss root layout redirects", () => { await server?.close(); }); + // Next.js' shared router-server checks the matched filesystem output before + // rejecting unsupported methods in both dev and production. + it("returns 405 for public-file mutations in an App-only dev project", async () => { + const res = await fetch(`${baseUrl}/static.txt`, { method: "POST" }); + + expect(res.status).toBe(405); + expect(res.headers.get("allow")).toBe("GET, HEAD"); + expect(await res.text()).toBe("Method Not Allowed"); + }); + + it("updates App-only public-file mutation handling from watcher events", async () => { + const filePath = path.join( + ROOT_LAYOUT_NOT_FOUND_REDIRECT_FIXTURE_DIR, + "public", + "watcher added.txt", + ); + + await fsp.writeFile(filePath, "watcher-added", "utf8"); + server.watcher.emit("add", filePath); + try { + const added = await fetch(`${baseUrl}/watcher%20added.txt`, { method: "POST" }); + expect(added.status).toBe(405); + expect(added.headers.get("allow")).toBe("GET, HEAD"); + } finally { + await fsp.rm(filePath, { force: true }); + server.watcher.emit("unlink", filePath); + } + + const removed = await fetch(`${baseUrl}/watcher%20added.txt`, { method: "POST" }); + expect(removed.status).not.toBe(405); + }); + // Faithfully combines two Next.js contracts: // - route misses render the root not-found page inside the root layout: // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/not-found/basic/index.test.ts @@ -2417,3 +2449,54 @@ describe("App Router route-miss root layout redirects", () => { expect(new URL(res.headers.get("location")!, baseUrl).pathname).toBe("/result"); }); }); + +describe("App Router custom publicDir in dev", () => { + let server: ViteDevServer; + let baseUrl: string; + + beforeAll(async () => { + ({ server, baseUrl } = await startFixtureServer(ROOT_LAYOUT_NOT_FOUND_REDIRECT_FIXTURE_DIR, { + appRouter: true, + publicDir: "custom-public", + })); + }, 30000); + + afterAll(async () => { + await server?.close(); + }); + + it("uses the configured public directory for mutation matching", async () => { + const custom = await fetch(`${baseUrl}/custom.txt`, { method: "POST" }); + expect(custom.status).toBe(405); + expect(custom.headers.get("allow")).toBe("GET, HEAD"); + + const defaultPublic = await fetch(`${baseUrl}/static.txt`, { method: "POST" }); + expect(defaultPublic.status).not.toBe(405); + }); +}); + +describe("App Router public files whose route starts with basePath in dev", () => { + let server: ViteDevServer; + let baseUrl: string; + + beforeAll(async () => { + process.env.VINEXT_ENCODED_PATH_BASEPATH_I18N = "1"; + try { + ({ server, baseUrl } = await startFixtureServer(APP_FIXTURE_DIR, { appRouter: true })); + } finally { + delete process.env.VINEXT_ENCODED_PATH_BASEPATH_I18N; + } + }, 30000); + + afterAll(async () => { + await server?.close(); + }); + + it("does not strip a coincidental basePath segment twice before mutation matching", async () => { + const res = await fetch(`${baseUrl}/docs/docs/coincidental-basepath.txt`, { method: "POST" }); + + expect(res.status).toBe(405); + expect(res.headers.get("allow")).toBe("GET, HEAD"); + expect(await res.text()).toBe("Method Not Allowed"); + }); +}); diff --git a/tests/app-router-production-server.test.ts b/tests/app-router-production-server.test.ts index 554b452893..471d650e65 100644 --- a/tests/app-router-production-server.test.ts +++ b/tests/app-router-production-server.test.ts @@ -1405,6 +1405,14 @@ describe("App Router Production server (startProdServer)", () => { expect(await res.text()).toContain("vinext"); }); + it("returns 405 for unsupported methods on existing public files", async () => { + const res = await fetch(`${baseUrl}/logo/logo.svg`, { method: "POST" }); + + expect(res.status).toBe(405); + expect(res.headers.get("allow")).toBe("GET, HEAD"); + expect(await res.text()).toBe("Method Not Allowed"); + }); + it("serves public files under basePath and 404s without it", async () => { // Ported from Next.js: test/e2e/basepath/basepath.test.ts // https://github.com/vercel/next.js/blob/canary/test/e2e/basepath/basepath.test.ts diff --git a/tests/app-rsc-response-finalizer.test.ts b/tests/app-rsc-response-finalizer.test.ts index 1622719288..d96e9e4e9f 100644 --- a/tests/app-rsc-response-finalizer.test.ts +++ b/tests/app-rsc-response-finalizer.test.ts @@ -81,6 +81,42 @@ describe("finalizeAppRscResponse — config header application", () => { expect(response.headers.get("x-added")).toBeNull(); }); + + it("re-sanitizes static-file 405 headers after applying config headers", async () => { + const response = new Response("Method Not Allowed", { + status: 405, + headers: { Allow: "GET, HEAD", "Content-Type": "text/plain; charset=utf-8" }, + }); + const request = new Request("http://example.com/file.txt", { method: "POST" }); + + await finalizeAppRscResponse(response, request, { + basePath: "", + configHeaders: [ + { + source: "/file.txt", + headers: [ + { key: "content-encoding", value: "gzip" }, + { key: "content-length", value: "999" }, + { key: "content-range", value: "bytes 0-998/999" }, + { key: "content-type", value: "application/octet-stream" }, + { key: "transfer-encoding", value: "chunked" }, + { key: "allow", value: "POST" }, + { key: "x-config-header", value: "keep" }, + ], + }, + ], + i18nConfig: null, + requestContext: makeRequestContext(), + }); + + expect(response.headers.get("allow")).toBe("GET, HEAD"); + expect(response.headers.get("content-encoding")).toBeNull(); + expect(response.headers.get("content-length")).toBeNull(); + expect(response.headers.get("content-range")).toBeNull(); + expect(response.headers.get("content-type")).toBe("text/plain; charset=utf-8"); + expect(response.headers.get("transfer-encoding")).toBeNull(); + expect(response.headers.get("x-config-header")).toBe("keep"); + }); }); // ── App Router RSC vary header ────────────────────────────────────────── diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 58beed7587..d86b46260e 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -1246,6 +1246,32 @@ describe("scanPublicFileRoutes", () => { expect(scanPublicFileRoutes(tmpDir)).toEqual(["/first.txt", "/nested/second.txt"]); }); + + it("scans the configured public directory and respects publicDir: false", () => { + writeFile(tmpDir, "custom-public/custom.txt", "custom"); + writeFile(tmpDir, "public/default.txt", "default"); + + expect(scanPublicFileRoutes(tmpDir, "custom-public")).toEqual(["/custom.txt"]); + expect(scanPublicFileRoutes(tmpDir, false)).toEqual([]); + // Vite's resolved config represents `publicDir: false` as an empty string. + expect(scanPublicFileRoutes(tmpDir, "")).toEqual([]); + }); + + // Ported from Next.js: test/e2e/dynamic-routing/shared.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/dynamic-routing/shared.ts + it("stores public-file routes with URL-encoded path segments", () => { + writeFile(tmpDir, "public/hello copy.txt", "space"); + writeFile(tmpDir, "public/hello+copy.txt", "plus"); + writeFile(tmpDir, "public/hello%20copy.txt", "percent"); + + expect(scanPublicFileRoutes(tmpDir)).toEqual([ + "/hello copy.txt", + "/hello%20copy.txt", + "/hello%2520copy.txt", + "/hello%2Bcopy.txt", + "/hello+copy.txt", + ]); + }); }); describe("readPagesRouterEntrySource", () => { @@ -1448,6 +1474,7 @@ describe("readPagesRouterEntrySource", () => { expect(content).toContain("serveFilesystemRoute: async"); expect(content).toContain("fetchWorkerFilesystemRoute("); expect(content).toContain("env.ASSETS!.fetch(assetRequest)"); + expect(content).toContain("publicFiles"); }); it("exports the built-in fetch handler and router-specific worker entries", () => { @@ -1881,6 +1908,104 @@ describe("fetchWorkerFilesystemRoute", () => { expect(fetchAsset).toHaveBeenCalledOnce(); }); + it.each(["POST", "DELETE"])( + "uses a bodyless HEAD probe before returning 405 for an existing asset on %s", + async (method) => { + const fetchAsset = vi.fn(async (request: Request) => { + expect(request.method).toBe("HEAD"); + expect(request.body).toBeNull(); + return new Response(null, { status: 200 }); + }); + + const sourceRequest = + method === "POST" + ? new Request("https://example.com/file.txt", { + method: "POST", + body: "must-not-forward", + }) + : new Request("https://example.com/file.txt", { method: "DELETE" }); + const result = await fetchWorkerFilesystemRoute( + sourceRequest, + "/file.txt", + "direct", + fetchAsset, + new Set(["/file.txt"]), + ); + + expect(result).toBeInstanceOf(Response); + if (!(result instanceof Response)) return; + expect(result.status).toBe(405); + expect(result.headers.get("allow")).toBe("GET, HEAD"); + await expect(result.text()).resolves.toBe("Method Not Allowed"); + }, + ); + + it("falls through after a HEAD probe misses for an unsupported method", async () => { + const fetchAsset = vi.fn(async (request: Request) => { + expect(request.method).toBe("HEAD"); + return new Response(null, { status: 404 }); + }); + + await expect( + fetchWorkerFilesystemRoute( + new Request("https://example.com/missing.txt", { method: "POST" }), + "/missing.txt", + "direct", + fetchAsset, + new Set(["/missing.txt"]), + ), + ).resolves.toBe(false); + }); + + it("skips direct mutation probes when the pathname is not a public file", async () => { + const fetchAsset = vi.fn(async () => new Response(null, { status: 200 })); + + await expect( + fetchWorkerFilesystemRoute( + new Request("https://example.com/checkout", { method: "POST", body: "order=1" }), + "/checkout", + "direct", + fetchAsset, + new Set(["/file.txt"]), + ), + ).resolves.toBe(false); + expect(fetchAsset).not.toHaveBeenCalled(); + }); + + it.each(["/hello%20copy.txt", "/hello copy.txt"])( + "matches encoded public-file inventory for %s", + async (requestPathname) => { + const fetchAsset = vi.fn(async () => new Response(null, { status: 200 })); + + const result = await fetchWorkerFilesystemRoute( + new Request("https://example.com/hello%20copy.txt", { method: "POST" }), + requestPathname, + "direct", + fetchAsset, + new Set(["/hello copy.txt", "/hello%20copy.txt"]), + ); + + expect(result).toBeInstanceOf(Response); + expect(fetchAsset).toHaveBeenCalledOnce(); + }, + ); + + it("still probes direct built assets outside the public-file inventory", async () => { + const fetchAsset = vi.fn(async () => new Response(null, { status: 200 })); + + const result = await fetchWorkerFilesystemRoute( + new Request("https://example.com/_next/static/chunks/app.js", { method: "POST" }), + "/_next/static/chunks/app.js", + "direct", + fetchAsset, + new Set(), + true, + ); + + expect(result).toBeInstanceOf(Response); + expect(fetchAsset).toHaveBeenCalledOnce(); + }); + it("skips direct and API filesystem probes", async () => { const fetchAsset = vi.fn(async () => new Response("unexpected")); diff --git a/tests/e2e/cloudflare-pages-router-dev/pages-router.spec.ts b/tests/e2e/cloudflare-pages-router-dev/pages-router.spec.ts index 37737dcb92..c95c31722e 100644 --- a/tests/e2e/cloudflare-pages-router-dev/pages-router.spec.ts +++ b/tests/e2e/cloudflare-pages-router-dev/pages-router.spec.ts @@ -36,6 +36,23 @@ test.describe("Pages Router on Cloudflare Workers (vite dev)", () => { expect(html).toContain("__NEXT_DATA__"); }); + test("public-file mutations are handled inside the Cloudflare Worker", async ({ request }) => { + const res = await request.post(`${BASE}/static.txt`); + + expect(res.status()).toBe(405); + expect(res.headers()["allow"]).toBe("GET, HEAD"); + expect(await res.text()).toBe("Method Not Allowed"); + }); + + test("encoded public-file mutations are handled inside the Cloudflare Worker", async ({ + request, + }) => { + const res = await request.post(`${BASE}/hello%20copy.txt`); + + expect(res.status()).toBe(405); + expect(res.headers()["allow"]).toBe("GET, HEAD"); + }); + test("GSSP runs inside the Cloudflare Worker", async ({ request }) => { // getServerSideProps on /ssr reads navigator.userAgent and embeds it in // the rendered HTML. "Cloudflare-Workers" can only appear if GSSP executed diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index 37aced6185..4a8f5af2a1 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -1344,6 +1344,32 @@ describe("App Router entry templates", () => { // ── Pages Router entry template runtime bootstrap ───────────────────── describe("Pages Router entry template", () => { + it("embeds the build-time public-file inventory", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-public-entry-")); + const pagesDir = path.join(tmpDir, "pages"); + + try { + fs.mkdirSync(pagesDir, { recursive: true }); + fs.writeFileSync( + path.join(pagesDir, "index.tsx"), + "export default function Page() { return null; }", + ); + + const code = await generateServerEntry( + pagesDir, + await resolveNextConfig({}), + createValidFileMatcher(), + null, + null, + ["/static.txt"], + ); + + expect(code).toContain('export const publicFiles = new Set(["/static.txt"]);'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("reports trusted _next/data classification from URL normalization", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-data-entry-")); const pagesDir = path.join(tmpDir, "pages"); diff --git a/tests/fixtures/app-basic/public/docs/coincidental-basepath.txt b/tests/fixtures/app-basic/public/docs/coincidental-basepath.txt new file mode 100644 index 0000000000..00a74efb8f --- /dev/null +++ b/tests/fixtures/app-basic/public/docs/coincidental-basepath.txt @@ -0,0 +1 @@ +coincidental base path public file diff --git a/tests/fixtures/root-layout-not-found-redirect/custom-public/custom.txt b/tests/fixtures/root-layout-not-found-redirect/custom-public/custom.txt new file mode 100644 index 0000000000..2483765d26 --- /dev/null +++ b/tests/fixtures/root-layout-not-found-redirect/custom-public/custom.txt @@ -0,0 +1 @@ +custom public asset diff --git a/tests/fixtures/root-layout-not-found-redirect/public/static.txt b/tests/fixtures/root-layout-not-found-redirect/public/static.txt new file mode 100644 index 0000000000..3078812992 --- /dev/null +++ b/tests/fixtures/root-layout-not-found-redirect/public/static.txt @@ -0,0 +1 @@ +app-only public asset diff --git a/tests/helpers.ts b/tests/helpers.ts index 57c1f3d1fa..75b5df6ca4 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -56,6 +56,7 @@ export async function startFixtureServer( appDir?: string | null; appRouter?: boolean; listen?: boolean; + publicDir?: string | false; server?: { host?: string; allowedHosts?: true | string[]; @@ -86,6 +87,7 @@ export async function startFixtureServer( root: fixtureDir, configFile: false, plugins, + publicDir: opts?.publicDir, // Vite may discover additional deps after the first request (especially // with @vitejs/plugin-rsc environments) and trigger a re-optimization. // In non-browser test clients, we can't "reload" and would otherwise diff --git a/tests/pages-request-pipeline.test.ts b/tests/pages-request-pipeline.test.ts index 61ea7f82ac..775905bd92 100644 --- a/tests/pages-request-pipeline.test.ts +++ b/tests/pages-request-pipeline.test.ts @@ -1138,6 +1138,40 @@ describe("serveFilesystemRoute", () => { expect(renderPage).not.toHaveBeenCalled(); }); + it("strips stale middleware body headers from static 405 responses", async () => { + const serveFilesystemRoute = vi.fn( + async () => + new Response("Method Not Allowed", { + status: 405, + headers: { Allow: "GET, HEAD" }, + }), + ); + const middleware = makeMiddleware({ + status: 404, + responseHeaders: [ + ["content-encoding", "gzip"], + ["content-length", "999"], + ["content-type", "application/wrong"], + ["transfer-encoding", "chunked"], + ["x-from-middleware", "1"], + ], + }); + + const result = await runPagesRequest( + new Request("https://example.com/file.txt", { method: "POST" }), + baseDeps({ serveFilesystemRoute, runMiddleware: middleware }), + ); + expect(result.type).toBe("response"); + if (result.type !== "response") return; + expect(result.response.status).toBe(405); + expect(result.response.headers.get("allow")).toBe("GET, HEAD"); + expect(result.response.headers.get("content-encoding")).toBeNull(); + expect(result.response.headers.get("content-length")).toBeNull(); + expect(result.response.headers.get("content-type")).toBe("text/plain; charset=utf-8"); + expect(result.response.headers.get("transfer-encoding")).toBeNull(); + expect(result.response.headers.get("x-from-middleware")).toBe("1"); + }); + it("falls through to render when serveFilesystemRoute returns false", async () => { const renderPage = makeRenderPage(200); const serveFilesystemRoute = vi.fn(async () => false); @@ -1203,13 +1237,94 @@ describe("serveFilesystemRoute", () => { ); expect(result.type).toBe("handled"); - expect(serveFilesystemRoute).toHaveBeenNthCalledWith( - 1, - "/sv/rewrite-files/file.txt", - {}, - "direct", + expect(serveFilesystemRoute).toHaveBeenCalledOnce(); + expect(serveFilesystemRoute).toHaveBeenCalledWith("/file.txt", {}, "beforeFiles"); + }); + + // Next.js runs beforeFiles rewrites before check_fs: + // https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/router-utils/resolve-routes.ts + it("lets a beforeFiles rewrite move a mutation away from an existing public file", async () => { + const serveFilesystemRoute = vi.fn(async (pathname: string) => + pathname === "/asset.txt" + ? new Response("Method Not Allowed", { + status: 405, + headers: { Allow: "GET, HEAD" }, + }) + : false, + ); + const handleApi = vi.fn(async () => new Response("rewritten api", { status: 201 })); + + const result = await runPagesRequest( + new Request("http://localhost/asset.txt", { method: "POST" }), + baseDeps({ + configRewrites: { + beforeFiles: [{ source: "/asset.txt", destination: "/api/rewritten" }], + afterFiles: [], + fallback: [], + }, + handleApi, + serveFilesystemRoute, + }), + ); + + expect(result.type).toBe("response"); + if (result.type !== "response") return; + expect(result.response.status).toBe(201); + await expect(result.response.text()).resolves.toBe("rewritten api"); + expect(serveFilesystemRoute).toHaveBeenCalledOnce(); + expect(serveFilesystemRoute).toHaveBeenCalledWith("/api/rewritten", {}, "beforeFiles"); + expect(handleApi).toHaveBeenCalledWith(expect.any(Request), "/api/rewritten", null); + }); + + it("lets a middleware rewrite move a mutation away from an existing public file", async () => { + const serveFilesystemRoute = vi.fn(async (pathname: string) => + pathname === "/asset.txt" + ? new Response("Method Not Allowed", { + status: 405, + headers: { Allow: "GET, HEAD" }, + }) + : false, ); - expect(serveFilesystemRoute).toHaveBeenNthCalledWith(2, "/file.txt", {}, "beforeFiles"); + const handleApi = vi.fn(async () => new Response("middleware api", { status: 202 })); + + const result = await runPagesRequest( + new Request("http://localhost/asset.txt", { method: "POST" }), + baseDeps({ + handleApi, + runMiddleware: makeMiddleware({ rewriteUrl: "/api/from-middleware" }), + serveFilesystemRoute, + }), + ); + + expect(result.type).toBe("response"); + if (result.type !== "response") return; + expect(result.response.status).toBe(202); + await expect(result.response.text()).resolves.toBe("middleware api"); + expect(serveFilesystemRoute).toHaveBeenCalledOnce(); + expect(serveFilesystemRoute).toHaveBeenCalledWith("/api/from-middleware", {}, "beforeFiles"); + expect(handleApi).toHaveBeenCalledWith(expect.any(Request), "/api/from-middleware", null); + }); + + it("re-enters filesystem matching after a middleware rewrite", async () => { + const serveFilesystemRoute = vi.fn(async (pathname: string, _headers, phase) => + pathname === "/asset.txt" && phase === "beforeFiles" + ? new Response("rewritten asset") + : false, + ); + + const result = await runPagesRequest( + makeRequest("/source"), + baseDeps({ + runMiddleware: makeMiddleware({ rewriteUrl: "/asset.txt" }), + serveFilesystemRoute, + }), + ); + + expect(result.type).toBe("response"); + if (result.type !== "response") return; + await expect(result.response.text()).resolves.toBe("rewritten asset"); + expect(serveFilesystemRoute).toHaveBeenCalledOnce(); + expect(serveFilesystemRoute).toHaveBeenCalledWith("/asset.txt", {}, "beforeFiles"); }); it("returns a Worker-style asset response after a beforeFiles rewrite", async () => { diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 9e2f765655..47e0eb6847 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -749,6 +749,38 @@ describe("Pages Router integration", () => { expect(await res.text()).toContain("Method Not Allowed"); }); + it("returns 405 for an existing public file after middleware, but lets misses route", async () => { + const get = await fetch(`${baseUrl}/dedupe-script.js`); + expect(get.status).toBe(200); + expect(await get.text()).toContain("window.__vinextScriptDedupeExecutions"); + + const head = await fetch(`${baseUrl}/dedupe-script.js`, { method: "HEAD" }); + expect(head.status).toBe(200); + expect(await head.text()).toBe(""); + + const existing = await fetch(`${baseUrl}/dedupe-script.js`, { method: "POST" }); + expect(existing.status).toBe(405); + expect(existing.headers.get("allow")).toBe("GET, HEAD"); + expect(existing.headers.get("x-custom-middleware")).toBe("active"); + expect(await existing.text()).toBe("Method Not Allowed"); + + const missing = await fetch(`${baseUrl}/missing-public-file.js`, { method: "POST" }); + expect(missing.status).not.toBe(405); + }); + + it("does not classify files under a disabled Vite publicDir as static assets", async () => { + const disabled = await startFixtureServer(FIXTURE_DIR, { publicDir: false }); + try { + const get = await fetch(`${disabled.baseUrl}/dedupe-script.js`); + expect(get.status).not.toBe(200); + + const post = await fetch(`${disabled.baseUrl}/dedupe-script.js`, { method: "POST" }); + expect(post.status).not.toBe(405); + } finally { + await disabled.server.close(); + } + }); + // Refs #1463: GSP (getStaticProps) pages are also "static" from the // routing perspective; POST should produce 405. Mirrors the Next.js // condition `(typeof components.Component === 'string' || isSSG)` in @@ -6591,6 +6623,14 @@ describe("Production server middleware (Pages Router)", () => { expect(await res.text()).toContain("Method Not Allowed"); }); + it("returns 405 with Allow: GET, HEAD on POST to an existing public file (prod)", async () => { + const res = await fetch(`${prodUrl}/dedupe-script.js`, { method: "POST" }); + + expect(res.status).toBe(405); + expect(res.headers.get("allow")).toBe("GET, HEAD"); + expect(await res.text()).toBe("Method Not Allowed"); + }); + // Regression for #1331: after a middleware rewrite, the rewrite target // must go through full route resolution where static routes win over // dynamic catch-alls. Without the fix the `[id]` dynamic page captures diff --git a/tests/request-pipeline.test.ts b/tests/request-pipeline.test.ts index 275133895a..3135759f23 100644 --- a/tests/request-pipeline.test.ts +++ b/tests/request-pipeline.test.ts @@ -290,7 +290,13 @@ describe("resolvePublicFileRoute", () => { const response = resolvePublicFileRoute({ cleanPathname: "/logo.svg", middlewareContext: { - headers: new Headers({ "x-from-middleware": "1" }), + headers: new Headers({ + "content-encoding": "gzip", + "content-length": "999", + "content-type": "application/wrong", + "transfer-encoding": "chunked", + "x-from-middleware": "1", + }), status: 203, }, pathname: "/logo.svg", @@ -304,19 +310,28 @@ describe("resolvePublicFileRoute", () => { expect(response!.headers.get("x-from-middleware")).toBe("1"); }); - it("does not signal non-GET/HEAD, RSC, or missing public file requests", () => { + it("returns 405 for unsupported methods only after a public file match", async () => { const publicFiles = new Set(["/logo.svg", "/about.rsc"]); const middlewareContext = { headers: null, status: null }; - expect( - resolvePublicFileRoute({ - cleanPathname: "/logo.svg", - middlewareContext, - pathname: "/logo.svg", - publicFiles, - request: new Request("https://example.com/logo.svg", { method: "POST" }), - }), - ).toBeNull(); + const mutationResponse = resolvePublicFileRoute({ + cleanPathname: "/logo.svg", + middlewareContext: { + headers: new Headers({ "x-from-middleware": "1" }), + status: null, + }, + pathname: "/logo.svg", + publicFiles, + request: new Request("https://example.com/logo.svg", { method: "POST" }), + }); + expect(mutationResponse?.status).toBe(405); + expect(mutationResponse?.headers.get("allow")).toBe("GET, HEAD"); + expect(mutationResponse?.headers.get("x-from-middleware")).toBe("1"); + expect(mutationResponse?.headers.get("content-encoding")).toBeNull(); + expect(mutationResponse?.headers.get("content-length")).toBeNull(); + expect(mutationResponse?.headers.get("content-type")).toBe("text/plain; charset=utf-8"); + expect(mutationResponse?.headers.get("transfer-encoding")).toBeNull(); + await expect(mutationResponse?.text()).resolves.toBe("Method Not Allowed"); expect( resolvePublicFileRoute({ cleanPathname: "/about.rsc", @@ -337,6 +352,19 @@ describe("resolvePublicFileRoute", () => { ).toBeNull(); }); + it("matches decoded request variants against encoded public-file keys", () => { + const response = resolvePublicFileRoute({ + cleanPathname: "/hello copy.txt", + middlewareContext: { headers: null, status: null }, + pathname: "/hello%20copy.txt", + publicFiles: new Set(["/hello copy.txt", "/hello%20copy.txt"]), + request: new Request("https://example.com/hello%20copy.txt", { method: "POST" }), + }); + + expect(response?.status).toBe(405); + expect(response?.headers.get("allow")).toBe("GET, HEAD"); + }); + it("creates standalone static file signals from normal modules", () => { const response = createStaticFileSignal("/robots.txt", { headers: new Headers({ "cache-control": "no-store" }), diff --git a/tests/serve-static.test.ts b/tests/serve-static.test.ts index 6881c87f5f..55783be139 100644 --- a/tests/serve-static.test.ts +++ b/tests/serve-static.test.ts @@ -107,6 +107,66 @@ describe("tryServeStatic (with StaticFileCache)", () => { await fsp.rm(clientDir, { recursive: true, force: true }); }); + it("returns 405 with Allow for unsupported methods on cached assets", async () => { + await writeFile(clientDir, "_next/static/app-abc123.js", "console.log('asset')"); + const cache = await StaticFileCache.create(clientDir); + const req = mockReq(undefined, undefined, "POST"); + const { res, captured } = mockRes(); + + const served = await tryServeStatic( + req, + res, + clientDir, + "/_next/static/app-abc123.js", + true, + cache, + ); + await captured.ended; + + expect(served).toBe(true); + expect(captured.status).toBe(405); + expect(captured.headers.Allow).toBe("GET, HEAD"); + expect(captured.body.toString()).toBe("Method Not Allowed"); + }); + + it("returns 405 with Allow for unsupported methods on uncached assets", async () => { + await writeFile(clientDir, "robots.txt", "User-agent: *"); + const req = mockReq(undefined, undefined, "DELETE"); + const { res, captured } = mockRes(); + + const served = await tryServeStatic(req, res, clientDir, "/robots.txt", true, undefined, { + "X-From-Middleware": "preserved", + "Set-Cookie": ["a=1", "b=2"], + "Content-Encoding": "gzip", + "Content-Range": "bytes 0-17/18", + "Content-Type": "application/wrong", + }); + await captured.ended; + + expect(served).toBe(true); + expect(captured.status).toBe(405); + expect(captured.headers.Allow).toBe("GET, HEAD"); + expect(captured.headers["X-From-Middleware"]).toBe("preserved"); + expect(captured.headers["Set-Cookie"]).toEqual(["a=1", "b=2"]); + expect(captured.headers["Content-Encoding"]).toBeUndefined(); + expect(captured.headers["Content-Range"]).toBeUndefined(); + expect(captured.headers["Content-Type"]).toBe("text/plain; charset=utf-8"); + expect(captured.body.toString()).toBe("Method Not Allowed"); + }); + + it("does not turn missing assets into method errors", async () => { + const cache = await StaticFileCache.create(clientDir); + const req = mockReq(undefined, undefined, "POST"); + const { res } = mockRes(); + + await expect( + tryServeStatic(req, res, clientDir, "/_next/static/missing.js", true, cache), + ).resolves.toBe(false); + await expect(tryServeStatic(req, res, clientDir, "/public-missing.txt", true)).resolves.toBe( + false, + ); + }); + // ── Precompressed serving ────────────────────────────────────── it("serves precompressed brotli for hashed assets when client accepts br", async () => {