Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
506d825
fix: serve static HTML files from public/ after rewrites (issue #199)
yunus25jmi1 Mar 1, 2026
7d3786f
Merge remote-tracking branch 'upstream/main' into fix/issue-199-rewrites
yunus25jmi1 Mar 8, 2026
cc56d2c
address bonk review: hoist resolvedPublicDir, log non-ENOENT errors, …
yunus25jmi1 Mar 8, 2026
c4835ee
address bonk review: hoist __publicDir to module scope, add fallback …
yunus25jmi1 Mar 8, 2026
945b9cc
fix: use path.resolve for publicDir consistency with index.ts
yunus25jmi1 Mar 8, 2026
a6852a1
Update packages/vinext/src/index.ts
yunus25jmi1 Mar 9, 2026
e629695
Update packages/vinext/src/index.ts
yunus25jmi1 Mar 9, 2026
a9013cd
Merge upstream/main into fix/issue-199-rewrites
yunus25jmi1 Mar 9, 2026
cab6115
fix syntax errors in merge resolution
yunus25jmi1 Mar 9, 2026
e30bd8b
fix: update snapshots and formatting for static file serving feature
yunus25jmi1 Mar 9, 2026
60ee65d
Update packages/vinext/src/index.ts
yunus25jmi1 Mar 9, 2026
d2f2c8d
Update packages/vinext/src/index.ts
yunus25jmi1 Mar 9, 2026
12c8f59
Merge branch 'main' into fix/issue-199-rewrites
yunus25jmi1 Mar 9, 2026
2cdcbac
Merge upstream/main into fix/issue-199-rewrites - resolve conflicts
yunus25jmi1 Mar 15, 2026
70c2663
fix(pages-router): add static file check for fallback rewrites
yunus25jmi1 Mar 15, 2026
1a1d97b
fix: apply oxfmt formatting
yunus25jmi1 Mar 15, 2026
aeefb45
fix(vite-8): remove deprecated Rollup config options for Vite 8/Rolldown
yunus25jmi1 Mar 15, 2026
b162476
Revert "fix(vite-8): remove deprecated Rollup config options for Vite…
yunus25jmi1 Mar 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -106,6 +108,7 @@ export function generateRscEntry(
trailingSlash?: boolean,
config?: AppRouterConfig,
instrumentationPath?: string | null,
root?: string,
Copy link
Contributor

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-2384 hard 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. The null fallback + warning is safe but it means TypeScript won't catch a missing root at a future call site. Up to you — the current approach works.

Copy link
Contributor

Choose a reason for hiding this comment

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

With 10 positional parameters, the call site at index.ts:2377-2384 is increasingly hard to read. This has been noted in previous reviews as tech debt. Since you're adding a new parameter here, this would have been a good opportunity to bundle the trailing optional params into an options object — but I agree it's not strictly necessary in this PR.

One concrete suggestion: make root non-optional. The only call site always passes it, and the null fallback silently disables the feature rather than surfacing an error. A required parameter would let TypeScript catch any future caller that forgets it.

Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: with 10 positional parameters, consider making root required (drop the ?). The only call site (index.ts:1414) always provides it, and the null fallback silently disables static file serving rather than surfacing a type error. A required parameter would let TypeScript catch any future caller that forgets it.

Non-blocking — the current approach with the warning at line 70 is safe.

): string {
const bp = basePath ?? "";
const ts = trailingSlash ?? false;
Expand All @@ -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[] = [];
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.

const importMap: Map<string, string> = new Map();
Copy link
Contributor

Choose a reason for hiding this comment

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

When root is not provided, publicDir becomes null and static file serving silently degrades. Since the only call site (index.ts line 2347) always passes root, this won't fire in practice. But the silent null makes it easy for a future caller to miss root and get no error.

Consider either making root required (since the single call site always provides it), or logging a warning when it's missing.

Copy link
Contributor

Choose a reason for hiding this comment

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

The publicDir is null when root isn't provided, and the generated code bakes in ${JSON.stringify(null)} !== null which is always false — effectively disabling the feature silently. The warning on lines 70-71 is good, but since the only call site (index.ts:2384) always passes root, consider making root required (drop the ?) so TypeScript enforces this at the call site. If a future caller forgets root, they'd get a type error instead of silently broken static file serving.

Non-blocking — the current code is safe.

Expand Down Expand Up @@ -293,6 +301,8 @@ ${slotEntries.join(",\n")}
});

