Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
91 changes: 62 additions & 29 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -845,6 +845,29 @@ export interface VinextOptions {
};
}

/** 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",
".rsc": "text/x-component",
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This CONTENT_TYPES map is a duplicate of the one in prod-server.ts (line 226). On current main, it has been further extracted into server/static-file-cache.ts. Adding a second copy creates a maintenance hazard — when one is updated the other is easily forgotten.

After rebasing onto main, import CONTENT_TYPES from static-file-cache.ts instead of duplicating it.


export default function vinext(options: VinextOptions = {}): PluginOption[] {
const viteMajorVersion = getViteMajorVersion();
let root: string;
Expand Down Expand Up @@ -2589,6 +2612,31 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
// (app router is handled by @vitejs/plugin-rsc's built-in middleware)
if (!hasPagesDir) return next();

const applyRequestHeadersToNodeRequest = (nextRequestHeaders: Headers) => {
for (const key of Object.keys(req.headers)) {
delete req.headers[key];
}
for (const [key, value] of nextRequestHeaders) {
req.headers[key] = value;
}
};

let middlewareRequestHeaders: Headers | null = null;
let deferredMwResponseHeaders: [string, string][] | null = null;

const applyDeferredMwHeaders = (
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The refactored applyDeferredMwHeaders now takes explicit response and headers parameters instead of closing over res and deferredMwResponseHeaders. While this is a reasonable API improvement, note that adding the x-middleware-* header filter here changes behavior at every call site — the original applyDeferredMwHeaders() did not filter x-middleware-* headers.

On main, the middleware response header collection already strips x-middleware-* headers when building deferredMwResponseHeaders (see the if (!key.startsWith("x-middleware-")) check around line 2925). So the filter in applyDeferredMwHeaders is redundant for the existing call sites but acts as defense-in-depth. This is fine, but worth noting for correctness — double-filtering is harmless but the original code path already handled it.

response: import("node:http").ServerResponse,
headers?: [string, string][] | Headers | null,
) => {
if (!headers) return;
for (const [key, value] of headers) {
// skip internal x-middleware- headers
if (key.startsWith("x-middleware-")) continue;
// append handles multiple Set-Cookie correctly
response.appendHeader(key, value);
}
};

// Skip Vite internal requests and static files
if (
url.startsWith("/@") ||
Expand Down Expand Up @@ -2674,16 +2722,21 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
}

// Skip requests for files with extensions (static assets)
let pathname = url.split("?")[0];
if (pathname.includes(".") && !pathname.endsWith(".html")) {
const [pathnameWithExt] = url.split("?");
const ext = path.extname(pathnameWithExt);
if (ext && ext !== ".html" && CONTENT_TYPES[ext]) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Behavioral regression: the old guard was pathname.includes(".") && !pathname.endsWith(".html") which matched any file with a dot in its path. The new guard requires the extension to be in the CONTENT_TYPES allowlist.

This means files with unlisted extensions (.xml, .txt, .pdf, .mp4, .webm, .zip, .csv, .yaml, .wasm, etc.) will no longer be passed to Vite's static middleware and will instead fall through to the Pages Router SSR handler, likely resulting in 404s or broken responses for those file types.

If the goal is to apply middleware headers to static files, the extension allowlist approach is the wrong tool — keep the original broad check and apply headers conditionally:

Suggested change
if (ext && ext !== ".html" && CONTENT_TYPES[ext]) {
if (ext && ext !== ".html") {

// If middleware was run, apply its headers (Set-Cookie, etc.)
// before Vite's built-in static-file middleware sends the file.
// This ensures public/ asset responses have middleware headers.
applyDeferredMwHeaders(res, deferredMwResponseHeaders);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This applyDeferredMwHeaders() call is always a no-op. At this point in the control flow, middleware has not yet executed — deferredMwResponseHeaders is still null (initialized on line 2625). Middleware runs ~100 lines below (line 2840). So the comment "If middleware was run, apply its headers" is inaccurate.

For middleware headers to actually appear on static file responses, either:

  1. Move the static-file bailout to after the middleware block, or
  2. Run middleware before the extension check.

Both options change the execution order, so this needs careful thought about whether it matches Next.js semantics. Per the Next.js execution order, middleware runs before filesystem routes — so option (1) or (2) would actually be more correct for public/ directory files.

return next();
}

// Guard against protocol-relative URL open redirects.
// Normalize backslashes first: browsers treat /\ as // in URL
// context. Check the RAW pathname before normalizePath so the
// guard fires before normalizePath collapses //.
pathname = pathname.replaceAll("\\", "/");
let pathname = pathnameWithExt.replaceAll("\\", "/");
if (pathname.startsWith("//")) {
res.writeHead(404);
res.end("404 Not Found");
Expand Down Expand Up @@ -2783,26 +2836,6 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
if (redirected) return;
}

const applyRequestHeadersToNodeRequest = (nextRequestHeaders: Headers) => {
for (const key of Object.keys(req.headers)) {
delete req.headers[key];
}
for (const [key, value] of nextRequestHeaders) {
req.headers[key] = value;
}
};

let middlewareRequestHeaders: Headers | null = null;
let deferredMwResponseHeaders: [string, string][] | null = null;

const applyDeferredMwHeaders = () => {
if (deferredMwResponseHeaders) {
for (const [key, value] of deferredMwResponseHeaders) {
res.appendHeader(key, value);
}
}
};

// Run middleware.ts if present
if (middlewarePath) {
// Only trust X-Forwarded-Proto when behind a trusted proxy
Expand Down Expand Up @@ -2968,7 +3001,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {

// External rewrite from beforeFiles — proxy to external URL
if (isExternalUrl(resolvedUrl)) {
applyDeferredMwHeaders();
applyDeferredMwHeaders(res, deferredMwResponseHeaders);
await proxyExternalRewriteNode(req, res, resolvedUrl);
return;
}
Expand All @@ -2983,7 +3016,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
);
const apiMatch = matchRoute(resolvedUrl, apiRoutes);
if (apiMatch) {
applyDeferredMwHeaders();
applyDeferredMwHeaders(res, deferredMwResponseHeaders);
if (middlewareRequestHeaders) {
applyRequestHeadersToNodeRequest(middlewareRequestHeaders);
}
Expand Down Expand Up @@ -3023,7 +3056,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {

// External rewrite from afterFiles — proxy to external URL
if (isExternalUrl(resolvedUrl)) {
applyDeferredMwHeaders();
applyDeferredMwHeaders(res, deferredMwResponseHeaders);
await proxyExternalRewriteNode(req, res, resolvedUrl);
return;
}
Expand All @@ -3043,7 +3076,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
// Try rendering the resolved URL
const match = matchRoute(resolvedUrl.split("?")[0], routes);
if (match) {
applyDeferredMwHeaders();
applyDeferredMwHeaders(res, deferredMwResponseHeaders);
if (middlewareRequestHeaders) {
applyRequestHeadersToNodeRequest(middlewareRequestHeaders);
}
Expand All @@ -3061,15 +3094,15 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
if (fallbackRewrite) {
// External fallback rewrite — proxy to external URL
if (isExternalUrl(fallbackRewrite)) {
applyDeferredMwHeaders();
applyDeferredMwHeaders(res, deferredMwResponseHeaders);
await proxyExternalRewriteNode(req, res, fallbackRewrite);
return;
}
const fallbackMatch = matchRoute(fallbackRewrite.split("?")[0], routes);
if (!fallbackMatch && hasAppDir) {
return next();
}
applyDeferredMwHeaders();
applyDeferredMwHeaders(res, deferredMwResponseHeaders);
if (middlewareRequestHeaders) {
applyRequestHeadersToNodeRequest(middlewareRequestHeaders);
}
Expand Down
25 changes: 25 additions & 0 deletions packages/vinext/src/server/prod-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1181,6 +1181,9 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
}

// ── 7. Apply beforeFiles rewrites from next.config.js ─────────
// Serve static files for beforeFiles rewrite targets. This matches
// Next.js behavior where beforeFiles rewrites can resolve to static
// files in public/ or other direct filesystem paths.
if (configRewrites.beforeFiles?.length) {
const rewritten = matchRewrite(resolvedPathname, configRewrites.beforeFiles, postMwReqCtx);
if (rewritten) {
Expand All @@ -1191,6 +1194,14 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
}
resolvedUrl = rewritten;
resolvedPathname = rewritten.split("?")[0];

// Try serving static file at the rewritten path
if (
path.extname(resolvedPathname) &&
tryServeStatic(req, res, clientDir, resolvedPathname, compress, middlewareHeaders)
) {
return;
}
}
}

Expand Down Expand Up @@ -1238,6 +1249,13 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
}
resolvedUrl = rewritten;
resolvedPathname = rewritten.split("?")[0];

if (
path.extname(resolvedPathname) &&
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This change won't apply to current main. The tryServeStatic function on main is now async (returns Promise<boolean>), accepts a StaticFileCache parameter, and has ETag/304 support. The entire startPagesRouterServer function has been refactored.

After rebasing, this logic needs to be re-implemented using the new tryServeStatic signature and staticCache parameter.

tryServeStatic(req, res, clientDir, resolvedPathname, compress, middlewareHeaders)
) {
return;
}
}
}

Expand All @@ -1259,6 +1277,13 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
await sendWebResponse(proxyResponse, req, res, compress);
return;
}
const fallbackPathname = fallbackRewrite.split("?")[0];
if (
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same issue as the beforeFiles block above — this code targets an older version of prod-server.ts. After rebasing, re-implement against the current tryServeStatic API.

Also note: the afterFiles rewrite block in prod-server.ts on main (around line 1340) doesn't have this static-file check either, so you'll want to add it there too for consistency.

path.extname(fallbackPathname) &&
tryServeStatic(req, res, clientDir, fallbackPathname, compress, middlewareHeaders)
) {
return;
}
response = await renderPage(webRequest, fallbackRewrite, ssrManifest);
}
}
Expand Down
Loading