diff --git a/examples/pages-router-cloudflare/middleware.ts b/examples/pages-router-cloudflare/middleware.ts index bd045466..1530d7cb 100644 --- a/examples/pages-router-cloudflare/middleware.ts +++ b/examples/pages-router-cloudflare/middleware.ts @@ -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", + ], }; diff --git a/examples/pages-router-cloudflare/next.config.mjs b/examples/pages-router-cloudflare/next.config.mjs new file mode 100644 index 00000000..69e9cc90 --- /dev/null +++ b/examples/pages-router-cloudflare/next.config.mjs @@ -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, + }, + ]; + }, +}; diff --git a/examples/pages-router-cloudflare/worker/index.ts b/examples/pages-router-cloudflare/worker/index.ts index a9484462..feee5316 100644 --- a/examples/pages-router-cloudflare/worker/index.ts +++ b/examples/pages-router-cloudflare/worker/index.ts @@ -17,6 +17,7 @@ import { requestContextFromRequest, isExternalUrl, proxyExternalRequest, + sanitizeDestination, } from "vinext/config/config-matchers"; import { mergeHeaders } from "vinext/server/worker-utils"; @@ -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) + ? 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 = {}; @@ -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); for (const h of matched) { const lk = h.key.toLowerCase(); if (lk === "set-cookie") { @@ -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); if (rewritten) { if (isExternalUrl(rewritten)) { return proxyExternalRequest(request, rewritten); @@ -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); @@ -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); @@ -240,4 +244,3 @@ export default { } }, }; - diff --git a/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts index 8fa6dc7a..3101716d 100644 --- a/packages/vinext/src/deploy.ts +++ b/packages/vinext/src/deploy.ts @@ -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 = {}; let middlewareRewriteStatus: number | undefined; @@ -680,9 +699,11 @@ 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 @@ -690,7 +711,7 @@ export default { // 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") { @@ -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); diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index cf7526d6..57a07f1b 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -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) { + // _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; + const responseHeaders = new Headers(response.headers); + if (_mwCtx.headers) { + for (const [key, value] of _mwCtx.headers) { + responseHeaders.append(key, value); + } + } + 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 @@ -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 @@ -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); @@ -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 diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 215100e0..da966df5 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -2019,6 +2019,44 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } } + // When @cloudflare/vite-plugin is present, delegate the entire + // Pages Router request pipeline to the Worker/miniflare side. + // That keeps middleware, headers, redirects, rewrites, API + // routes, and rendering in one place instead of mutating the + // host request and forwarding post-middleware state downstream. + if (hasCloudflarePlugin) return next(); + + // Snapshot of req.headers before middleware runs. Used for both + // preMiddlewareReqCtx and the middleware Request itself. Intentionally + // captured once here — applyRequestHeadersToNodeRequest() mutates + // req.headers later, but by then this Headers object is no longer read. + const nodeRequestHeaders = new Headers( + Object.fromEntries( + Object.entries(req.headers) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : String(v)]), + ), + ); + + const requestOrigin = `http://${req.headers.host || "localhost"}`; + const preMiddlewareReqUrl = new URL(url, requestOrigin); + const preMiddlewareReqCtx: RequestContext = requestContextFromRequest( + new Request(preMiddlewareReqUrl, { headers: nodeRequestHeaders }), + ); + + // Config redirects run before middleware, but still match against + // the original normalized pathname and request headers/cookies. + if (nextConfig?.redirects.length) { + const redirected = applyRedirects( + pathname, + res, + nextConfig.redirects, + preMiddlewareReqCtx, + nextConfig.basePath ?? "", + ); + if (redirected) return; + } + const applyRequestHeadersToNodeRequest = (nextRequestHeaders: Headers) => { for (const key of Object.keys(req.headers)) { delete req.headers[key]; @@ -2045,11 +2083,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const origin = `${mwProto}://${req.headers.host || "localhost"}`; const middlewareRequest = new Request(new URL(url, origin), { method: req.method, - headers: Object.fromEntries( - Object.entries(req.headers) - .filter(([, v]) => v !== undefined) - .map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : String(v)]), - ), + headers: nodeRequestHeaders, }); const result = await runMiddleware( getPagesRunner(), @@ -2138,60 +2172,22 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } } - // ── Cloudflare Workers dev mode ──────────────────────────── - // When @cloudflare/vite-plugin is present, ALL rendering runs - // inside the miniflare Worker subprocess — both App Router (via - // virtual:vinext-rsc-entry) and Pages Router (via - // virtual:vinext-server-entry → renderPage/handleApiRoute). - // - // The Worker entry already handles config redirects, rewrites, - // headers, and all routing internally. Running them here too - // would duplicate that logic and produce incorrect behaviour - // (double redirects, headers set on the wrong object, etc.). - // - // Middleware.ts is the only thing that belongs in the host connect - // handler — it has already run above. Any terminal middleware - // result (redirect, block response) has already been sent. - // Any rewrite has been written back to req.url above so the - // Cloudflare plugin's handler sees the correct path. - // - // Call next() to hand off to the Cloudflare plugin's connect - // handler, which dispatches the request to miniflare. - if (hasCloudflarePlugin) return next(); - // Build request context once for has/missing condition checks - // across headers, redirects, and rewrites. + // for config rules that execute after middleware (rewrites). // Convert Node.js IncomingMessage headers to a Web Request for // requestContextFromRequest(), which uses the standard Web API. - const reqUrl = new URL(url, `http://${req.headers.host || "localhost"}`); - const reqCtxHeaders = - middlewareRequestHeaders ?? - new Headers( - Object.fromEntries( - Object.entries(req.headers) - .filter(([, v]) => v !== undefined) - .map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : String(v)]), - ), - ); + const reqUrl = new URL(url, requestOrigin); + const reqCtxHeaders = middlewareRequestHeaders ?? nodeRequestHeaders; const reqCtx: RequestContext = requestContextFromRequest( new Request(reqUrl, { headers: reqCtxHeaders }), ); // Apply custom headers from next.config.js + // Header matching still uses the original normalized pathname and + // pre-middleware request state; middleware response headers win + // later because they are already on the outgoing response. if (nextConfig?.headers.length) { - applyHeaders(pathname, res, nextConfig.headers, reqCtx); - } - - // Apply redirects from next.config.js - if (nextConfig?.redirects.length) { - const redirected = applyRedirects( - pathname, - res, - nextConfig.redirects, - reqCtx, - nextConfig.basePath ?? "", - ); - if (redirected) return; + applyHeaders(pathname, res, nextConfig.headers, preMiddlewareReqCtx); } // Apply rewrites from next.config.js (beforeFiles) diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index b13dee14..99cc496d 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -833,13 +833,35 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { duplex: hasBody ? "half" : undefined, }); - // Build request context for has/missing condition matching. - // headers and redirects run before middleware and use this pre-middleware - // snapshot. beforeFiles, afterFiles, and fallback all run after middleware - // per the Next.js execution order, so they use postMwReqCtx below. + // 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 all run after middleware per the + // Next.js execution order, so they use postMwReqCtx below. const reqCtx: RequestContext = requestContextFromRequest(webRequest); - // ── 4. Run middleware ───────────────────────────────────────── + // ── 4. Apply redirects from next.config.js ──────────────────── + if (configRedirects.length) { + const redirect = matchRedirect(pathname, configRedirects, reqCtx); + if (redirect) { + // Guard against double-prefixing: only add basePath if destination + // doesn't already start with it. + // Sanitize the final destination to prevent protocol-relative URL open redirects. + const dest = sanitizeDestination( + basePath && + !isExternalUrl(redirect.destination) && + !hasBasePath(redirect.destination, basePath) + ? basePath + redirect.destination + : redirect.destination, + ); + res.writeHead(redirect.permanent ? 308 : 307, { Location: dest }); + res.end(); + return; + } + } + + // ── 5. Run middleware ───────────────────────────────────────── let resolvedUrl = url; const middlewareHeaders: Record = {}; let middlewareRewriteStatus: number | undefined; @@ -924,16 +946,18 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { ); webRequest = 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]; - // ── 5. Apply custom headers from next.config.js ─────────────── + // ── 6. Apply custom headers from next.config.js ─────────────── // Config headers are additive for multi-value headers (Vary, // Set-Cookie) and override for everything else. Set-Cookie values // are stored 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") { @@ -955,26 +979,6 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { } } - // ── 6. Apply redirects from next.config.js ──────────────────── - if (configRedirects.length) { - const redirect = matchRedirect(resolvedPathname, configRedirects, reqCtx); - if (redirect) { - // Guard against double-prefixing: only add basePath if destination - // doesn't already start with it. - // Sanitize the final destination to prevent protocol-relative URL open redirects. - const dest = sanitizeDestination( - basePath && - !isExternalUrl(redirect.destination) && - !hasBasePath(redirect.destination, basePath) - ? basePath + redirect.destination - : redirect.destination, - ); - res.writeHead(redirect.permanent ? 308 : 307, { Location: dest }); - res.end(); - return; - } - } - // ── 7. Apply beforeFiles rewrites from next.config.js ───────── if (configRewrites.beforeFiles?.length) { const rewritten = matchRewrite(resolvedPathname, configRewrites.beforeFiles, postMwReqCtx); diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 40ce67c8..b669f7f2 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -2096,16 +2096,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) { + // _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; + const responseHeaders = new Headers(response.headers); + if (_mwCtx.headers) { + for (const [key, value] of _mwCtx.headers) { + responseHeaders.append(key, value); + } + } + 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 @@ -2149,28 +2172,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 @@ -2182,16 +2205,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); @@ -2204,17 +2227,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 @@ -4823,16 +4846,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) { + // _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; + const responseHeaders = new Headers(response.headers); + if (_mwCtx.headers) { + for (const [key, value] of _mwCtx.headers) { + responseHeaders.append(key, value); + } + } + 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 @@ -4876,28 +4922,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 @@ -4909,16 +4955,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); @@ -4931,17 +4977,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 @@ -7577,16 +7623,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) { + // _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; + const responseHeaders = new Headers(response.headers); + if (_mwCtx.headers) { + for (const [key, value] of _mwCtx.headers) { + responseHeaders.append(key, value); + } + } + 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 @@ -7630,28 +7699,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 @@ -7663,16 +7732,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); @@ -7685,17 +7754,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 @@ -10341,16 +10410,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) { + // _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; + const responseHeaders = new Headers(response.headers); + if (_mwCtx.headers) { + for (const [key, value] of _mwCtx.headers) { + responseHeaders.append(key, value); + } + } + 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 @@ -10394,28 +10486,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 @@ -10427,16 +10519,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); @@ -10449,17 +10541,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 @@ -13072,16 +13164,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) { + // _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; + const responseHeaders = new Headers(response.headers); + if (_mwCtx.headers) { + for (const [key, value] of _mwCtx.headers) { + responseHeaders.append(key, value); + } + } + 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 @@ -13125,28 +13240,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 @@ -13158,16 +13273,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); @@ -13180,17 +13295,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 @@ -16074,16 +16189,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) { + // _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; + const responseHeaders = new Headers(response.headers); + if (_mwCtx.headers) { + for (const [key, value] of _mwCtx.headers) { + responseHeaders.append(key, value); + } + } + 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 @@ -16127,28 +16265,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 @@ -16160,16 +16298,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); @@ -16182,17 +16320,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 @@ -17614,7 +17752,7 @@ const i18nConfig = null; const buildId = "test-build-id"; // Full resolved config for production server (embedded at build time) -export const vinextConfig = {"basePath":"","trailingSlash":false,"redirects":[{"source":"/old-about","destination":"/about","permanent":true},{"source":"/repeat-redirect/:id","destination":"/docs/:id/:id","permanent":false}],"rewrites":{"beforeFiles":[{"source":"/before-rewrite","destination":"/about"},{"source":"/repeat-rewrite/:id","destination":"/docs/:id/:id"},{"source":"/mw-gated-before","has":[{"type":"cookie","key":"mw-before-user"}],"destination":"/about"}],"afterFiles":[{"source":"/after-rewrite","destination":"/about"},{"source":"/mw-gated-rewrite","has":[{"type":"cookie","key":"mw-user"}],"destination":"/about"}],"fallback":[{"source":"/fallback-rewrite","destination":"/about"}]},"headers":[{"source":"/api/(.*)","headers":[{"key":"X-Custom-Header","value":"vinext"}]},{"source":"/about","has":[{"type":"cookie","key":"logged-in"}],"headers":[{"key":"X-Auth-Only-Header","value":"1"}]},{"source":"/about","missing":[{"type":"cookie","key":"logged-in"}],"headers":[{"key":"X-Guest-Only-Header","value":"1"}]},{"source":"/ssr","headers":[{"key":"Vary","value":"Accept-Language"}]}],"i18n":null,"images":{}}; +export const vinextConfig = {"basePath":"","trailingSlash":false,"redirects":[{"source":"/old-about","destination":"/about","permanent":true},{"source":"/repeat-redirect/:id","destination":"/docs/:id/:id","permanent":false},{"source":"/redirect-before-middleware-rewrite","destination":"/about","permanent":false},{"source":"/redirect-before-middleware-response","destination":"/about","permanent":false}],"rewrites":{"beforeFiles":[{"source":"/before-rewrite","destination":"/about"},{"source":"/repeat-rewrite/:id","destination":"/docs/:id/:id"},{"source":"/mw-gated-before","has":[{"type":"cookie","key":"mw-before-user"}],"destination":"/about"}],"afterFiles":[{"source":"/after-rewrite","destination":"/about"},{"source":"/mw-gated-rewrite","has":[{"type":"cookie","key":"mw-user"}],"destination":"/about"}],"fallback":[{"source":"/fallback-rewrite","destination":"/about"}]},"headers":[{"source":"/api/(.*)","headers":[{"key":"X-Custom-Header","value":"vinext"}]},{"source":"/about","has":[{"type":"cookie","key":"logged-in"}],"headers":[{"key":"X-Auth-Only-Header","value":"1"}]},{"source":"/about","missing":[{"type":"cookie","key":"logged-in"}],"headers":[{"key":"X-Guest-Only-Header","value":"1"}]},{"source":"/ssr","headers":[{"key":"Vary","value":"Accept-Language"}]},{"source":"/headers-before-middleware-rewrite","headers":[{"key":"X-Rewrite-Source-Header","value":"1"}]}],"i18n":null,"images":{}}; // ISR cache helpers (inlined for the server entry) async function isrGet(key) { diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index ac45f3f9..8cb1ce70 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -482,6 +482,15 @@ describe("generatePagesRouterWorkerEntry", () => { expect(middlewarePos).toBeLessThan(apiRoutePos); }); + it("applies next.config.js redirects before middleware", () => { + const content = generatePagesRouterWorkerEntry(); + const redirectPos = content.indexOf("matchRedirect(pathname, configRedirects, reqCtx)"); + const middlewarePos = content.indexOf("runMiddleware(request, ctx)"); + expect(redirectPos).toBeGreaterThan(-1); + expect(middlewarePos).toBeGreaterThan(-1); + expect(redirectPos).toBeLessThan(middlewarePos); + }); + it("handles middleware redirects", () => { const content = generatePagesRouterWorkerEntry(); expect(content).toContain("result.redirectUrl"); @@ -514,7 +523,7 @@ describe("generatePagesRouterWorkerEntry", () => { it("applies next.config.js redirects", () => { const content = generatePagesRouterWorkerEntry(); expect(content).toContain("configRedirects"); - expect(content).toContain("matchRedirect(resolvedPathname"); + expect(content).toContain("matchRedirect(pathname"); }); it("applies next.config.js rewrites (beforeFiles, afterFiles, fallback)", () => { @@ -528,7 +537,7 @@ describe("generatePagesRouterWorkerEntry", () => { it("applies next.config.js custom headers", () => { const content = generatePagesRouterWorkerEntry(); expect(content).toContain("configHeaders"); - expect(content).toContain("matchHeaders(resolvedPathname"); + expect(content).toContain("matchHeaders(pathname"); }); it("handles basePath stripping and creates a new request with stripped URL for middleware", () => { diff --git a/tests/e2e/cloudflare-pages-router-dev/pages-router.spec.ts b/tests/e2e/cloudflare-pages-router-dev/pages-router.spec.ts index 7ed3b828..0279e7ec 100644 --- a/tests/e2e/cloudflare-pages-router-dev/pages-router.spec.ts +++ b/tests/e2e/cloudflare-pages-router-dev/pages-router.spec.ts @@ -22,4 +22,31 @@ test.describe("Pages Router on Cloudflare Workers (vite dev)", () => { const html = await res.text(); expect(html).toContain("Cloudflare-Workers"); }); + + test("config headers still match the pre-middleware pathname after a rewrite", async ({ + request, + }) => { + const res = await request.get(`${BASE}/headers-before-middleware-rewrite`); + expect(res.status()).toBe(200); + expect(res.headers()["x-rewrite-source-header"]).toBe("1"); + + const html = await res.text(); + expect(html).toContain("Server-Side Rendered on Workers"); + }); + + test("config redirects still win before middleware responses", async ({ request }) => { + const res = await request.get(`${BASE}/redirect-before-middleware-response`, { + maxRedirects: 0, + }); + expect(res.status()).toBe(307); + expect(res.headers()["location"]).toContain("/about"); + }); + + test("config redirects still win before middleware rewrites", async ({ request }) => { + const res = await request.get(`${BASE}/redirect-before-middleware-rewrite`, { + maxRedirects: 0, + }); + expect(res.status()).toBe(307); + expect(res.headers()["location"]).toContain("/about"); + }); }); diff --git a/tests/fixtures/pages-basic/middleware.ts b/tests/fixtures/pages-basic/middleware.ts index 67541d75..d89d0418 100644 --- a/tests/fixtures/pages-basic/middleware.ts +++ b/tests/fixtures/pages-basic/middleware.ts @@ -26,6 +26,18 @@ export function middleware(request: NextRequest) { return NextResponse.rewrite(new URL("/ssr", request.url)); } + if (url.pathname === "/headers-before-middleware-rewrite") { + return NextResponse.rewrite(new URL("/ssr", request.url)); + } + + if (url.pathname === "/redirect-before-middleware-rewrite") { + return NextResponse.redirect(new URL("/ssr", request.url)); + } + + if (url.pathname === "/redirect-before-middleware-response") { + return new Response("middleware should not win", { status: 418 }); + } + // Block /blocked with a custom response if (url.pathname === "/blocked") { return new Response("Access Denied", { status: 403 }); diff --git a/tests/fixtures/pages-basic/next.config.mjs b/tests/fixtures/pages-basic/next.config.mjs index df5773ff..534536c9 100644 --- a/tests/fixtures/pages-basic/next.config.mjs +++ b/tests/fixtures/pages-basic/next.config.mjs @@ -16,6 +16,16 @@ const nextConfig = { destination: "/docs/:id/:id", permanent: false, }, + { + source: "/redirect-before-middleware-rewrite", + destination: "/about", + permanent: false, + }, + { + source: "/redirect-before-middleware-response", + destination: "/about", + permanent: false, + }, ]; }, async rewrites() { @@ -90,6 +100,10 @@ const nextConfig = { source: "/ssr", headers: [{ key: "Vary", value: "Accept-Language" }], }, + { + source: "/headers-before-middleware-rewrite", + headers: [{ key: "X-Rewrite-Source-Header", value: "1" }], + }, ]; }, }; diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index c3949321..f6ee5c74 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -253,6 +253,45 @@ describe("Pages Router integration", () => { expect(res.headers.get("location")).toBe("/about"); }); + // Ported from Next.js: + // test/e2e/app-dir/rewrites-redirects/rewrites-redirects.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/rewrites-redirects/rewrites-redirects.test.ts + // and + // test/e2e/middleware-rewrites/test/index.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-rewrites/test/index.test.ts + it("applies next.config.js headers using the pre-middleware pathname after a rewrite in dev", async () => { + const res = await fetch(`${baseUrl}/headers-before-middleware-rewrite`); + expect(res.status).toBe(200); + expect(res.headers.get("x-rewrite-source-header")).toBe("1"); + const html = await res.text(); + expect(html).toContain("Server-Side Rendered"); + }); + + // Ported from Next.js: + // test/e2e/app-dir/rewrites-redirects/rewrites-redirects.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/rewrites-redirects/rewrites-redirects.test.ts + // and + // test/e2e/middleware-rewrites/test/index.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-rewrites/test/index.test.ts + it("applies next.config.js redirects before middleware rewrites in dev", async () => { + const res = await fetch(`${baseUrl}/redirect-before-middleware-rewrite`, { + redirect: "manual", + }); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toContain("/about"); + }); + + // Ported from Next.js: + // test/e2e/app-dir/rewrites-redirects/rewrites-redirects.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/rewrites-redirects/rewrites-redirects.test.ts + it("applies next.config.js redirects before middleware responses in dev", async () => { + const res = await fetch(`${baseUrl}/redirect-before-middleware-response`, { + redirect: "manual", + }); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toContain("/about"); + }); + it("applies redirects with repeated dynamic params in the destination", async () => { const res = await fetch(`${baseUrl}/repeat-redirect/hello`, { redirect: "manual" }); expect(res.status).toBe(307); @@ -1725,6 +1764,45 @@ describe("Production server middleware (Pages Router)", () => { expect(html).toContain("Server-Side Rendered"); }); + // Ported from Next.js: + // test/e2e/app-dir/rewrites-redirects/rewrites-redirects.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/rewrites-redirects/rewrites-redirects.test.ts + // and + // test/e2e/middleware-rewrites/test/index.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-rewrites/test/index.test.ts + it("applies next.config.js headers using the pre-middleware pathname after a rewrite", async () => { + const res = await fetch(`${prodUrl}/headers-before-middleware-rewrite`); + expect(res.status).toBe(200); + expect(res.headers.get("x-rewrite-source-header")).toBe("1"); + const html = await res.text(); + expect(html).toContain("Server-Side Rendered"); + }); + + // Ported from Next.js: + // test/e2e/app-dir/rewrites-redirects/rewrites-redirects.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/rewrites-redirects/rewrites-redirects.test.ts + // and + // test/e2e/middleware-rewrites/test/index.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-rewrites/test/index.test.ts + it("applies next.config.js redirects before middleware rewrites in production", async () => { + const res = await fetch(`${prodUrl}/redirect-before-middleware-rewrite`, { + redirect: "manual", + }); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toContain("/about"); + }); + + // Ported from Next.js: + // test/e2e/app-dir/rewrites-redirects/rewrites-redirects.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/rewrites-redirects/rewrites-redirects.test.ts + it("applies next.config.js redirects before middleware responses in production", async () => { + const res = await fetch(`${prodUrl}/redirect-before-middleware-response`, { + redirect: "manual", + }); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toContain("/about"); + }); + it("blocks /blocked with 403 via middleware", async () => { const res = await fetch(`${prodUrl}/blocked`); expect(res.status).toBe(403);