-
Notifications
You must be signed in to change notification settings - Fork 253
fix(rewrites): serve static files from public/ when rewrite target is a .html path #217
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
506d825
7d3786f
cc56d2c
c4835ee
945b9cc
a6852a1
e629695
a9013cd
cab6115
e30bd8b
60ee65d
d2f2c8d
12c8f59
2cdcbac
70c2663
1a1d97b
aeefb45
b162476
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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, | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. With 10 positional parameters, the call site at One concrete suggestion: make
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: with 10 positional parameters, consider making Non-blocking — the current approach with the warning at line 70 is safe. |
||||||
| ): 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[] = []; | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The fallback
Suggested change
Or more simply, just assert that |
||||||
| const importMap: Map<string, string> = new Map(); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When Consider either making
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Non-blocking — the current code is safe. |
||||||
|
|
@@ -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)}; | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good — |
||||||
| 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); | ||||||
| } | ||||||
| } | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dev/prod parity issue: This static file check only runs inside The current flow in the App Router dev path:
The check should be inserted right after |
||||||
|
|
||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, string> = { | ||
| html: "text/html; charset=utf-8", | ||
| htm: "text/html; charset=utf-8", | ||
| css: "text/css", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice consolidation. One missing entry that's fairly common in Next.js apps: |
||
| 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"; | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good consolidation. One minor note: |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<string, string> = { | ||
| ".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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Clean implementation — delegating to the existing |
||
| ) { | ||
| 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); | ||
| } | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This function now has 10 positional parameters, which makes the call site at
index.ts:2377-2384hard to read (you have to count arguments to know what maps to what). Not something to fix in this PR, but worth noting as tech debt — a future refactor could bundle the later optional params into an options object.For this PR specifically: since the only call site always provides
root, making it required (dropping the?) would be a stronger API choice. Thenullfallback + warning is safe but it means TypeScript won't catch a missingrootat a future call site. Up to you — the current approach works.