Skip to content

fix(rewrites): serve static files from public/ when rewrite target is a .html path#217

Open
yunus25jmi1 wants to merge 1 commit intocloudflare:mainfrom
yunus25jmi1:fix/issue-199-rewrites
Open

fix(rewrites): serve static files from public/ when rewrite target is a .html path#217
yunus25jmi1 wants to merge 1 commit intocloudflare:mainfrom
yunus25jmi1:fix/issue-199-rewrites

Conversation

@yunus25jmi1
Copy link
Contributor

Fixes #199.

Problem

When next.config rewrites() map a clean URL to a static .html file in public/ (a common pattern for serving pre-built static pages like /auth/no-access/auth/no-access.html), vinext was returning 404 because no Next.js route matches .html paths.

Root Cause

After afterFiles rewrites resolve a path to *.html, the routing logic in all three server paths would find no matching app/pages route for the .html URL and return 404, without ever checking the filesystem's public/ directory.

Fix

Three server paths updated:

  1. Pages Router dev (packages/vinext/src/index.ts): After afterFiles/fallback rewrites, before the final handler() call, check if the resolved URL points to a file in public/ and serve it directly.

  2. App Router dev (packages/vinext/src/server/app-dev-server.ts): The generated RSC entry now checks public/ for the rewritten pathname before rendering the 404 page. Added a new optional root parameter to generateRscEntry() so the public directory path is embedded in the generated virtual module.

  3. Production server (packages/vinext/src/server/prod-server.ts): After afterFiles rewrites and after fallback rewrites produce a path with a file extension, tryServeStatic() is called against the built clientDir (which contains public/ files) before passing to SSR.

Also added a staticMimeType() helper in index.ts to ensure correct Content-Type headers.

Tests

  • Integration tests in app-router.test.ts and pages-router.test.ts: GET /static-html-page (rewritten → /static-html-page.html) returns 200 with correct HTML content and text/html Content-Type.
  • Unit test in app-router.test.ts: generateRscEntry() embeds the public/ path in the generated code.
  • Fixture public/static-html-page.html files added to both app-basic and pages-basic fixtures; next.config rewrites updated.

What async rewrites() flat array does

Per Next.js semantics, when rewrites() returns a flat array, all rules go into afterFiles. vinext already handles this correctly via resolveNextConfig(). This PR fixes the serving of the rewritten destination when it's a static file.

@yunus25jmi1
Copy link
Contributor Author

@southpolesteve @elithrar kindly review the changes.

@yunus25jmi1
Copy link
Contributor Author

Kindly review the changes. @southpolesteve

@yunus25jmi1 yunus25jmi1 force-pushed the fix/issue-199-rewrites branch from 01837a4 to 098e14a Compare March 5, 2026 09:22
@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 5, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@217

commit: 6c640c6

@james-elicx
Copy link
Collaborator

/bigbonk review

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working on this — the use case (rewriting clean URLs to static .html files in public/) is a real gap. The overall approach of checking the filesystem after rewrites fail to match a route is reasonable. However, there are several issues that should be addressed before merging.

Summary of issues

  1. Path traversal guard missing in dev server pathsprod-server.ts uses tryServeStatic() which has traversal protection, but the two dev-server paths (in index.ts and app-dev-server.ts) do not. While the rewrite destination is developer-controlled, cleanPathname in the RSC entry can be influenced by request URLs after middleware processing.

  2. MIME type map duplication — The MIME type mapping is now maintained in three separate places with inconsistent entries. This will drift over time.

  3. publicDir fallback is wrong for src/app layoutspath.dirname(appDir) produces /project/src/public when appDir is /project/src/app. The Vite root is always passed from the call site, but the fallback is misleading and would be silently wrong if ever hit.

  4. String concatenation instead of path.join in generated RSC code — The generated code uses publicDir + cleanPathname which doesn't normalize the path.

  5. deploy.ts (Cloudflare Workers Pages Router entry) not updated — Per AGENTS.md: "When fixing a bug in any of these files, check whether the same bug exists in the others." The Cloudflare Workers entry (deploy.ts) has the same afterFiles/fallback rewrite logic without static file serving. This is likely fine because Cloudflare's ASSETS binding serves static files at the platform level, but it should be explicitly acknowledged.

  6. Missing navigationContext cleanup in RSC entry — When returning the static file Response, setHeadersContext(null) and setNavigationContext(null) are not called, unlike all other early-return paths nearby.

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.
const publicDir = path.join(root ?? path.dirname(appDir), "public").replace(/\\/g, "/");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback path.dirname(appDir) is incorrect when appDir is /project/src/app — it would produce /project/src/public instead of /project/public. In practice root is always passed from the call site in index.ts, so this won't be hit at runtime. But the fallback is misleading. Consider throwing or logging a warning if root is not provided, rather than silently computing a wrong path.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 6, 2026

