Skip to content
Merged
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
20 changes: 19 additions & 1 deletion examples/pages-router-cloudflare/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,29 @@ import { NextRequest, NextResponse } from "next/server";
* that middleware actually ran (and didn't crash with the outsideEmitter bug).
*/
export function middleware(request: NextRequest) {
if (request.nextUrl.pathname === "/headers-before-middleware-rewrite") {
return NextResponse.rewrite(new URL("/ssr", request.url));
}

if (request.nextUrl.pathname === "/redirect-before-middleware-rewrite") {
return NextResponse.redirect(new URL("/ssr", request.url));
}

if (request.nextUrl.pathname === "/redirect-before-middleware-response") {
return new Response("middleware response", { status: 418 });
}

const response = NextResponse.next();
response.headers.set("x-mw-ran", "true");
return response;
}

export const config = {
matcher: ["/api/:path*", "/ssr"],
matcher: [
"/api/:path*",
"/ssr",
"/headers-before-middleware-rewrite",
"/redirect-before-middleware-rewrite",
"/redirect-before-middleware-response",
],
};
25 changes: 25 additions & 0 deletions examples/pages-router-cloudflare/next.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export default {
async headers() {
return [
{
source: "/headers-before-middleware-rewrite",
headers: [{ key: "x-rewrite-source-header", value: "1" }],
},
];
},

async redirects() {
return [
{
source: "/redirect-before-middleware-rewrite",
destination: "/about",
permanent: false,
},
{
source: "/redirect-before-middleware-response",
destination: "/about",
permanent: false,
},
];
},
};
45 changes: 24 additions & 21 deletions examples/pages-router-cloudflare/worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
requestContextFromRequest,
isExternalUrl,
proxyExternalRequest,
sanitizeDestination,
} from "vinext/config/config-matchers";
import { mergeHeaders } from "vinext/server/worker-utils";

Expand Down Expand Up @@ -73,9 +74,25 @@ export default {
request = new Request(strippedUrl, request);
}

// Build request context for has/missing condition matching
// Build request context for config matching that runs before middleware.
const reqCtx = requestContextFromRequest(request);

// Apply redirects from next.config.js before middleware.
if (configRedirects.length) {
const redirect = matchRedirect(pathname, configRedirects, reqCtx);
if (redirect) {
const dest = sanitizeDestination(
basePath && !isExternalUrl(redirect.destination) && !redirect.destination.startsWith(basePath)
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: The deploy template and prod-server both use !hasBasePath(redirect.destination, basePath) here instead of !redirect.destination.startsWith(basePath). The difference matters when basePath is /app and the destination is /applicationstartsWith would incorrectly skip the prefix, while hasBasePath correctly requires a segment boundary.

Since this is an example file and hasBasePath isn't exported from vinext/config/config-matchers, this is low-priority, but worth noting for correctness parity.

Suggested change
basePath && !isExternalUrl(redirect.destination) && !redirect.destination.startsWith(basePath)
basePath && !isExternalUrl(redirect.destination) && !redirect.destination.startsWith(basePath + "/") && redirect.destination !== basePath

Or alternatively, inline the hasBasePath logic or export it from the config-matchers barrel.

? basePath + redirect.destination
: redirect.destination,
);
return new Response(null, {
status: redirect.permanent ? 308 : 307,
headers: { Location: dest },
});
}
}

// Run middleware
let resolvedUrl = urlWithQuery;
const middlewareHeaders: Record<string, string | string[]> = {};
Expand Down Expand Up @@ -142,11 +159,12 @@ export default {
});
}

const postMwReqCtx = requestContextFromRequest(request);
let resolvedPathname = resolvedUrl.split("?")[0];

