diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index d01624b2..41e0811e 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -41,6 +41,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"; @@ -2380,7 +2381,7 @@ hydrate(); allowedOrigins: nextConfig?.serverActionsAllowedOrigins, allowedDevOrigins: nextConfig?.allowedDevOrigins, bodySizeLimit: nextConfig?.serverActionsBodySizeLimit, - }, instrumentationPath); + }, instrumentationPath, root); } if (id === RESOLVED_APP_SSR_ENTRY && hasAppDir) { return generateSsrEntry(); @@ -3065,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 @@ -3075,10 +3078,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)) { + // "." + 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); + 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; @@ -3107,6 +3132,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)) { + // "." + 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); + 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 85f7ec4f..1c1d2bb4 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. @@ -52,6 +54,7 @@ export function generateRscEntry( trailingSlash?: boolean, config?: AppRouterConfig, instrumentationPath?: string | null, + root?: string, ): string { const bp = basePath ?? ""; const ts = trailingSlash ?? false; @@ -60,6 +63,11 @@ export function generateRscEntry( const headers = config?.headers ?? []; const allowedOrigins = config?.allowedOrigins ?? []; 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 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(); @@ -210,6 +218,8 @@ ${slotEntries.join(",\n")} }); return ` +import __nodeFs from "node:fs"; +import __nodePath from "node:path"; import { renderToReadableStream, decodeReply, @@ -1066,6 +1076,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)} @@ -1901,6 +1913,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); } + } + } } } @@ -1916,6 +1948,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 && __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/server/mime.ts b/packages/vinext/src/server/mime.ts new file mode 100644 index 00000000..25a98e3e --- /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 3bc080af..6ce40eee 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 7016cee9..0a4bf13d 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -2024,6 +2024,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("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"); @@ -2155,6 +2186,24 @@ 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: [ diff --git a/tests/fixtures/app-basic/next.config.ts b/tests/fixtures/app-basic/next.config.ts index 0613b746..bc67e859 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 @@ -98,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/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/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/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..16810677 100644 --- a/tests/fixtures/pages-basic/next.config.mjs +++ b/tests/fixtures/pages-basic/next.config.mjs @@ -43,12 +43,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 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/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/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..77e289a6 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -287,6 +287,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);