Review submitted with REQUEST_CHANGES. Here's a summary of the feedback:

Key issues identified:

  1. Path traversal guard missing — The two dev-server paths (index.ts and app-dev-server.ts) lack the traversal protection that tryServeStatic() in prod-server.ts already has. Suggested adding path.resolve() + prefix check.

  2. MIME type map duplicated 3xstaticMimeType() in index.ts, inline __mimeTypes in generated RSC code, and CONTENT_TYPES in prod-server.ts all have different subsets. Recommended extracting to a shared module.

  3. Wrong publicDir fallback for src/app projectspath.dirname(appDir) produces /project/src/public instead of /project/public when the app directory is under src/. The root parameter is always passed in practice, but the fallback is silently wrong.

  4. String concatenation in generated codepublicDir + cleanPathname doesn't normalize paths. Should use path.join() or add explicit traversal guard.

  5. Missing context cleanup — The static file return path in the RSC entry skips setHeadersContext(null) and setNavigationContext(null), unlike all other early-return paths.

  6. deploy.ts not updated — Noted that this is likely fine since Cloudflare's ASSETS binding handles static files, but should be explicitly acknowledged.

github run

@james-elicx
Copy link
Collaborator

I noticed in the issue that they were using a nested route, but we're only reproducing with a top-level route - it might be worth adding a nested one as well to ensure we're covering their scenario.

@yunus25jmi1
Copy link
Contributor Author

@james-elicx As I go through the issues, I'm going to working on it after dinner. Kindly review the remaining PR.

…flare#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)
@yunus25jmi1 yunus25jmi1 force-pushed the fix/issue-199-rewrites branch from 098e14a to 8f8e18a Compare March 6, 2026 16:36
@yunus25jmi1
Copy link
Contributor Author

@southpolesteve @james-elicx The fixes is pushed. Kindly review the changes.

@james-elicx
Copy link
Collaborator

/bigbonk review

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good work on this PR — the use case is real (rewriting to static .html in public/) and the overall approach is sound. The shared MIME map is a nice improvement over the previous state. There are a few issues to address before merging.