// Apply custom headers from next.config.js
if (configHeaders.length) {
const matched = matchHeaders(resolvedPathname, configHeaders);
const matched = matchHeaders(pathname, configHeaders, reqCtx);
Copy link
Contributor

Choose a reason for hiding this comment

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

The matchHeaders call correctly uses pathname + reqCtx now. However, the header-merge loop below (lines 166-181) has a pre-existing inconsistency: the else branch at line 179 unconditionally sets middlewareHeaders[lk] = h.value, which lets a config header overwrite a middleware header with the same key. The deploy template and prod-server both guard with } else if (!(lk in middlewareHeaders)) { so middleware always wins. Worth aligning since this code was touched.

for (const h of matched) {
const lk = h.key.toLowerCase();
if (lk === "set-cookie") {
Expand All @@ -160,29 +178,15 @@ export default {
}
} else if (lk === "vary" && middlewareHeaders[lk]) {
middlewareHeaders[lk] += ", " + h.value;
} else {
} else if (!(lk in middlewareHeaders)) {
middlewareHeaders[lk] = h.value;
}
}
}

// Apply redirects from next.config.js
if (configRedirects.length) {
const redirect = matchRedirect(resolvedPathname, configRedirects, reqCtx);
if (redirect) {
const dest = basePath && !redirect.destination.startsWith(basePath)
? basePath + redirect.destination
: redirect.destination;
return new Response(null, {
status: redirect.permanent ? 308 : 307,
headers: { Location: dest },
});
}
}

