diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index c443c265f..f318040bd 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -8,6 +8,7 @@ * Previously housed in server/app-dev-server.ts. */ import fs from "node:fs"; +import path from "node:path"; import { fileURLToPath } from "node:url"; import type { NextHeader, @@ -25,6 +26,7 @@ import { generateRouteMatchNormalizationCode, } from "../server/middleware-codegen.js"; import { isProxyFile } from "../server/middleware.js"; +import { MIME_TYPES } from "../server/mime.js"; // Pre-computed absolute paths for generated-code imports. The virtual RSC // entry can't use relative imports (it has no real file location), so we @@ -106,6 +108,7 @@ export function generateRscEntry( trailingSlash?: boolean, config?: AppRouterConfig, instrumentationPath?: string | null, + root?: string, ): string { const bp = basePath ?? ""; const ts = trailingSlash ?? false; @@ -115,6 +118,11 @@ export function generateRscEntry( const allowedOrigins = config?.allowedOrigins ?? []; const bodySizeLimit = config?.bodySizeLimit ?? 1 * 1024 * 1024; const i18nConfig = config?.i18n ?? null; + // Compute the public/ directory path for serving static files after rewrites. + // appDir is something like /project/app or /project/src/app; root is the Vite root. + // We need `root` for correctness — path.dirname(appDir) is wrong for src/app layouts + // (e.g. /project/src/public instead of /project/public). + const publicDir = root ? path.resolve(root, "public") : null; // Build import map for all page and layout files const imports: string[] = []; const importMap: Map = new Map(); @@ -293,6 +301,8 @@ ${slotEntries.join(",\n")} }); return ` +import __nodeFs from "node:fs"; +import __nodePath from "node:path"; import { renderToReadableStream, decodeReply, @@ -1334,6 +1344,8 @@ const __configRedirects = ${JSON.stringify(redirects)}; const __configRewrites = ${JSON.stringify(rewrites)}; const __configHeaders = ${JSON.stringify(headers)}; const __allowedOrigins = ${JSON.stringify(allowedOrigins)}; +const __mimeTypes = ${JSON.stringify(MIME_TYPES)}; +const __publicDir = ${JSON.stringify(publicDir)}; ${generateDevOriginCheckCode(config?.allowedDevOrigins)} @@ -1948,6 +1960,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return proxyExternalRequest(request, __afterRewritten); } cleanPathname = __afterRewritten; + // If the rewritten path has a file extension, it may point to a static + // file in public/. Serve it directly before route matching. + const __afterExtname = __nodePath.extname(cleanPathname); + if (__afterExtname && __publicDir !== null) { + // "." + cleanPathname works because rewrite destinations always start with "/"; + // the traversal guard below catches any malformed path regardless. + const __afterPublicFile = __nodePath.resolve(__publicDir, "." + cleanPathname); + if (__afterPublicFile.startsWith(__publicDir + __nodePath.sep)) { + try { + const __afterStat = __nodeFs.statSync(__afterPublicFile); + if (__afterStat.isFile()) { + const __afterContent = __nodeFs.readFileSync(__afterPublicFile); + const __afterExt = __afterExtname.slice(1).toLowerCase(); + setHeadersContext(null); + setNavigationContext(null); + return new Response(__afterContent, { status: 200, headers: { "Content-Type": __mimeTypes[__afterExt] ?? "application/octet-stream" } }); + } + } catch (__e) { if (__e?.code !== 'ENOENT') console.warn('[vinext] static file check failed:', __e); } + } + } } } @@ -1963,7 +1995,25 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return proxyExternalRequest(request, __fallbackRewritten); } cleanPathname = __fallbackRewritten; - match = matchRoute(cleanPathname); + // Check if fallback targets a static file in public/ + const __fbExtname = __nodePath.extname(cleanPathname); + if (__fbExtname && __publicDir !== null) { + // "." + cleanPathname: see afterFiles comment above — leading "/" is assumed. + const __fbPublicFile = __nodePath.resolve(__publicDir, "." + cleanPathname); + if (__fbPublicFile.startsWith(__publicDir + __nodePath.sep)) { + try { + const __fbStat = __nodeFs.statSync(__fbPublicFile); + if (__fbStat.isFile()) { + const __fbContent = __nodeFs.readFileSync(__fbPublicFile); + const __fbExt = __fbExtname.slice(1).toLowerCase(); + setHeadersContext(null); + setNavigationContext(null); + return new Response(__fbContent, { status: 200, headers: { "Content-Type": __mimeTypes[__fbExt] ?? "application/octet-stream" } }); + } + } catch (__e) { if (__e?.code !== 'ENOENT') console.warn('[vinext] static file check failed:', __e); } + } + } + match = matchRoute(cleanPathname, routes); } } diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 50da4b418..0c3843039 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -53,6 +53,7 @@ import { } from "./utils/manifest-paths.js"; import { hasBasePath } from "./utils/base-path.js"; import { asyncHooksStubPlugin } from "./plugins/async-hooks-stub.js"; +import { mimeType } from "./server/mime.js"; import { clientReferenceDedupPlugin } from "./plugins/client-reference-dedup.js"; import { hasWranglerConfig, formatMissingCloudflarePluginError } from "./deploy.js"; import tsconfigPaths from "vite-tsconfig-paths"; @@ -1624,6 +1625,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { i18n: nextConfig?.i18n, }, instrumentationPath, + root, ); } if (id === RESOLVED_APP_SSR_ENTRY && hasAppDir) { @@ -2360,6 +2362,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const routes = await pagesRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher); + const resolvedPublicDir = path.resolve(root, "public"); + // Apply afterFiles rewrites — these run after initial route matching // If beforeFiles already rewrote the URL, afterFiles still run on the // *resolved* pathname. Next.js applies these when route matching succeeds @@ -2370,10 +2374,43 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { nextConfig.rewrites.afterFiles, reqCtx, ); - if (afterRewrite) resolvedUrl = afterRewrite; + if (afterRewrite) { + resolvedUrl = afterRewrite; + // External rewrite from afterFiles — proxy to external URL + if (isExternalUrl(afterRewrite)) { + await proxyExternalRewriteNode(req, res, afterRewrite); + return; + } + // If the rewritten path has a file extension, it may point to a + // static file in public/. Serve it directly before route matching + // (which would miss it and SSR would return 404). + const afterFilesPathname = afterRewrite.split("?")[0]; + if (path.extname(afterFilesPathname)) { + // "." + afterFilesPathname works because rewrite destinations always start with "/" + const publicFilePath = path.resolve( + resolvedPublicDir, + "." + afterFilesPathname, + ); + if (publicFilePath.startsWith(resolvedPublicDir + path.sep)) { + try { + const stat = fs.statSync(publicFilePath); + if (stat.isFile()) { + const content = fs.readFileSync(publicFilePath); + const ext = path.extname(afterFilesPathname).slice(1).toLowerCase(); + res.writeHead(200, { "Content-Type": mimeType(ext) }); + res.end(content); + return; + } + } catch (e: any) { + if (e?.code !== "ENOENT") + console.warn("[vinext] static file check failed:", e); + } + } + } + } } - // External rewrite from afterFiles — proxy to external URL + // External rewrite (from beforeFiles) — proxy to external URL if (isExternalUrl(resolvedUrl)) { await proxyExternalRewriteNode(req, res, resolvedUrl); return; @@ -2413,6 +2450,27 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { await proxyExternalRewriteNode(req, res, fallbackRewrite); return; } + // Check if fallback targets a static file in public/ + const fallbackPathname = fallbackRewrite.split("?")[0]; + if (path.extname(fallbackPathname)) { + const resolvedPublicDir = path.resolve(root, "public"); + const publicFilePath = path.resolve(resolvedPublicDir, "." + fallbackPathname); + if (publicFilePath.startsWith(resolvedPublicDir + path.sep)) { + try { + const stat = fs.statSync(publicFilePath); + if (stat.isFile()) { + const content = fs.readFileSync(publicFilePath); + const ext = path.extname(fallbackPathname).slice(1).toLowerCase(); + res.writeHead(200, { "Content-Type": mimeType(ext) }); + res.end(content); + return; + } + } catch (e: any) { + if (e?.code !== "ENOENT") + console.warn("[vinext] static file check failed:", e); + } + } + } const fallbackMatch = matchRoute(fallbackRewrite.split("?")[0], routes); if (!fallbackMatch && hasAppDir) { return next(); @@ -2429,7 +2487,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // otherwise render via the pages SSR handler (will 404 for unknown routes). if (hasAppDir) return next(); - await handler(req, res, resolvedUrl, mwStatus); + try { + await handler(req, res, resolvedUrl, mwStatus); + } catch (e) { + next(e); + } } catch (e) { next(e); } diff --git a/packages/vinext/src/server/mime.ts b/packages/vinext/src/server/mime.ts new file mode 100644 index 000000000..25a98e3e3 --- /dev/null +++ b/packages/vinext/src/server/mime.ts @@ -0,0 +1,47 @@ +/** + * Shared MIME type map for serving static files. + * + * Used by index.ts (Pages Router dev), app-dev-server.ts (generated RSC entry), + * and prod-server.ts (production server). Centralised here to avoid drift. + * + * Keys are bare extensions (no leading dot). Use `mimeType()` for lookup. + */ +export const MIME_TYPES: Record = { + html: "text/html; charset=utf-8", + htm: "text/html; charset=utf-8", + css: "text/css", + js: "application/javascript", + mjs: "application/javascript", + cjs: "application/javascript", + json: "application/json", + txt: "text/plain", + xml: "application/xml", + svg: "image/svg+xml", + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + avif: "image/avif", + ico: "image/x-icon", + woff: "font/woff", + woff2: "font/woff2", + ttf: "font/ttf", + otf: "font/otf", + eot: "application/vnd.ms-fontobject", + pdf: "application/pdf", + map: "application/json", + mp4: "video/mp4", + webm: "video/webm", + mp3: "audio/mpeg", + ogg: "audio/ogg", + wasm: "application/wasm", +}; + +/** + * Look up a MIME type by bare extension (no leading dot). + * Returns "application/octet-stream" for unknown extensions. + */ +export function mimeType(ext: string): string { + return MIME_TYPES[ext] ?? "application/octet-stream"; +} diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 951ec231b..22d1e349a 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -46,6 +46,7 @@ import { import { normalizePath } from "./normalize-path.js"; import { hasBasePath, stripBasePath } from "../utils/base-path.js"; import { computeLazyChunks } from "../index.js"; +import { mimeType } from "./mime.js"; import { manifestFileWithBase } from "../utils/manifest-paths.js"; import { normalizePathnameForRouteMatchStrict } from "../routing/utils.js"; import type { ExecutionContextLike } from "../shims/request-context.js"; @@ -221,27 +222,11 @@ function sendCompressed( } } -/** Content-type lookup for static assets. */ -const CONTENT_TYPES: Record = { - ".js": "application/javascript", - ".mjs": "application/javascript", - ".css": "text/css", - ".html": "text/html", - ".json": "application/json", - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".svg": "image/svg+xml", - ".ico": "image/x-icon", - ".woff": "font/woff", - ".woff2": "font/woff2", - ".ttf": "font/ttf", - ".eot": "application/vnd.ms-fontobject", - ".webp": "image/webp", - ".avif": "image/avif", - ".map": "application/json", -}; +/** Content-type lookup for static assets using the shared MIME map. */ +function contentType(ext: string): string { + // ext comes from path.extname() so it has a leading dot (e.g. ".js") + return mimeType(ext.startsWith(".") ? ext.slice(1) : ext); +} /** * Try to serve a static file from the client build directory. @@ -280,7 +265,7 @@ function tryServeStatic( } const ext = path.extname(staticFile); - const ct = CONTENT_TYPES[ext] ?? "application/octet-stream"; + const ct = contentType(ext); const isHashed = pathname.startsWith("/assets/"); const cacheControl = isHashed ? "public, max-age=31536000, immutable" : "public, max-age=3600"; @@ -648,7 +633,7 @@ async function startAppRouterServer(options: AppRouterServerOptions) { // Block SVG and other unsafe content types by checking the file extension. // SVG is only allowed when dangerouslyAllowSVG is enabled in next.config.js. const ext = path.extname(params.imageUrl).toLowerCase(); - const ct = CONTENT_TYPES[ext] ?? "application/octet-stream"; + const ct = contentType(ext); if (!isSafeImageContentType(ct, imageConfig?.dangerouslyAllowSVG)) { res.writeHead(400); res.end("The requested resource is not an allowed image type"); @@ -832,7 +817,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { // Block SVG and other unsafe content types. // SVG is only allowed when dangerouslyAllowSVG is enabled. const ext = path.extname(params.imageUrl).toLowerCase(); - const ct = CONTENT_TYPES[ext] ?? "application/octet-stream"; + const ct = contentType(ext); if (!isSafeImageContentType(ct, pagesImageConfig?.dangerouslyAllowSVG)) { res.writeHead(400); res.end("The requested resource is not an allowed image type"); @@ -1108,6 +1093,15 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { } resolvedUrl = rewritten; resolvedPathname = rewritten.split("?")[0]; + // If the rewritten path has a file extension, it may point to a static + // file in public/ (copied to clientDir during build). Try to serve it + // directly before falling through to SSR (which would return 404). + if ( + path.extname(resolvedPathname) && + tryServeStatic(req, res, clientDir, resolvedPathname, compress) + ) { + return; + } } } @@ -1129,6 +1123,14 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { await sendWebResponse(proxyResponse, req, res, compress); return; } + // Check if fallback targets a static file in public/ + const fallbackPathname = fallbackRewrite.split("?")[0]; + if ( + path.extname(fallbackPathname) && + tryServeStatic(req, res, clientDir, fallbackPathname, compress) + ) { + return; + } response = await renderPage(webRequest, fallbackRewrite, ssrManifest); } } diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index e3118453f..2b2089239 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -335,6 +335,8 @@ main(); exports[`App Router entry templates > generateRscEntry snapshot (minimal routes) 1`] = ` " +import __nodeFs from "node:fs"; +import __nodePath from "node:path"; import { renderToReadableStream, decodeReply, @@ -1412,6 +1414,8 @@ const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; const __allowedOrigins = []; +const __mimeTypes = {"html":"text/html; charset=utf-8","htm":"text/html; charset=utf-8","css":"text/css","js":"application/javascript","mjs":"application/javascript","cjs":"application/javascript","json":"application/json","txt":"text/plain","xml":"application/xml","svg":"image/svg+xml","png":"image/png","jpg":"image/jpeg","jpeg":"image/jpeg","gif":"image/gif","webp":"image/webp","avif":"image/avif","ico":"image/x-icon","woff":"font/woff","woff2":"font/woff2","ttf":"font/ttf","otf":"font/otf","eot":"application/vnd.ms-fontobject","pdf":"application/pdf","map":"application/json","mp4":"video/mp4","webm":"video/webm","mp3":"audio/mpeg","ogg":"audio/ogg","wasm":"application/wasm"}; +const __publicDir = null; // ── Dev server origin verification ────────────────────────────────────── @@ -2082,6 +2086,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return proxyExternalRequest(request, __afterRewritten); } cleanPathname = __afterRewritten; + // If the rewritten path has a file extension, it may point to a static + // file in public/. Serve it directly before route matching. + const __afterExtname = __nodePath.extname(cleanPathname); + if (__afterExtname && __publicDir !== null) { + // "." + cleanPathname works because rewrite destinations always start with "/"; + // the traversal guard below catches any malformed path regardless. + const __afterPublicFile = __nodePath.resolve(__publicDir, "." + cleanPathname); + if (__afterPublicFile.startsWith(__publicDir + __nodePath.sep)) { + try { + const __afterStat = __nodeFs.statSync(__afterPublicFile); + if (__afterStat.isFile()) { + const __afterContent = __nodeFs.readFileSync(__afterPublicFile); + const __afterExt = __afterExtname.slice(1).toLowerCase(); + setHeadersContext(null); + setNavigationContext(null); + return new Response(__afterContent, { status: 200, headers: { "Content-Type": __mimeTypes[__afterExt] ?? "application/octet-stream" } }); + } + } catch (__e) { if (__e?.code !== 'ENOENT') console.warn('[vinext] static file check failed:', __e); } + } + } } } @@ -2097,7 +2121,25 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return proxyExternalRequest(request, __fallbackRewritten); } cleanPathname = __fallbackRewritten; - match = matchRoute(cleanPathname); + // Check if fallback targets a static file in public/ + const __fbExtname = __nodePath.extname(cleanPathname); + if (__fbExtname && __publicDir !== null) { + // "." + cleanPathname: see afterFiles comment above — leading "/" is assumed. + const __fbPublicFile = __nodePath.resolve(__publicDir, "." + cleanPathname); + if (__fbPublicFile.startsWith(__publicDir + __nodePath.sep)) { + try { + const __fbStat = __nodeFs.statSync(__fbPublicFile); + if (__fbStat.isFile()) { + const __fbContent = __nodeFs.readFileSync(__fbPublicFile); + const __fbExt = __fbExtname.slice(1).toLowerCase(); + setHeadersContext(null); + setNavigationContext(null); + return new Response(__fbContent, { status: 200, headers: { "Content-Type": __mimeTypes[__fbExt] ?? "application/octet-stream" } }); + } + } catch (__e) { if (__e?.code !== 'ENOENT') console.warn('[vinext] static file check failed:', __e); } + } + } + match = matchRoute(cleanPathname, routes); } } @@ -3224,6 +3266,8 @@ if (import.meta.hot) { exports[`App Router entry templates > generateRscEntry snapshot (with config) 1`] = ` " +import __nodeFs from "node:fs"; +import __nodePath from "node:path"; import { renderToReadableStream, decodeReply, @@ -4301,6 +4345,8 @@ const __configRedirects = [{"source":"/old","destination":"/new","permanent":tru const __configRewrites = {"beforeFiles":[{"source":"/api/:path*","destination":"/backend/:path*"}],"afterFiles":[],"fallback":[]}; const __configHeaders = [{"source":"/api/:path*","headers":[{"key":"X-Custom","value":"test"}]}]; const __allowedOrigins = ["https://example.com"]; +const __mimeTypes = {"html":"text/html; charset=utf-8","htm":"text/html; charset=utf-8","css":"text/css","js":"application/javascript","mjs":"application/javascript","cjs":"application/javascript","json":"application/json","txt":"text/plain","xml":"application/xml","svg":"image/svg+xml","png":"image/png","jpg":"image/jpeg","jpeg":"image/jpeg","gif":"image/gif","webp":"image/webp","avif":"image/avif","ico":"image/x-icon","woff":"font/woff","woff2":"font/woff2","ttf":"font/ttf","otf":"font/otf","eot":"application/vnd.ms-fontobject","pdf":"application/pdf","map":"application/json","mp4":"video/mp4","webm":"video/webm","mp3":"audio/mpeg","ogg":"audio/ogg","wasm":"application/wasm"}; +const __publicDir = null; // ── Dev server origin verification ────────────────────────────────────── @@ -4974,6 +5020,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return proxyExternalRequest(request, __afterRewritten); } cleanPathname = __afterRewritten; + // If the rewritten path has a file extension, it may point to a static + // file in public/. Serve it directly before route matching. + const __afterExtname = __nodePath.extname(cleanPathname); + if (__afterExtname && __publicDir !== null) { + // "." + cleanPathname works because rewrite destinations always start with "/"; + // the traversal guard below catches any malformed path regardless. + const __afterPublicFile = __nodePath.resolve(__publicDir, "." + cleanPathname); + if (__afterPublicFile.startsWith(__publicDir + __nodePath.sep)) { + try { + const __afterStat = __nodeFs.statSync(__afterPublicFile); + if (__afterStat.isFile()) { + const __afterContent = __nodeFs.readFileSync(__afterPublicFile); + const __afterExt = __afterExtname.slice(1).toLowerCase(); + setHeadersContext(null); + setNavigationContext(null); + return new Response(__afterContent, { status: 200, headers: { "Content-Type": __mimeTypes[__afterExt] ?? "application/octet-stream" } }); + } + } catch (__e) { if (__e?.code !== 'ENOENT') console.warn('[vinext] static file check failed:', __e); } + } + } } } @@ -4989,7 +5055,25 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return proxyExternalRequest(request, __fallbackRewritten); } cleanPathname = __fallbackRewritten; - match = matchRoute(cleanPathname); + // Check if fallback targets a static file in public/ + const __fbExtname = __nodePath.extname(cleanPathname); + if (__fbExtname && __publicDir !== null) { + // "." + cleanPathname: see afterFiles comment above — leading "/" is assumed. + const __fbPublicFile = __nodePath.resolve(__publicDir, "." + cleanPathname); + if (__fbPublicFile.startsWith(__publicDir + __nodePath.sep)) { + try { + const __fbStat = __nodeFs.statSync(__fbPublicFile); + if (__fbStat.isFile()) { + const __fbContent = __nodeFs.readFileSync(__fbPublicFile); + const __fbExt = __fbExtname.slice(1).toLowerCase(); + setHeadersContext(null); + setNavigationContext(null); + return new Response(__fbContent, { status: 200, headers: { "Content-Type": __mimeTypes[__fbExt] ?? "application/octet-stream" } }); + } + } catch (__e) { if (__e?.code !== 'ENOENT') console.warn('[vinext] static file check failed:', __e); } + } + } + match = matchRoute(cleanPathname, routes); } } @@ -6116,6 +6200,8 @@ if (import.meta.hot) { exports[`App Router entry templates > generateRscEntry snapshot (with global error) 1`] = ` " +import __nodeFs from "node:fs"; +import __nodePath from "node:path"; import { renderToReadableStream, decodeReply, @@ -7223,6 +7309,8 @@ const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; const __allowedOrigins = []; +const __mimeTypes = {"html":"text/html; charset=utf-8","htm":"text/html; charset=utf-8","css":"text/css","js":"application/javascript","mjs":"application/javascript","cjs":"application/javascript","json":"application/json","txt":"text/plain","xml":"application/xml","svg":"image/svg+xml","png":"image/png","jpg":"image/jpeg","jpeg":"image/jpeg","gif":"image/gif","webp":"image/webp","avif":"image/avif","ico":"image/x-icon","woff":"font/woff","woff2":"font/woff2","ttf":"font/ttf","otf":"font/otf","eot":"application/vnd.ms-fontobject","pdf":"application/pdf","map":"application/json","mp4":"video/mp4","webm":"video/webm","mp3":"audio/mpeg","ogg":"audio/ogg","wasm":"application/wasm"}; +const __publicDir = null; // ── Dev server origin verification ────────────────────────────────────── @@ -7893,6 +7981,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return proxyExternalRequest(request, __afterRewritten); } cleanPathname = __afterRewritten; + // If the rewritten path has a file extension, it may point to a static + // file in public/. Serve it directly before route matching. + const __afterExtname = __nodePath.extname(cleanPathname); + if (__afterExtname && __publicDir !== null) { + // "." + cleanPathname works because rewrite destinations always start with "/"; + // the traversal guard below catches any malformed path regardless. + const __afterPublicFile = __nodePath.resolve(__publicDir, "." + cleanPathname); + if (__afterPublicFile.startsWith(__publicDir + __nodePath.sep)) { + try { + const __afterStat = __nodeFs.statSync(__afterPublicFile); + if (__afterStat.isFile()) { + const __afterContent = __nodeFs.readFileSync(__afterPublicFile); + const __afterExt = __afterExtname.slice(1).toLowerCase(); + setHeadersContext(null); + setNavigationContext(null); + return new Response(__afterContent, { status: 200, headers: { "Content-Type": __mimeTypes[__afterExt] ?? "application/octet-stream" } }); + } + } catch (__e) { if (__e?.code !== 'ENOENT') console.warn('[vinext] static file check failed:', __e); } + } + } } } @@ -7908,7 +8016,25 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return proxyExternalRequest(request, __fallbackRewritten); } cleanPathname = __fallbackRewritten; - match = matchRoute(cleanPathname); + // Check if fallback targets a static file in public/ + const __fbExtname = __nodePath.extname(cleanPathname); + if (__fbExtname && __publicDir !== null) { + // "." + cleanPathname: see afterFiles comment above — leading "/" is assumed. + const __fbPublicFile = __nodePath.resolve(__publicDir, "." + cleanPathname); + if (__fbPublicFile.startsWith(__publicDir + __nodePath.sep)) { + try { + const __fbStat = __nodeFs.statSync(__fbPublicFile); + if (__fbStat.isFile()) { + const __fbContent = __nodeFs.readFileSync(__fbPublicFile); + const __fbExt = __fbExtname.slice(1).toLowerCase(); + setHeadersContext(null); + setNavigationContext(null); + return new Response(__fbContent, { status: 200, headers: { "Content-Type": __mimeTypes[__fbExt] ?? "application/octet-stream" } }); + } + } catch (__e) { if (__e?.code !== 'ENOENT') console.warn('[vinext] static file check failed:', __e); } + } + } + match = matchRoute(cleanPathname, routes); } } @@ -9043,6 +9169,8 @@ if (import.meta.hot) { exports[`App Router entry templates > generateRscEntry snapshot (with instrumentation) 1`] = ` " +import __nodeFs from "node:fs"; +import __nodePath from "node:path"; import { renderToReadableStream, decodeReply, @@ -10149,6 +10277,8 @@ const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; const __allowedOrigins = []; +const __mimeTypes = {"html":"text/html; charset=utf-8","htm":"text/html; charset=utf-8","css":"text/css","js":"application/javascript","mjs":"application/javascript","cjs":"application/javascript","json":"application/json","txt":"text/plain","xml":"application/xml","svg":"image/svg+xml","png":"image/png","jpg":"image/jpeg","jpeg":"image/jpeg","gif":"image/gif","webp":"image/webp","avif":"image/avif","ico":"image/x-icon","woff":"font/woff","woff2":"font/woff2","ttf":"font/ttf","otf":"font/otf","eot":"application/vnd.ms-fontobject","pdf":"application/pdf","map":"application/json","mp4":"video/mp4","webm":"video/webm","mp3":"audio/mpeg","ogg":"audio/ogg","wasm":"application/wasm"}; +const __publicDir = null; // ── Dev server origin verification ────────────────────────────────────── @@ -10822,6 +10952,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return proxyExternalRequest(request, __afterRewritten); } cleanPathname = __afterRewritten; + // If the rewritten path has a file extension, it may point to a static + // file in public/. Serve it directly before route matching. + const __afterExtname = __nodePath.extname(cleanPathname); + if (__afterExtname && __publicDir !== null) { + // "." + cleanPathname works because rewrite destinations always start with "/"; + // the traversal guard below catches any malformed path regardless. + const __afterPublicFile = __nodePath.resolve(__publicDir, "." + cleanPathname); + if (__afterPublicFile.startsWith(__publicDir + __nodePath.sep)) { + try { + const __afterStat = __nodeFs.statSync(__afterPublicFile); + if (__afterStat.isFile()) { + const __afterContent = __nodeFs.readFileSync(__afterPublicFile); + const __afterExt = __afterExtname.slice(1).toLowerCase(); + setHeadersContext(null); + setNavigationContext(null); + return new Response(__afterContent, { status: 200, headers: { "Content-Type": __mimeTypes[__afterExt] ?? "application/octet-stream" } }); + } + } catch (__e) { if (__e?.code !== 'ENOENT') console.warn('[vinext] static file check failed:', __e); } + } + } } } @@ -10837,7 +10987,25 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return proxyExternalRequest(request, __fallbackRewritten); } cleanPathname = __fallbackRewritten; - match = matchRoute(cleanPathname); + // Check if fallback targets a static file in public/ + const __fbExtname = __nodePath.extname(cleanPathname); + if (__fbExtname && __publicDir !== null) { + // "." + cleanPathname: see afterFiles comment above — leading "/" is assumed. + const __fbPublicFile = __nodePath.resolve(__publicDir, "." + cleanPathname); + if (__fbPublicFile.startsWith(__publicDir + __nodePath.sep)) { + try { + const __fbStat = __nodeFs.statSync(__fbPublicFile); + if (__fbStat.isFile()) { + const __fbContent = __nodeFs.readFileSync(__fbPublicFile); + const __fbExt = __fbExtname.slice(1).toLowerCase(); + setHeadersContext(null); + setNavigationContext(null); + return new Response(__fbContent, { status: 200, headers: { "Content-Type": __mimeTypes[__fbExt] ?? "application/octet-stream" } }); + } + } catch (__e) { if (__e?.code !== 'ENOENT') console.warn('[vinext] static file check failed:', __e); } + } + } + match = matchRoute(cleanPathname, routes); } } @@ -11964,6 +12132,8 @@ if (import.meta.hot) { exports[`App Router entry templates > generateRscEntry snapshot (with metadata routes) 1`] = ` " +import __nodeFs from "node:fs"; +import __nodePath from "node:path"; import { renderToReadableStream, decodeReply, @@ -13048,6 +13218,8 @@ const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; const __allowedOrigins = []; +const __mimeTypes = {"html":"text/html; charset=utf-8","htm":"text/html; charset=utf-8","css":"text/css","js":"application/javascript","mjs":"application/javascript","cjs":"application/javascript","json":"application/json","txt":"text/plain","xml":"application/xml","svg":"image/svg+xml","png":"image/png","jpg":"image/jpeg","jpeg":"image/jpeg","gif":"image/gif","webp":"image/webp","avif":"image/avif","ico":"image/x-icon","woff":"font/woff","woff2":"font/woff2","ttf":"font/ttf","otf":"font/otf","eot":"application/vnd.ms-fontobject","pdf":"application/pdf","map":"application/json","mp4":"video/mp4","webm":"video/webm","mp3":"audio/mpeg","ogg":"audio/ogg","wasm":"application/wasm"}; +const __publicDir = null; // ── Dev server origin verification ────────────────────────────────────── @@ -13718,6 +13890,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return proxyExternalRequest(request, __afterRewritten); } cleanPathname = __afterRewritten; + // If the rewritten path has a file extension, it may point to a static + // file in public/. Serve it directly before route matching. + const __afterExtname = __nodePath.extname(cleanPathname); + if (__afterExtname && __publicDir !== null) { + // "." + cleanPathname works because rewrite destinations always start with "/"; + // the traversal guard below catches any malformed path regardless. + const __afterPublicFile = __nodePath.resolve(__publicDir, "." + cleanPathname); + if (__afterPublicFile.startsWith(__publicDir + __nodePath.sep)) { + try { + const __afterStat = __nodeFs.statSync(__afterPublicFile); + if (__afterStat.isFile()) { + const __afterContent = __nodeFs.readFileSync(__afterPublicFile); + const __afterExt = __afterExtname.slice(1).toLowerCase(); + setHeadersContext(null); + setNavigationContext(null); + return new Response(__afterContent, { status: 200, headers: { "Content-Type": __mimeTypes[__afterExt] ?? "application/octet-stream" } }); + } + } catch (__e) { if (__e?.code !== 'ENOENT') console.warn('[vinext] static file check failed:', __e); } + } + } } } @@ -13733,7 +13925,25 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return proxyExternalRequest(request, __fallbackRewritten); } cleanPathname = __fallbackRewritten; - match = matchRoute(cleanPathname); + // Check if fallback targets a static file in public/ + const __fbExtname = __nodePath.extname(cleanPathname); + if (__fbExtname && __publicDir !== null) { + // "." + cleanPathname: see afterFiles comment above — leading "/" is assumed. + const __fbPublicFile = __nodePath.resolve(__publicDir, "." + cleanPathname); + if (__fbPublicFile.startsWith(__publicDir + __nodePath.sep)) { + try { + const __fbStat = __nodeFs.statSync(__fbPublicFile); + if (__fbStat.isFile()) { + const __fbContent = __nodeFs.readFileSync(__fbPublicFile); + const __fbExt = __fbExtname.slice(1).toLowerCase(); + setHeadersContext(null); + setNavigationContext(null); + return new Response(__fbContent, { status: 200, headers: { "Content-Type": __mimeTypes[__fbExt] ?? "application/octet-stream" } }); + } + } catch (__e) { if (__e?.code !== 'ENOENT') console.warn('[vinext] static file check failed:', __e); } + } + } + match = matchRoute(cleanPathname, routes); } } @@ -14860,6 +15070,8 @@ if (import.meta.hot) { exports[`App Router entry templates > generateRscEntry snapshot (with middleware) 1`] = ` " +import __nodeFs from "node:fs"; +import __nodePath from "node:path"; import { renderToReadableStream, decodeReply, @@ -16166,6 +16378,8 @@ const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; const __allowedOrigins = []; +const __mimeTypes = {"html":"text/html; charset=utf-8","htm":"text/html; charset=utf-8","css":"text/css","js":"application/javascript","mjs":"application/javascript","cjs":"application/javascript","json":"application/json","txt":"text/plain","xml":"application/xml","svg":"image/svg+xml","png":"image/png","jpg":"image/jpeg","jpeg":"image/jpeg","gif":"image/gif","webp":"image/webp","avif":"image/avif","ico":"image/x-icon","woff":"font/woff","woff2":"font/woff2","ttf":"font/ttf","otf":"font/otf","eot":"application/vnd.ms-fontobject","pdf":"application/pdf","map":"application/json","mp4":"video/mp4","webm":"video/webm","mp3":"audio/mpeg","ogg":"audio/ogg","wasm":"application/wasm"}; +const __publicDir = null; // ── Dev server origin verification ────────────────────────────────────── @@ -16924,6 +17138,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return proxyExternalRequest(request, __afterRewritten); } cleanPathname = __afterRewritten; + // If the rewritten path has a file extension, it may point to a static + // file in public/. Serve it directly before route matching. + const __afterExtname = __nodePath.extname(cleanPathname); + if (__afterExtname && __publicDir !== null) { + // "." + cleanPathname works because rewrite destinations always start with "/"; + // the traversal guard below catches any malformed path regardless. + const __afterPublicFile = __nodePath.resolve(__publicDir, "." + cleanPathname); + if (__afterPublicFile.startsWith(__publicDir + __nodePath.sep)) { + try { + const __afterStat = __nodeFs.statSync(__afterPublicFile); + if (__afterStat.isFile()) { + const __afterContent = __nodeFs.readFileSync(__afterPublicFile); + const __afterExt = __afterExtname.slice(1).toLowerCase(); + setHeadersContext(null); + setNavigationContext(null); + return new Response(__afterContent, { status: 200, headers: { "Content-Type": __mimeTypes[__afterExt] ?? "application/octet-stream" } }); + } + } catch (__e) { if (__e?.code !== 'ENOENT') console.warn('[vinext] static file check failed:', __e); } + } + } } } @@ -16939,7 +17173,25 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return proxyExternalRequest(request, __fallbackRewritten); } cleanPathname = __fallbackRewritten; - match = matchRoute(cleanPathname); + // Check if fallback targets a static file in public/ + const __fbExtname = __nodePath.extname(cleanPathname); + if (__fbExtname && __publicDir !== null) { + // "." + cleanPathname: see afterFiles comment above — leading "/" is assumed. + const __fbPublicFile = __nodePath.resolve(__publicDir, "." + cleanPathname); + if (__fbPublicFile.startsWith(__publicDir + __nodePath.sep)) { + try { + const __fbStat = __nodeFs.statSync(__fbPublicFile); + if (__fbStat.isFile()) { + const __fbContent = __nodeFs.readFileSync(__fbPublicFile); + const __fbExt = __fbExtname.slice(1).toLowerCase(); + setHeadersContext(null); + setNavigationContext(null); + return new Response(__fbContent, { status: 200, headers: { "Content-Type": __mimeTypes[__fbExt] ?? "application/octet-stream" } }); + } + } catch (__e) { if (__e?.code !== 'ENOENT') console.warn('[vinext] static file check failed:', __e); } + } + } + match = matchRoute(cleanPathname, routes); } } @@ -18651,7 +18903,7 @@ const i18nConfig = null; const buildId = "test-build-id"; // Full resolved config for production server (embedded at build time) -export const vinextConfig = {"basePath":"","trailingSlash":false,"redirects":[{"source":"/old-about","destination":"/about","permanent":true},{"source":"/repeat-redirect/:id","destination":"/docs/:id/:id","permanent":false},{"source":"/redirect-before-middleware-rewrite","destination":"/about","permanent":false},{"source":"/redirect-before-middleware-response","destination":"/about","permanent":false}],"rewrites":{"beforeFiles":[{"source":"/before-rewrite","destination":"/about"},{"source":"/repeat-rewrite/:id","destination":"/docs/:id/:id"},{"source":"/mw-gated-before","has":[{"type":"cookie","key":"mw-before-user"}],"destination":"/about"}],"afterFiles":[{"source":"/after-rewrite","destination":"/about"},{"source":"/mw-gated-rewrite","has":[{"type":"cookie","key":"mw-user"}],"destination":"/about"}],"fallback":[{"source":"/fallback-rewrite","destination":"/about"}]},"headers":[{"source":"/api/(.*)","headers":[{"key":"X-Custom-Header","value":"vinext"}]},{"source":"/about","has":[{"type":"cookie","key":"logged-in"}],"headers":[{"key":"X-Auth-Only-Header","value":"1"}]},{"source":"/about","missing":[{"type":"cookie","key":"logged-in"}],"headers":[{"key":"X-Guest-Only-Header","value":"1"}]},{"source":"/ssr","headers":[{"key":"Vary","value":"Accept-Language"}]},{"source":"/headers-before-middleware-rewrite","headers":[{"key":"X-Rewrite-Source-Header","value":"1"}]}],"i18n":null,"images":{}}; +export const vinextConfig = {"basePath":"","trailingSlash":false,"redirects":[{"source":"/old-about","destination":"/about","permanent":true},{"source":"/repeat-redirect/:id","destination":"/docs/:id/:id","permanent":false},{"source":"/redirect-before-middleware-rewrite","destination":"/about","permanent":false},{"source":"/redirect-before-middleware-response","destination":"/about","permanent":false}],"rewrites":{"beforeFiles":[{"source":"/before-rewrite","destination":"/about"},{"source":"/repeat-rewrite/:id","destination":"/docs/:id/:id"},{"source":"/mw-gated-before","has":[{"type":"cookie","key":"mw-before-user"}],"destination":"/about"}],"afterFiles":[{"source":"/after-rewrite","destination":"/about"},{"source":"/mw-gated-rewrite","has":[{"type":"cookie","key":"mw-user"}],"destination":"/about"},{"source":"/static-html-page","destination":"/static-html-page.html"},{"source":"/auth/no-access","destination":"/auth/no-access.html"}],"fallback":[{"source":"/fallback-rewrite","destination":"/about"},{"source":"/fallback-static-page","destination":"/fallback-page.html"}]},"headers":[{"source":"/api/(.*)","headers":[{"key":"X-Custom-Header","value":"vinext"}]},{"source":"/about","has":[{"type":"cookie","key":"logged-in"}],"headers":[{"key":"X-Auth-Only-Header","value":"1"}]},{"source":"/about","missing":[{"type":"cookie","key":"logged-in"}],"headers":[{"key":"X-Guest-Only-Header","value":"1"}]},{"source":"/ssr","headers":[{"key":"Vary","value":"Accept-Language"}]},{"source":"/headers-before-middleware-rewrite","headers":[{"key":"X-Rewrite-Source-Header","value":"1"}]}],"i18n":null,"images":{}}; class ApiBodyParseError extends Error { constructor(message, statusCode) { diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index b6e718d6b..559aca6ab 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -2381,6 +2381,37 @@ describe("App Router next.config.js features (dev server integration)", () => { expect(html).toContain("About"); }); + it("serves static HTML file from public/ when afterFiles rewrite points to .html path", async () => { + // Regresses issue #199: async rewrites() returning flat array (→ afterFiles) that maps + // a clean URL to a .html file in public/ should serve the file, not return 404. + const res = await fetch(`${baseUrl}/static-html-page`); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("Hello from static HTML"); + // Should be served with text/html content-type + expect(res.headers.get("content-type")).toMatch(/text\/html/i); + }); + + it("serves nested static HTML file from public/ subdirectory via rewrite", async () => { + // Nested rewrites: /auth/no-access → /auth/no-access.html should resolve + // to public/auth/no-access.html and serve it. + const res = await fetch(`${baseUrl}/auth/no-access`); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("Access denied from nested static HTML"); + expect(res.headers.get("content-type")).toMatch(/text\/html/i); + }); + + it("serves static HTML file from public/ when fallback rewrite points to .html path", async () => { + // Fallback rewrites run after route matching fails. /fallback-static-page has no + // matching app route, so the fallback rewrite maps it to /fallback-page.html in public/. + const res = await fetch(`${baseUrl}/fallback-static-page`); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("Hello from fallback static HTML"); + expect(res.headers.get("content-type")).toMatch(/text\/html/i); + }); + it("fallback rewrites targeting Pages routes still work in mixed app/pages projects", async () => { const noAuthRes = await fetch(`${baseUrl}/mw-gated-fallback-pages`); expect(noAuthRes.status).toBe(404); @@ -2533,6 +2564,35 @@ describe("App Router next.config.js features (generateRscEntry)", () => { expect(code).toContain("/fallback-rewrite"); }); + it("embeds root/public path for serving static files after rewrite", () => { + // When root is provided, the generated code should contain that public path + // so it can serve .html files from public/ when a rewrite produces a .html path. + const code = generateRscEntry( + "/tmp/test/app", + minimalRoutes, + null, + [], + null, + "", + false, + {}, + null, + "/tmp/test", + ); + // path.resolve produces a fully normalized absolute path; the generated code embeds via JSON.stringify + const expectedPublicDir = path.resolve("/tmp/test", "public"); + expect(code).toContain(JSON.stringify(expectedPublicDir)); + // __publicDir should be hoisted to module scope + expect(code).toContain("const __publicDir = " + JSON.stringify(expectedPublicDir)); + // Should contain the node:fs and node:path imports for the static file handler + expect(code).toContain("__nodeFs"); + expect(code).toContain("__nodePath"); + expect(code).toContain("statSync"); + // Should use path.resolve + startsWith for traversal protection + expect(code).toContain("__nodePath.resolve"); + expect(code).toContain("startsWith"); + }); + it("generates custom header handling code when headers are provided", () => { const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, { headers: [{ source: "/api/(.*)", headers: [{ key: "X-Custom-Header", value: "vinext" }] }], diff --git a/tests/fixtures/app-basic/next.config.ts b/tests/fixtures/app-basic/next.config.ts index e779e321e..b33f54b6b 100644 --- a/tests/fixtures/app-basic/next.config.ts +++ b/tests/fixtures/app-basic/next.config.ts @@ -103,6 +103,10 @@ const nextConfig: NextConfig = { { source: "/after-rewrite-about", destination: "/about" }, // Used by E2E: config-redirect.spec.ts { source: "/config-rewrite", destination: "/" }, + // Used by Vitest: app-router.test.ts (rewrite to static HTML in public/) + { source: "/static-html-page", destination: "/static-html-page.html" }, + // Used by Vitest: app-router.test.ts (nested rewrite to static HTML in public/) + { source: "/auth/no-access", destination: "/auth/no-access.html" }, ], fallback: [ // Used by Vitest: app-router.test.ts — fallback rewrite gated on a @@ -114,6 +118,8 @@ const nextConfig: NextConfig = { has: [{ type: "cookie", key: "mw-fallback-user" }], destination: "/about", }, + // Used by Vitest: app-router.test.ts (fallback rewrite to static HTML in public/) + { source: "/fallback-static-page", destination: "/fallback-page.html" }, // Used by Vitest: app-router.test.ts — mixed app/pages fallback rewrite // gated on a middleware-injected cookie, targeting a Pages route. { diff --git a/tests/fixtures/app-basic/public/auth/no-access.html b/tests/fixtures/app-basic/public/auth/no-access.html new file mode 100644 index 000000000..19034aac3 --- /dev/null +++ b/tests/fixtures/app-basic/public/auth/no-access.html @@ -0,0 +1,9 @@ + + + + No Access + + + Access denied from nested static HTML + + diff --git a/tests/fixtures/app-basic/public/fallback-page.html b/tests/fixtures/app-basic/public/fallback-page.html new file mode 100644 index 000000000..44ad71659 --- /dev/null +++ b/tests/fixtures/app-basic/public/fallback-page.html @@ -0,0 +1,10 @@ + + + + + Fallback Page + + +

Hello from fallback static HTML

+ + diff --git a/tests/fixtures/app-basic/public/static-html-page.html b/tests/fixtures/app-basic/public/static-html-page.html new file mode 100644 index 000000000..07c276cf5 --- /dev/null +++ b/tests/fixtures/app-basic/public/static-html-page.html @@ -0,0 +1,10 @@ + + + + + Static HTML Page + + +

Hello from static HTML

+ + diff --git a/tests/fixtures/pages-basic/next.config.mjs b/tests/fixtures/pages-basic/next.config.mjs index 534536c91..78e6f83ef 100644 --- a/tests/fixtures/pages-basic/next.config.mjs +++ b/tests/fixtures/pages-basic/next.config.mjs @@ -63,12 +63,27 @@ const nextConfig = { has: [{ type: "cookie", key: "mw-user" }], destination: "/about", }, + // Used by Vitest: pages-router.test.ts (rewrite to static HTML in public/) + { + source: "/static-html-page", + destination: "/static-html-page.html", + }, + // Used by Vitest: pages-router.test.ts (nested rewrite to static HTML in public/) + { + source: "/auth/no-access", + destination: "/auth/no-access.html", + }, ], fallback: [ { source: "/fallback-rewrite", destination: "/about", }, + // Used by Vitest: pages-router.test.ts (fallback rewrite to static HTML in public/) + { + source: "/fallback-static-page", + destination: "/fallback-page.html", + }, ], }; }, diff --git a/tests/fixtures/pages-basic/public/auth/no-access.html b/tests/fixtures/pages-basic/public/auth/no-access.html new file mode 100644 index 000000000..19034aac3 --- /dev/null +++ b/tests/fixtures/pages-basic/public/auth/no-access.html @@ -0,0 +1,9 @@ + + + + No Access + + + Access denied from nested static HTML + + diff --git a/tests/fixtures/pages-basic/public/fallback-page.html b/tests/fixtures/pages-basic/public/fallback-page.html new file mode 100644 index 000000000..44ad71659 --- /dev/null +++ b/tests/fixtures/pages-basic/public/fallback-page.html @@ -0,0 +1,10 @@ + + + + + Fallback Page + + +

Hello from fallback static HTML

+ + diff --git a/tests/fixtures/pages-basic/public/static-html-page.html b/tests/fixtures/pages-basic/public/static-html-page.html new file mode 100644 index 000000000..07c276cf5 --- /dev/null +++ b/tests/fixtures/pages-basic/public/static-html-page.html @@ -0,0 +1,10 @@ + + + + + Static HTML Page + + +

Hello from static HTML

+ + diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 2d6ef990a..0935992af 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -473,6 +473,37 @@ describe("Pages Router integration", () => { expect(html).toContain("About"); }); + it("serves static HTML file from public/ when afterFiles rewrite points to .html path", async () => { + // Regresses issue #199: rewrites (afterFiles) that map a clean URL to a .html file + // in public/ should serve the file, not return 404. + const res = await fetch(`${baseUrl}/static-html-page`); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("Hello from static HTML"); + // Should be served with text/html content-type + expect(res.headers.get("content-type")).toMatch(/text\/html/i); + }); + + it("serves nested static HTML file from public/ subdirectory via rewrite", async () => { + // Nested rewrites: /auth/no-access → /auth/no-access.html should resolve + // to public/auth/no-access.html and serve it. + const res = await fetch(`${baseUrl}/auth/no-access`); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("Access denied from nested static HTML"); + expect(res.headers.get("content-type")).toMatch(/text\/html/i); + }); + + it("serves static HTML file from public/ when fallback rewrite points to .html path", async () => { + // Fallback rewrites run after route matching fails. /fallback-static-page has no + // matching pages route, so the fallback rewrite maps it to /fallback-page.html in public/. + const res = await fetch(`${baseUrl}/fallback-static-page`); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("Hello from fallback static HTML"); + expect(res.headers.get("content-type")).toMatch(/text\/html/i); + }); + it("applies fallback rewrites from next.config.js", async () => { const res = await fetch(`${baseUrl}/fallback-rewrite`); expect(res.status).toBe(200);