Summary

  1. includes(".") in prod-server.ts is too broad — The afterFiles and fallback checks in prod-server.ts still use includes(".") instead of path.extname(), which will trigger unnecessary tryServeStatic filesystem lookups on URLs like /api/v2.0/data. The Pages Router dev server (index.ts) and the App Router dev server (generated RSC entry) both correctly use path.extname(). This should be consistent.

  2. Pages Router dev server only checks static files at the very end — In index.ts, the static file check runs after both afterFiles and fallback rewrites fail to match a route. But prod-server.ts correctly checks immediately after the afterFiles rewrite produces a .html path. This means in dev, if afterFiles rewrites /foo to /foo.html and there's no route match, the code tries fallback rewrites before checking the filesystem. This isn't a correctness bug (the test passes because fallback rewrites won't match either), but it's a behavioral inconsistency with prod and does an unnecessary fallback rewrite attempt. Consider checking right after afterFiles rewrites resolve, matching the prod-server pattern.

  3. publicDir fallback is misleading — The comment correctly notes that path.dirname(appDir) is wrong for src/app layouts, but the fallback root ?? path.resolve(appDir, "..") is still present. Since root is always passed from the call site, this won't be hit in practice, but it would be cleaner to either throw or use a more defensive default.

// 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 (resolvedPathname.includes(".") && tryServeStatic(req, res, clientDir, resolvedPathname, compress)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

includes(".") is too broad here — it triggers tryServeStatic on any URL containing a dot (e.g. /api/v2.0/data). Both index.ts and the generated RSC entry use path.extname() for this check. This should be consistent:

Suggested change
if (resolvedPathname.includes(".") && tryServeStatic(req, res, clientDir, resolvedPathname, compress)) {
if (path.extname(resolvedPathname) && tryServeStatic(req, res, clientDir, resolvedPathname, compress)) {

}
// Check if fallback targets a static file in public/
const fallbackPathname = fallbackRewrite.split("?")[0];
if (fallbackPathname.includes(".") && tryServeStatic(req, res, clientDir, fallbackPathname, compress)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same includes(".") issue as above:

Suggested change
if (fallbackPathname.includes(".") && tryServeStatic(req, res, clientDir, fallbackPathname, compress)) {
if (path.extname(fallbackPathname) && tryServeStatic(req, res, clientDir, fallbackPathname, compress)) {

// corresponds to a static file in the public/ directory (e.g., after a
// rewrite: /auth/no-access → /auth/no-access.html). Serve it directly so
// we don't hit the SSR 404 path.
const resolvedStaticPath = resolvedUrl.split("?")[0];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This static file check runs at the very end, after fallback rewrites have already been tried and failed. In contrast, prod-server.ts checks right after afterFiles rewrites resolve a .html path (line 901) and again after fallback rewrites (line 923). The dev server should match the prod behavior:

  1. Check for static file right after afterFiles rewrites resolve (before matchRoute)
  2. Check again after fallback rewrites resolve

This avoids unnecessary fallback rewrite attempts and keeps dev/prod parity — something AGENTS.md explicitly calls out as important.

// (e.g. /project/src/public instead of /project/public).
const publicDir = path.join(root ?? path.resolve(appDir, ".."), "public").replace(/\\/g, "/");
// Build import map for all page and layout files
const imports: string[] = [];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback root ?? path.resolve(appDir, "..") is documented as wrong for src/app layouts, but still present. Since root is always passed from index.ts, this code path won't execute in normal usage. But if someone adds a new call site without root, they'll get a silently wrong path. Consider making this defensive:

Suggested change
const imports: string[] = [];
const publicDir = path.join(root ?? (() => { console.warn("[vinext] generateRscEntry: root not provided, public/ path may be incorrect"); return path.resolve(appDir, ".."); })(), "public").replace(/\\/g, "/");

Or more simply, just assert that root is provided and throw if not.

if (__extname) {
const __publicRoot = ${JSON.stringify(publicDir)};
const __publicFilePath = __nodePath.resolve(__publicRoot, "." + cleanPathname);
// Path traversal guard — resolved path must stay inside public/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this traversal guard uses hardcoded "/" as the separator. On Windows (if anyone runs the dev server there), path.resolve returns backslash-separated paths, so startsWith(__publicRoot + "/") would fail. Consider using __nodePath.sep instead:

if (__publicFilePath.startsWith(__publicRoot + __nodePath.sep)) {

The index.ts version correctly uses path.sep. The generated RSC entry should match.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 6, 2026

Review submitted. Here's a summary of what I flagged:

Review Summary (REQUEST_CHANGES)

5 inline comments across 3 files:

  1. prod-server.ts (lines 901 & 923)includes(".") is too broad for the file extension check. URLs like /api/v2.0/data would trigger unnecessary tryServeStatic filesystem lookups. Both index.ts and the generated RSC entry correctly use path.extname()prod-server.ts should match. Suggested the fix for both locations.

  2. index.ts (line 2740) — Dev/prod parity issue. The Pages Router dev server only checks for static files after all rewrites (afterFiles + fallback) fail, while prod-server.ts checks immediately after afterFiles rewrites resolve. Per AGENTS.md, dev/prod server logic should stay in sync.

  3. app-dev-server.ts (line 68) — The publicDir fallback (root ?? path.resolve(appDir, "..")) is documented as wrong for src/app layouts but still present. Suggested either throwing or adding a warning if root isn't provided, rather than silently computing a wrong path.

  4. app-dev-server.ts (line 1710) — The traversal guard uses a hardcoded "/" separator. On Windows, path.resolve returns backslash-separated paths, so startsWith(__publicRoot + "/") would fail. The index.ts version correctly uses path.sep — the generated code should use __nodePath.sep instead.

github run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add support for rewrites

2 participants