// Apply beforeFiles rewrites from next.config.js
if (configRewrites.beforeFiles?.length) {
const rewritten = matchRewrite(resolvedPathname, configRewrites.beforeFiles, reqCtx);
const rewritten = matchRewrite(resolvedPathname, configRewrites.beforeFiles, postMwReqCtx);
Copy link
Contributor

Choose a reason for hiding this comment

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

Good: beforeFiles and afterFiles rewrites now correctly use postMwReqCtx. Note that the fallback rewrites at line 223 still use the pre-middleware reqCtx, which differs from the deploy template and prod-server (which both use postMwReqCtx for fallback). Worth fixing while you're here.

if (rewritten) {
if (isExternalUrl(rewritten)) {
return proxyExternalRequest(request, rewritten);
Expand All @@ -202,7 +206,7 @@ export default {

// Apply afterFiles rewrites
if (configRewrites.afterFiles?.length) {
const rewritten = matchRewrite(resolvedPathname, configRewrites.afterFiles, reqCtx);
const rewritten = matchRewrite(resolvedPathname, configRewrites.afterFiles, postMwReqCtx);
if (rewritten) {
if (isExternalUrl(rewritten)) {
return proxyExternalRequest(request, rewritten);
Expand All @@ -219,7 +223,7 @@ export default {

// Fallback rewrites (if SSR returned 404)
if (response && response.status === 404 && configRewrites.fallback?.length) {
const fallbackRewrite = matchRewrite(resolvedPathname, configRewrites.fallback, reqCtx);
const fallbackRewrite = matchRewrite(resolvedPathname, configRewrites.fallback, postMwReqCtx);
if (fallbackRewrite) {
if (isExternalUrl(fallbackRewrite)) {
return proxyExternalRequest(request, fallbackRewrite);
Expand All @@ -240,4 +244,3 @@ export default {
}
},
};

57 changes: 30 additions & 27 deletions packages/vinext/src/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -612,15 +612,34 @@ export default {
request = new Request(strippedUrl, request);
}

// Build request context for has/missing condition matching.
// headers and redirects run before middleware, so they use this
// pre-middleware snapshot. beforeFiles, afterFiles, and fallback
// rewrites run after middleware (App Router order), so they use
// postMwReqCtx created after x-middleware-request-* headers are
// unpacked into request.
// Build request context for pre-middleware config matching. Redirects
// run before middleware in Next.js. Header match conditions also use the
// original request snapshot even though header merging happens later so
// middleware response headers can still take precedence.
// beforeFiles, afterFiles, and fallback rewrites run after middleware
// (App Router order), so they use postMwReqCtx created after
// x-middleware-request-* headers are unpacked into request.
const reqCtx = requestContextFromRequest(request);

// ── 3. Run middleware ──────────────────────────────────────────
// ── 3. Apply redirects from next.config.js ────────────────────
if (configRedirects.length) {
const redirect = matchRedirect(pathname, configRedirects, reqCtx);
if (redirect) {
const dest = sanitizeDestination(
basePath &&
!isExternalUrl(redirect.destination) &&
!hasBasePath(redirect.destination, basePath)
? basePath + redirect.destination
: redirect.destination,
);
return new Response(null, {
status: redirect.permanent ? 308 : 307,
headers: { Location: dest },
});
}
}

// ── 4. Run middleware ──────────────────────────────────────────
let resolvedUrl = urlWithQuery;
const middlewareHeaders: Record<string, string | string[]> = {};
let middlewareRewriteStatus: number | undefined;
Expand Down Expand Up @@ -680,17 +699,19 @@ export default {
const { postMwReqCtx, request: postMwReq } = applyMiddlewareRequestHeaders(middlewareHeaders, request);
request = postMwReq;

// Config header matching must keep using the original normalized pathname
// even if middleware rewrites the downstream route/render target.
let resolvedPathname = resolvedUrl.split("?")[0];

// ── 4. Apply custom headers from next.config.js ───────────────
// ── 5. Apply custom headers from next.config.js ───────────────
// Config headers are additive for multi-value headers (Vary,
// Set-Cookie) and override for everything else. Vary values are
// comma-joined per HTTP spec. Set-Cookie values are accumulated
// as arrays (RFC 6265 forbids comma-joining cookies).
// Middleware headers take precedence: skip config keys already set
// by middleware so middleware always wins for the same key.
if (configHeaders.length) {
const matched = matchHeaders(resolvedPathname, configHeaders, reqCtx);
const matched = matchHeaders(pathname, configHeaders, reqCtx);
for (const h of matched) {
const lk = h.key.toLowerCase();
if (lk === "set-cookie") {
Expand All @@ -712,24 +733,6 @@ export default {
}
}

// ── 5. Apply redirects from next.config.js ────────────────────
if (configRedirects.length) {
const redirect = matchRedirect(resolvedPathname, configRedirects, reqCtx);
if (redirect) {
const dest = sanitizeDestination(
basePath &&
!isExternalUrl(redirect.destination) &&
!hasBasePath(redirect.destination, basePath)
? basePath + redirect.destination
: redirect.destination,
);
return new Response(null, {
status: redirect.permanent ? 308 : 307,
headers: { Location: dest },
});
}
}

// ��─ 6. Apply beforeFiles rewrites from next.config.js ─────────
if (configRewrites.beforeFiles?.length) {
const rewritten = matchRewrite(resolvedPathname, configRewrites.beforeFiles, postMwReqCtx);
Expand Down
53 changes: 38 additions & 15 deletions packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1943,16 +1943,39 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
const hasDefault = typeof handler["default"] === "function";

// Route handlers need the same middleware header/status merge behavior as
// page responses. This keeps middleware response headers visible on API
// routes in Workers/dev, and preserves custom rewrite status overrides.
function attachRouteHandlerMiddlewareContext(response) {
Copy link
Contributor

Choose a reason for hiding this comment

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

When _mwCtx.headers is null and _mwCtx.status is null (no middleware ran), this still allocates a new Response on every route handler return across 10+ call sites. Consider an early-return guard to avoid unnecessary allocations:

Suggested change
function attachRouteHandlerMiddlewareContext(response) {
function attachRouteHandlerMiddlewareContext(response) {
if (!_mwCtx.headers && _mwCtx.status == null) return response;
const responseHeaders = new Headers(response.headers);

// _mwCtx.headers is only set (non-null) when middleware actually ran and
// produced a continue/rewrite response. An empty Headers object (middleware
// ran but produced no response headers) is a harmless edge case: the early
// return is skipped, but the copy loop below is a no-op, so no incorrect
// headers are added. The allocation cost in that case is acceptable.
if (!_mwCtx.headers && _mwCtx.status == null) return response;
Copy link
Contributor

Choose a reason for hiding this comment

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

The early-return guard (if (!_mwCtx.headers && _mwCtx.status == null) return response) is a nice touch and avoids unnecessary Response allocations on every route handler return when no middleware ran. One thing to double-check: _mwCtx.headers is a Headers object or null? If it's an empty Headers object (no entries but not null) when middleware runs but sets no response headers, the !_mwCtx.headers check would be falsy and we'd skip the early return, falling through to allocate a new Response that copies all headers but changes nothing. That's harmless but slightly wasteful. Depends on how _mwCtx is populated — if it's only set when middleware actually produces headers, this is fine.

const responseHeaders = new Headers(response.headers);
if (_mwCtx.headers) {
for (const [key, value] of _mwCtx.headers) {
responseHeaders.append(key, value);
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor: append means if middleware sets x-custom: foo and the route handler also returns x-custom: bar, the final response will have both values. This is correct for multi-value headers like Set-Cookie and Vary, but for single-value headers the middleware value becomes additive rather than authoritative. This matches the page response behavior (which also appends middleware headers), so it's consistent — just flagging that it's append not set.

}
}
return new Response(response.body, {
status: _mwCtx.status ?? response.status,
statusText: response.statusText,
headers: responseHeaders,
});
}

// OPTIONS auto-implementation: respond with Allow header and 204
if (method === "OPTIONS" && typeof handler["OPTIONS"] !== "function") {
const allowMethods = hasDefault ? HTTP_METHODS : exportedMethods;
if (!allowMethods.includes("OPTIONS")) allowMethods.push("OPTIONS");
setHeadersContext(null);
setNavigationContext(null);
return new Response(null, {
return attachRouteHandlerMiddlewareContext(new Response(null, {
status: 204,
headers: { "Allow": allowMethods.join(", ") },
});
}));
}

// HEAD auto-implementation: run GET handler and strip body
Expand Down Expand Up @@ -1996,28 +2019,28 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
if (draftCookie) newHeaders.append("Set-Cookie", draftCookie);

if (isAutoHead) {
return new Response(null, {
return attachRouteHandlerMiddlewareContext(new Response(null, {
status: response.status,
statusText: response.statusText,
headers: newHeaders,
});
}));
}
return new Response(response.body, {
return attachRouteHandlerMiddlewareContext(new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: newHeaders,
});
}));
}

if (isAutoHead) {
// Strip body for auto-HEAD, preserve headers and status
return new Response(null, {
return attachRouteHandlerMiddlewareContext(new Response(null, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}));
}
return response;
return attachRouteHandlerMiddlewareContext(response);
} catch (err) {
getAndClearPendingCookies(); // Clear any pending cookies on error
// Catch redirect() / notFound() thrown from route handlers
Expand All @@ -2029,16 +2052,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
setHeadersContext(null);
setNavigationContext(null);
return new Response(null, {
return attachRouteHandlerMiddlewareContext(new Response(null, {
status: statusCode,
headers: { Location: new URL(redirectUrl, request.url).toString() },
});
}));
}
if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) {
const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10);
setHeadersContext(null);
setNavigationContext(null);
return new Response(null, { status: statusCode });
return attachRouteHandlerMiddlewareContext(new Response(null, { status: statusCode }));
}
}
setHeadersContext(null);
Expand All @@ -2051,17 +2074,17 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
).catch((reportErr) => {
console.error("[vinext] Failed to report route handler error:", reportErr);
});
return new Response(null, { status: 500 });
return attachRouteHandlerMiddlewareContext(new Response(null, { status: 500 }));
} finally {
setHeadersAccessPhase(previousHeadersPhase);
}
}
setHeadersContext(null);
setNavigationContext(null);
return new Response(null, {
return attachRouteHandlerMiddlewareContext(new Response(null, {
status: 405,
headers: { Allow: exportedMethods.join(", ") },
});
}));
}

// Build the component tree: layouts wrapping the page
Expand Down
Loading
Loading