return `
import __nodeFs from "node:fs";
import __nodePath from "node:path";
import {
renderToReadableStream,
decodeReply,
Expand Down Expand Up @@ -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)};
Copy link
Contributor

Choose a reason for hiding this comment

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

Good — __mimeTypes is correctly hoisted to module scope in the generated code. This was flagged in earlier reviews and has been properly addressed.

const __publicDir = ${JSON.stringify(publicDir)};

${generateDevOriginCheckCode(config?.allowedDevOrigins)}

Expand Down Expand Up @@ -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); }
}
}
}
}

Expand All @@ -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);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Dev/prod parity issue: This static file check only runs inside if (!match) — i.e. after afterFiles rewrites, matchRoute(), and fallback rewrites have all been attempted. In contrast, prod-server.ts (line 958) and index.ts (line 2984) check for static files immediately after afterFiles rewrites resolve, before route matching.

The current flow in the App Router dev path:

  1. afterFiles rewrites /foo/foo.html (line 1773)
  2. matchRoute("/foo.html", routes) — always misses (line 1777)
  3. Fallback rewrites attempted — unnecessary for .html (line 1780)
  4. !match block finally checks public/ (here)

The check should be inserted right after cleanPathname = __afterRewritten; (after line 1773), and again after cleanPathname = __fallbackRewritten; (after line 1788), matching the other server paths. This avoids unnecessary matchRoute and fallback rewrite calls and keeps dev/prod parity — something AGENTS.md explicitly calls out.


Expand Down
68 changes: 65 additions & 3 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
} from "./utils/manifest-paths.js";
import { hasBasePath } from "./utils/base-path.js";
import { asyncHooksStubPlugin } from "./plugins/async-hooks-stub.js";
import { mimeType } from "./server/mime.js";
import { clientReferenceDedupPlugin } from "./plugins/client-reference-dedup.js";
import { hasWranglerConfig, formatMissingCloudflarePluginError } from "./deploy.js";
import tsconfigPaths from "vite-tsconfig-paths";
Expand Down Expand Up @@ -1624,6 +1625,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
i18n: nextConfig?.i18n,
},
instrumentationPath,
root,
);
}
if (id === RESOLVED_APP_SSR_ENTRY && hasAppDir) {
Expand Down Expand Up @@ -2360,6 +2362,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {

const routes = await pagesRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher);

const resolvedPublicDir = path.resolve(root, "public");

// Apply afterFiles rewrites — these run after initial route matching
// If beforeFiles already rewrote the URL, afterFiles still run on the
// *resolved* pathname. Next.js applies these when route matching succeeds
Expand All @@ -2370,10 +2374,43 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
nextConfig.rewrites.afterFiles,
reqCtx,
);
if (afterRewrite) resolvedUrl = afterRewrite;
if (afterRewrite) {
resolvedUrl = afterRewrite;
// External rewrite from afterFiles — proxy to external URL
if (isExternalUrl(afterRewrite)) {
await proxyExternalRewriteNode(req, res, afterRewrite);
return;
}
// If the rewritten path has a file extension, it may point to a
// static file in public/. Serve it directly before route matching
// (which would miss it and SSR would return 404).
const afterFilesPathname = afterRewrite.split("?")[0];
if (path.extname(afterFilesPathname)) {
// "." + afterFilesPathname works because rewrite destinations always start with "/"
const publicFilePath = path.resolve(
resolvedPublicDir,
"." + afterFilesPathname,
);
if (publicFilePath.startsWith(resolvedPublicDir + path.sep)) {
try {
const stat = fs.statSync(publicFilePath);
if (stat.isFile()) {
const content = fs.readFileSync(publicFilePath);
const ext = path.extname(afterFilesPathname).slice(1).toLowerCase();
res.writeHead(200, { "Content-Type": mimeType(ext) });
res.end(content);
return;
}
} catch (e: any) {
if (e?.code !== "ENOENT")
console.warn("[vinext] static file check failed:", e);
}
}
}
}
}

// External rewrite from afterFiles — proxy to external URL
// External rewrite (from beforeFiles) — proxy to external URL
if (isExternalUrl(resolvedUrl)) {
await proxyExternalRewriteNode(req, res, resolvedUrl);
return;
Expand Down Expand Up @@ -2413,6 +2450,27 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
await proxyExternalRewriteNode(req, res, fallbackRewrite);
return;
}
// Check if fallback targets a static file in public/
const fallbackPathname = fallbackRewrite.split("?")[0];
if (path.extname(fallbackPathname)) {
const resolvedPublicDir = path.resolve(root, "public");
const publicFilePath = path.resolve(resolvedPublicDir, "." + fallbackPathname);
if (publicFilePath.startsWith(resolvedPublicDir + path.sep)) {
try {
const stat = fs.statSync(publicFilePath);
if (stat.isFile()) {
const content = fs.readFileSync(publicFilePath);
const ext = path.extname(fallbackPathname).slice(1).toLowerCase();
res.writeHead(200, { "Content-Type": mimeType(ext) });
res.end(content);
return;
}
} catch (e: any) {
if (e?.code !== "ENOENT")
console.warn("[vinext] static file check failed:", e);
}
}
}
const fallbackMatch = matchRoute(fallbackRewrite.split("?")[0], routes);
if (!fallbackMatch && hasAppDir) {
return next();
Expand All @@ -2429,7 +2487,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
// otherwise render via the pages SSR handler (will 404 for unknown routes).
if (hasAppDir) return next();

await handler(req, res, resolvedUrl, mwStatus);
try {
await handler(req, res, resolvedUrl, mwStatus);
} catch (e) {
next(e);
}
} catch (e) {
next(e);
}
Expand Down
47 changes: 47 additions & 0 deletions packages/vinext/src/server/mime.ts
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",
Copy link
Contributor

Choose a reason for hiding this comment

The 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: wasm: "application/wasm". Cloudflare Workers apps in particular use .wasm files. Not blocking, but worth adding if you're touching this file anyway.

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";
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Good consolidation. One minor note: text/html; charset=utf-8 includes the charset for HTML, but text/css, text/plain, application/javascript, application/json, and application/xml are all text-based formats that could benefit from a charset declaration too. Not blocking since this matches what most static file servers do (charset only for HTML), but worth being aware of.

50 changes: 26 additions & 24 deletions packages/vinext/src/server/prod-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

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

Clean implementation — delegating to the existing tryServeStatic() (which already has traversal guards and compression support) is the right call here. The path.extname() gate avoids unnecessary filesystem lookups.

) {
return;
}
}
}

Expand All @@ -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);
}
}
Expand Down
Loading
Loading