From 506d82567dbacd61e4f4d3f66f1d3cbbddd845d7 Mon Sep 17 00:00:00 2001 From: MD YUNUS <115855149+yunus25jmi1@users.noreply.github.com> Date: Sun, 1 Mar 2026 19:21:00 +0000 Subject: [PATCH 1/4] fix: serve static HTML files from public/ after rewrites (issue #199) Address code review feedback: - Extract shared MIME type map (server/mime.ts) eliminating 3x duplication - Add path traversal guard using resolve + startsWith pattern - Use path.extname() instead of string splitting - Use path.join/resolve in generated RSC entry instead of string concat - Clean up navigation/headers context before returning static response - Add nested route test (public/auth/no-access.html via rewrite) --- packages/vinext/src/index.ts | 42 +++++++++++++-- packages/vinext/src/server/app-dev-server.ts | 51 +++++++++++++++++++ packages/vinext/src/server/mime.ts | 46 +++++++++++++++++ packages/vinext/src/server/prod-server.ts | 44 ++++++++-------- tests/app-router.test.ts | 37 ++++++++++++++ tests/fixtures/app-basic/next.config.ts | 4 ++ .../app-basic/public/auth/no-access.html | 5 ++ .../app-basic/public/static-html-page.html | 5 ++ tests/fixtures/pages-basic/next.config.mjs | 10 ++++ .../pages-basic/public/auth/no-access.html | 5 ++ .../pages-basic/public/static-html-page.html | 5 ++ tests/pages-router.test.ts | 21 ++++++++ 12 files changed, 248 insertions(+), 27 deletions(-) create mode 100644 packages/vinext/src/server/mime.ts create mode 100644 tests/fixtures/app-basic/public/auth/no-access.html create mode 100644 tests/fixtures/app-basic/public/static-html-page.html create mode 100644 tests/fixtures/pages-basic/public/auth/no-access.html create mode 100644 tests/fixtures/pages-basic/public/static-html-page.html diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 710c0d13..ee0d45c3 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -40,6 +40,7 @@ import { scanMetadataFiles } from "./server/metadata-routes.js"; import { staticExportPages } from "./build/static-export.js"; import { detectPackageManager } from "./utils/project.js"; import { asyncHooksStubPlugin } from "./plugins/async-hooks-stub.js"; +import { mimeType } from "./server/mime.js"; import tsconfigPaths from "vite-tsconfig-paths"; import react, { Options as VitePluginReactOptions } from "@vitejs/plugin-react"; import MagicString from "magic-string"; @@ -2343,7 +2344,7 @@ hydrate(); headers: nextConfig?.headers, allowedOrigins: nextConfig?.serverActionsAllowedOrigins, allowedDevOrigins: nextConfig?.allowedDevOrigins, - }, instrumentationPath); + }, instrumentationPath, root); } if (id === RESOLVED_APP_SSR_ENTRY && hasAppDir) { return generateSsrEntry(); @@ -2969,10 +2970,32 @@ hydrate(); nextConfig.rewrites.afterFiles, reqCtx, ); - if (afterRewrite) resolvedUrl = afterRewrite; + if (afterRewrite) { + // External rewrite from afterFiles — proxy to external URL + if (isExternalUrl(afterRewrite)) { + await proxyExternalRewriteNode(req, res, afterRewrite); + return; + } + resolvedUrl = afterRewrite; + // 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)) { + const resolvedPublicDir = path.resolve(root, "public"); + const publicFilePath = path.resolve(resolvedPublicDir, "." + afterFilesPathname); + if (publicFilePath.startsWith(resolvedPublicDir + path.sep) && fs.existsSync(publicFilePath) && fs.statSync(publicFilePath).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; + } + } + } } - // 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; @@ -3001,6 +3024,19 @@ hydrate(); 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) && fs.existsSync(publicFilePath) && fs.statSync(publicFilePath).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; + } + } await handler(req, res, fallbackRewrite, mwStatus); return; } diff --git a/packages/vinext/src/server/app-dev-server.ts b/packages/vinext/src/server/app-dev-server.ts index 66bd2ec8..4d72d0c4 100644 --- a/packages/vinext/src/server/app-dev-server.ts +++ b/packages/vinext/src/server/app-dev-server.ts @@ -7,6 +7,7 @@ * the SSR entry for HTML generation. */ import fs from "node:fs"; +import path from "node:path"; import { fileURLToPath } from "node:url"; import type { AppRoute } from "../routing/app-router.js"; import type { MetadataFileRoute } from "./metadata-routes.js"; @@ -14,6 +15,7 @@ import type { NextRedirect, NextRewrite, NextHeader } from "../config/next-confi import { generateDevOriginCheckCode } from "./dev-origin-check.js"; import { generateSafeRegExpCode, generateMiddlewareMatcherCode, generateNormalizePathCode } from "./middleware-codegen.js"; import { isProxyFile } from "./middleware.js"; +import { MIME_TYPES } from "./mime.js"; /** * Resolved config options relevant to App Router request handling. @@ -50,6 +52,7 @@ export function generateRscEntry( trailingSlash?: boolean, config?: AppRouterConfig, instrumentationPath?: string | null, + root?: string, ): string { const bp = basePath ?? ""; const ts = trailingSlash ?? false; @@ -57,6 +60,14 @@ export function generateRscEntry( const rewrites = config?.rewrites ?? { beforeFiles: [], afterFiles: [], fallback: [] }; const headers = config?.headers ?? []; const allowedOrigins = config?.allowedOrigins ?? []; + // 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 require `root` for correctness — path.dirname(appDir) is wrong for src/app layouts + // (e.g. /project/src/public instead of /project/public). + if (!root) { + console.warn("[vinext] generateRscEntry: root not provided, static file serving after rewrites will be disabled"); + } + const publicDir = root ? path.join(root, "public") : null; // Build import map for all page and layout files const imports: string[] = []; const importMap: Map = new Map(); @@ -207,6 +218,8 @@ ${slotEntries.join(",\n")} }); return ` +import __nodeFs from "node:fs"; +import __nodePath from "node:path"; import { renderToReadableStream, decodeReply, @@ -980,6 +993,7 @@ 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)}; ${generateDevOriginCheckCode(config?.allowedDevOrigins)} @@ -1805,6 +1819,25 @@ 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 && ${JSON.stringify(publicDir)} !== null) { + const __afterPublicRoot = ${JSON.stringify(publicDir)}; + const __afterPublicFile = __nodePath.resolve(__afterPublicRoot, "." + cleanPathname); + if (__afterPublicFile.startsWith(__afterPublicRoot + __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 { /* file doesn't exist or not readable */ } + } + } } } @@ -1820,6 +1853,24 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return __proxyExternalRequest(request, __fallbackRewritten); } cleanPathname = __fallbackRewritten; + // Check if fallback targets a static file in public/ + const __fbExtname = __nodePath.extname(cleanPathname); + if (__fbExtname && ${JSON.stringify(publicDir)} !== null) { + const __fbPublicRoot = ${JSON.stringify(publicDir)}; + const __fbPublicFile = __nodePath.resolve(__fbPublicRoot, "." + cleanPathname); + if (__fbPublicFile.startsWith(__fbPublicRoot + __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 { /* file doesn't exist or not readable */ } + } + } match = matchRoute(cleanPathname, routes); } } diff --git a/packages/vinext/src/server/mime.ts b/packages/vinext/src/server/mime.ts new file mode 100644 index 00000000..5f29f74e --- /dev/null +++ b/packages/vinext/src/server/mime.ts @@ -0,0 +1,46 @@ +/** + * 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", +}; + +/** + * 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 afc5a2dd..b6d79251 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -28,6 +28,7 @@ import type { RequestContext } from "../config/config-matchers.js"; import { IMAGE_OPTIMIZATION_PATH, IMAGE_CONTENT_SECURITY_POLICY, parseImageParams, isSafeImageContentType, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES, type ImageConfig } from "./image-optimization.js"; import { normalizePath } from "./normalize-path.js"; import { computeLazyChunks } from "../index.js"; +import { mimeType } from "./mime.js"; /** Convert a Node.js IncomingMessage into a ReadableStream for Web Request body. */ function readNodeStream(req: IncomingMessage): ReadableStream { @@ -184,27 +185,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. @@ -247,7 +232,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" @@ -566,7 +551,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"); @@ -733,7 +718,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"); @@ -967,6 +952,12 @@ 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; + } } } @@ -984,6 +975,11 @@ 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/app-router.test.ts b/tests/app-router.test.ts index 55f1d7cf..ca2b9bc6 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -1997,6 +1997,27 @@ 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("applies custom headers from next.config.js on API routes", async () => { const res = await fetch(`${baseUrl}/api/hello`); expect(res.headers.get("x-custom-header")).toBe("vinext-app"); @@ -2128,6 +2149,22 @@ 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.join uses OS separators; the generated code embeds via JSON.stringify + const expectedPublicDir = path.join("/tmp/test", "public"); + expect(code).toContain(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: [ diff --git a/tests/fixtures/app-basic/next.config.ts b/tests/fixtures/app-basic/next.config.ts index 0613b746..1fed9f2d 100644 --- a/tests/fixtures/app-basic/next.config.ts +++ b/tests/fixtures/app-basic/next.config.ts @@ -87,6 +87,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 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 00000000..1f774566 --- /dev/null +++ b/tests/fixtures/app-basic/public/auth/no-access.html @@ -0,0 +1,5 @@ + + +No Access +Access denied from nested 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 00000000..36a9bc31 --- /dev/null +++ b/tests/fixtures/app-basic/public/static-html-page.html @@ -0,0 +1,5 @@ + + +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 2cbd727a..f20c53ae 100644 --- a/tests/fixtures/pages-basic/next.config.mjs +++ b/tests/fixtures/pages-basic/next.config.mjs @@ -43,6 +43,16 @@ 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: [ { 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 00000000..1f774566 --- /dev/null +++ b/tests/fixtures/pages-basic/public/auth/no-access.html @@ -0,0 +1,5 @@ + + +No Access +Access denied from nested 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 00000000..36a9bc31 --- /dev/null +++ b/tests/fixtures/pages-basic/public/static-html-page.html @@ -0,0 +1,5 @@ + + +Static HTML Page +

Hello from static HTML

+ diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 57cb2e71..1b089079 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -287,6 +287,27 @@ 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("applies fallback rewrites from next.config.js", async () => { const res = await fetch(`${baseUrl}/fallback-rewrite`); expect(res.status).toBe(200); From cc56d2c88376eada0a0ac22b8a194e5a6b6d97e3 Mon Sep 17 00:00:00 2001 From: MD YUNUS Date: Mon, 9 Mar 2026 01:37:40 +0530 Subject: [PATCH 2/4] address bonk review: hoist resolvedPublicDir, log non-ENOENT errors, add wasm MIME type, document leading-slash assumption --- packages/vinext/src/index.ts | 6 ++++-- packages/vinext/src/server/app-dev-server.ts | 7 +++++-- packages/vinext/src/server/mime.ts | 1 + 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index fd52582b..41e0811e 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -3066,6 +3066,8 @@ hydrate(); 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 @@ -3088,7 +3090,7 @@ hydrate(); // (which would miss it and SSR would return 404). const afterFilesPathname = afterRewrite.split("?")[0]; if (path.extname(afterFilesPathname)) { - const resolvedPublicDir = path.resolve(root, "public"); + // "." + afterFilesPathname works because rewrite destinations always start with "/" const publicFilePath = path.resolve(resolvedPublicDir, "." + afterFilesPathname); if (publicFilePath.startsWith(resolvedPublicDir + path.sep) && fs.existsSync(publicFilePath) && fs.statSync(publicFilePath).isFile()) { const content = fs.readFileSync(publicFilePath); @@ -3133,7 +3135,7 @@ hydrate(); // Check if fallback targets a static file in public/ const fallbackPathname = fallbackRewrite.split("?")[0]; if (path.extname(fallbackPathname)) { - const resolvedPublicDir = path.resolve(root, "public"); + // "." + fallbackPathname: see afterFiles comment above — leading "/" is assumed const publicFilePath = path.resolve(resolvedPublicDir, "." + fallbackPathname); if (publicFilePath.startsWith(resolvedPublicDir + path.sep) && fs.existsSync(publicFilePath) && fs.statSync(publicFilePath).isFile()) { const content = fs.readFileSync(publicFilePath); diff --git a/packages/vinext/src/server/app-dev-server.ts b/packages/vinext/src/server/app-dev-server.ts index ac41f1e5..0ba22dd5 100644 --- a/packages/vinext/src/server/app-dev-server.ts +++ b/packages/vinext/src/server/app-dev-server.ts @@ -1920,6 +1920,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __afterExtname = __nodePath.extname(cleanPathname); if (__afterExtname && ${JSON.stringify(publicDir)} !== null) { const __afterPublicRoot = ${JSON.stringify(publicDir)}; + // "." + cleanPathname works because rewrite destinations always start with "/"; + // the traversal guard below catches any malformed path regardless. const __afterPublicFile = __nodePath.resolve(__afterPublicRoot, "." + cleanPathname); if (__afterPublicFile.startsWith(__afterPublicRoot + __nodePath.sep)) { try { @@ -1931,7 +1933,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext(null); return new Response(__afterContent, { status: 200, headers: { "Content-Type": __mimeTypes[__afterExt] ?? "application/octet-stream" } }); } - } catch { /* file doesn't exist or not readable */ } + } catch (__e) { if (__e?.code !== 'ENOENT') console.warn('[vinext] static file check failed:', __e); } } } } @@ -1953,6 +1955,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __fbExtname = __nodePath.extname(cleanPathname); if (__fbExtname && ${JSON.stringify(publicDir)} !== null) { const __fbPublicRoot = ${JSON.stringify(publicDir)}; + // "." + cleanPathname: see afterFiles comment above — leading "/" is assumed. const __fbPublicFile = __nodePath.resolve(__fbPublicRoot, "." + cleanPathname); if (__fbPublicFile.startsWith(__fbPublicRoot + __nodePath.sep)) { try { @@ -1964,7 +1967,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext(null); return new Response(__fbContent, { status: 200, headers: { "Content-Type": __mimeTypes[__fbExt] ?? "application/octet-stream" } }); } - } catch { /* file doesn't exist or not readable */ } + } catch (__e) { if (__e?.code !== 'ENOENT') console.warn('[vinext] static file check failed:', __e); } } } match = matchRoute(cleanPathname, routes); diff --git a/packages/vinext/src/server/mime.ts b/packages/vinext/src/server/mime.ts index 5f29f74e..25a98e3e 100644 --- a/packages/vinext/src/server/mime.ts +++ b/packages/vinext/src/server/mime.ts @@ -35,6 +35,7 @@ export const MIME_TYPES: Record = { webm: "video/webm", mp3: "audio/mpeg", ogg: "audio/ogg", + wasm: "application/wasm", }; /** From c4835eedf9b9eaa0128edbdb6c06872e9a6fa2ad Mon Sep 17 00:00:00 2001 From: MD YUNUS Date: Mon, 9 Mar 2026 02:18:51 +0530 Subject: [PATCH 3/4] address bonk review: hoist __publicDir to module scope, add fallback rewrite static file tests --- packages/vinext/src/server/app-dev-server.ts | 20 ++++++++----------- tests/app-router.test.ts | 12 +++++++++++ tests/fixtures/app-basic/next.config.ts | 2 ++ .../app-basic/public/fallback-page.html | 5 +++++ tests/fixtures/pages-basic/next.config.mjs | 5 +++++ .../pages-basic/public/fallback-page.html | 5 +++++ tests/pages-router.test.ts | 10 ++++++++++ 7 files changed, 47 insertions(+), 12 deletions(-) create mode 100644 tests/fixtures/app-basic/public/fallback-page.html create mode 100644 tests/fixtures/pages-basic/public/fallback-page.html diff --git a/packages/vinext/src/server/app-dev-server.ts b/packages/vinext/src/server/app-dev-server.ts index 0ba22dd5..e48dfed9 100644 --- a/packages/vinext/src/server/app-dev-server.ts +++ b/packages/vinext/src/server/app-dev-server.ts @@ -65,11 +65,8 @@ export function generateRscEntry( const bodySizeLimit = config?.bodySizeLimit ?? 1 * 1024 * 1024; // 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 require `root` for correctness — path.dirname(appDir) is wrong for src/app layouts + // We need `root` for correctness — path.dirname(appDir) is wrong for src/app layouts // (e.g. /project/src/public instead of /project/public). - if (!root) { - console.warn("[vinext] generateRscEntry: root not provided, static file serving after rewrites will be disabled"); - } const publicDir = root ? path.join(root, "public") : null; // Build import map for all page and layout files const imports: string[] = []; @@ -1080,6 +1077,7 @@ 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)} @@ -1918,12 +1916,11 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // 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 && ${JSON.stringify(publicDir)} !== null) { - const __afterPublicRoot = ${JSON.stringify(publicDir)}; + 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(__afterPublicRoot, "." + cleanPathname); - if (__afterPublicFile.startsWith(__afterPublicRoot + __nodePath.sep)) { + const __afterPublicFile = __nodePath.resolve(__publicDir, "." + cleanPathname); + if (__afterPublicFile.startsWith(__publicDir + __nodePath.sep)) { try { const __afterStat = __nodeFs.statSync(__afterPublicFile); if (__afterStat.isFile()) { @@ -1953,11 +1950,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname = __fallbackRewritten; // Check if fallback targets a static file in public/ const __fbExtname = __nodePath.extname(cleanPathname); - if (__fbExtname && ${JSON.stringify(publicDir)} !== null) { - const __fbPublicRoot = ${JSON.stringify(publicDir)}; + if (__fbExtname && __publicDir !== null) { // "." + cleanPathname: see afterFiles comment above — leading "/" is assumed. - const __fbPublicFile = __nodePath.resolve(__fbPublicRoot, "." + cleanPathname); - if (__fbPublicFile.startsWith(__fbPublicRoot + __nodePath.sep)) { + const __fbPublicFile = __nodePath.resolve(__publicDir, "." + cleanPathname); + if (__fbPublicFile.startsWith(__publicDir + __nodePath.sep)) { try { const __fbStat = __nodeFs.statSync(__fbPublicFile); if (__fbStat.isFile()) { diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index c52c24df..d75b5050 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -2045,6 +2045,16 @@ describe("App Router next.config.js features (dev server integration)", () => { 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("applies custom headers from next.config.js on API routes", async () => { const res = await fetch(`${baseUrl}/api/hello`); expect(res.headers.get("x-custom-header")).toBe("vinext-app"); @@ -2183,6 +2193,8 @@ describe("App Router next.config.js features (generateRscEntry)", () => { // path.join uses OS separators; the generated code embeds via JSON.stringify const expectedPublicDir = path.join("/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"); diff --git a/tests/fixtures/app-basic/next.config.ts b/tests/fixtures/app-basic/next.config.ts index 1fed9f2d..bc67e859 100644 --- a/tests/fixtures/app-basic/next.config.ts +++ b/tests/fixtures/app-basic/next.config.ts @@ -102,6 +102,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" }, ], }; }, 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 00000000..f3bb6b8e --- /dev/null +++ b/tests/fixtures/app-basic/public/fallback-page.html @@ -0,0 +1,5 @@ + + +Fallback Page +

Hello from fallback static HTML

+ diff --git a/tests/fixtures/pages-basic/next.config.mjs b/tests/fixtures/pages-basic/next.config.mjs index f20c53ae..16810677 100644 --- a/tests/fixtures/pages-basic/next.config.mjs +++ b/tests/fixtures/pages-basic/next.config.mjs @@ -59,6 +59,11 @@ const nextConfig = { 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/fallback-page.html b/tests/fixtures/pages-basic/public/fallback-page.html new file mode 100644 index 00000000..f3bb6b8e --- /dev/null +++ b/tests/fixtures/pages-basic/public/fallback-page.html @@ -0,0 +1,5 @@ + + +Fallback Page +

Hello from fallback static HTML

+ diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 1b089079..77e289a6 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -308,6 +308,16 @@ describe("Pages Router integration", () => { 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); From 945b9cc3e97a544425ff25acdbc0d8f8fdbc4817 Mon Sep 17 00:00:00 2001 From: MD YUNUS Date: Mon, 9 Mar 2026 03:48:09 +0530 Subject: [PATCH 4/4] fix: use path.resolve for publicDir consistency with index.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address bonk review: path.join → path.resolve for publicDir computation in app-dev-server.ts, matching the pattern used in index.ts. path.resolve guarantees a fully normalized absolute path, making the traversal guard comparison more robust. --- packages/vinext/src/server/app-dev-server.ts | 2 +- tests/app-router.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/server/app-dev-server.ts b/packages/vinext/src/server/app-dev-server.ts index e48dfed9..1c1d2bb4 100644 --- a/packages/vinext/src/server/app-dev-server.ts +++ b/packages/vinext/src/server/app-dev-server.ts @@ -67,7 +67,7 @@ export function generateRscEntry( // 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.join(root, "public") : null; + 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(); diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index d75b5050..0a4bf13d 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -2190,8 +2190,8 @@ describe("App Router next.config.js features (generateRscEntry)", () => { // 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.join uses OS separators; the generated code embeds via JSON.stringify - const expectedPublicDir = path.join("/tmp/test", "public"); + // 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));