import { headers as nextHeaders } from "next/headers";
import { NextRequest, NextResponse, NextFetchEvent } from "next/server";
import { recordMiddlewareInvocation } from "./instrumentation-state";
/**
* App Router middleware that uses NextRequest-specific APIs.
* This tests that the middleware receives a NextRequest (not a plain Request).
*
* Also covers OpenNext compat tests (ON-11):
* - Redirect with cookie setting
* - Rewrite (URL stays, content from another page)
* - Rewrite with custom status code
* - Block with 403
* - Search params forwarding
*/
export async function middleware(request: NextRequest, event: NextFetchEvent) {
// Test NextRequest.nextUrl - this would fail with TypeError if request is plain Request
const { pathname } = request.nextUrl;
// Ported from Next.js: test/e2e/middleware-general/app/middleware-node.js
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-general/app/middleware-node.js
if (pathname === "/_next/static/middleware-rewrite.js") {
return new Response("rewritten missing asset", {
headers: { "content-type": "text/plain" },
});
}
// Record this invocation so tests can detect double-execution.
// In a hybrid app+pages fixture the Vite connect handler runs middleware
// via ssrLoadModule (SSR env) and then the RSC entry runs it again inline
// (RSC env). A single request should produce exactly one invocation.
recordMiddlewareInvocation(pathname);
// Regression for cloudflare/vinext#1480: a node-runtime middleware that
// reads the request body on POST (auth, logging, body-size accounting, …)
// and then falls through must NOT prevent the downstream server-action POST
// from being intercepted and reading its own body. We consume the body here
// exactly as Next.js' `middleware-node.js` fixture does, then `.next()`.
if (pathname === "/nextjs-compat/action-node-mw" && request.method === "POST") {
await request.text();
return NextResponse.next();
}
// Test NextRequest.cookies - this would fail with TypeError if request is plain Request
const sessionToken = request.cookies.get("session");
const acceptsRsc = request.headers.get("accept")?.startsWith("text/x-component") ?? false;
if (acceptsRsc && pathname === "/rsc-fetch-redirect-src") {
return NextResponse.redirect(new URL("/rsc-fetch-error-target.rsc", request.url), 307);
}
if (acceptsRsc && pathname === "/rsc-fetch-error-target") {
return new Response("
Internal Server Error
", {
status: 500,
headers: { "content-type": "text/html" },
});
}
const response = NextResponse.next();
// Add headers to prove middleware ran and NextRequest APIs worked
response.headers.set("x-mw-pathname", pathname);
response.headers.set("x-mw-ran", "true");
if (sessionToken) {
response.headers.set("x-mw-has-session", "true");
}
// Redirect /middleware-redirect to /about (with cookie, like OpenNext)
// Ref: opennextjs-cloudflare middleware.ts — redirect with set-cookie header
if (pathname === "/middleware-redirect") {
return NextResponse.redirect(new URL("/about", request.url), {
headers: { "set-cookie": "middleware-redirect=success; Path=/" },
});
}
// Rewrite /middleware-rewrite to render / content (URL stays the same)
// Ref: opennextjs-cloudflare middleware.ts — NextResponse.rewrite
if (pathname === "/middleware-rewrite") {
return NextResponse.rewrite(new URL("/", request.url));
}
// Used by Vitest: nextjs-compat/hooks.test.ts — verifies usePathname()
// returns the CANONICAL URL (the one the user sees) after a middleware
// rewrite, not the internal rewrite target. Mirrors the Next.js test
// semantics for `/rewritten-use-pathname` via a middleware rewrite.
if (pathname === "/middleware-rewritten-use-pathname") {
return NextResponse.rewrite(new URL("/nextjs-compat/hooks-search", request.url));
}
// Ported from Next.js: test/e2e/middleware-rewrites/app/middleware.js
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-rewrites/app/middleware.js
if (pathname === "/middleware-external-rewrite") {
const target =
request.headers.get("x-middleware-test-rewrite-target") ??
process.env.TEST_MIDDLEWARE_EXTERNAL_PROXY_TARGET;
if (target) {
const rewriteTarget = new URL("/middleware-external-target", target);
rewriteTarget.search = request.nextUrl.search;
if (request.headers.get("x-middleware-test-request-override") === "1") {
const headers = new Headers(request.headers);
headers.set("x-hello-from-middleware1", "hello");
headers.set("x-hello-from-middleware2", "world");
return NextResponse.rewrite(rewriteTarget, { request: { headers } });
}
return NextResponse.rewrite(rewriteTarget);
}
}
// Rewrite with query params — the rewrite URL's query string should be
// visible to the target page via searchParams props and useSearchParams().
if (pathname === "/middleware-rewrite-query") {
return NextResponse.rewrite(
new URL("/search-query?searchParams=from-rewrite&extra=injected", request.url),
);
}
// Issue #1342 / Next.js parity: middleware preserves the original request's
// query by mutating `request.nextUrl` (which carries the existing search)
// rather than constructing a fresh path-only URL. Mirrors Next.js:
// test/e2e/middleware-rewrites/app/middleware.js — `url.pathname = '/x'` then
// `NextResponse.rewrite(url)`.
if (pathname === "/middleware-rewrite-keep-original-query") {
const target = request.nextUrl.clone();
target.pathname = "/search-query";
return NextResponse.rewrite(target);
}
// Rewrite with custom status code
// Ref: opennextjs-cloudflare middleware.ts — NextResponse.rewrite with status
if (pathname === "/middleware-rewrite-status") {
return NextResponse.rewrite(new URL("/", request.url), {
status: 403,
});
}
// Action forward loop test: rewrite POSTs from /nextjs-compat/action-forward-loop
// to /nextjs-compat/action-forward-loop-rewrite so the receiving page does not
// bundle the action. Without the x-action-forwarded guard, a multi-worker
// deployment would loop indefinitely. In vinext's single-worker model, the
// guard still fires defensively when the header is injected.
if (pathname === "/nextjs-compat/action-forward-loop" && request.method === "POST") {
return NextResponse.rewrite(new URL("/nextjs-compat/action-forward-loop-rewrite", request.url));
}
// Block /middleware-blocked with custom response
if (pathname === "/middleware-blocked") {
return new Response("Blocked by middleware", { status: 403 });
}
// Throw an error to test that middleware errors return 500, not bypass auth
if (pathname === "/middleware-throw") {
throw new Error("middleware crash");
}
// Test event and event.waitUntil (needed for Clerk etc)
if (pathname === "/middleware-event") {
if (!event || typeof event.waitUntil !== "function") {
return new Response("Missing event.waitUntil", { status: 500 });
}
event.waitUntil(Promise.resolve());
return new Response("Event OK", { status: 200 });
}
if (pathname === "/middleware-fetch-dedupe") {
const target = process.env.TEST_FETCH_DEDUPE_TARGET;
if (!target) {
return Response.json({ error: "missing TEST_FETCH_DEDUPE_TARGET" }, { status: 500 });
}
const first = await fetch(target, { cache: "no-store" });
const second = await fetch(target, { cache: "no-store" });
const firstBody = (await first.json()) as { count: number };
const secondBody = (await second.json()) as { count: number };
return Response.json({ counts: [firstBody.count, secondBody.count] });
}
// Inject mw-before-user=1 cookie for beforeFiles rewrite gating test.
// In App Router order, beforeFiles rewrites run after middleware, so they
// should see this cookie. The /mw-gated-before rule in next.config.ts has:
// [cookie:mw-before-user], which matches when ?mw-auth is present.
if (pathname === "/mw-gated-before" && request.nextUrl.searchParams.has("mw-auth")) {
const headers = new Headers(request.headers);
const existing = headers.get("cookie") ?? "";
headers.set("cookie", existing ? existing + "; mw-before-user=1" : "mw-before-user=1");
return NextResponse.next({ request: { headers } });
}
// Inject mw-fallback-user=1 cookie for fallback rewrite gating test.
// Fallback rewrites run after middleware and after a 404 from route matching.
// The /mw-gated-fallback rule has: [cookie:mw-fallback-user].
if (pathname === "/mw-gated-fallback" && request.nextUrl.searchParams.has("mw-auth")) {
const headers = new Headers(request.headers);
const existing = headers.get("cookie") ?? "";
headers.set("cookie", existing ? existing + "; mw-fallback-user=1" : "mw-fallback-user=1");
return NextResponse.next({ request: { headers } });
}
// Inject mw-pages-fallback-user=1 cookie for mixed app/pages fallback routing.
// This ensures the host dev shell can still match a Pages-targeted fallback
// rewrite in a mixed project after the middleware header ownership changes.
if (pathname === "/mw-gated-fallback-pages" && request.nextUrl.searchParams.has("mw-auth")) {
const headers = new Headers(request.headers);
const existing = headers.get("cookie") ?? "";
headers.set(
"cookie",
existing ? existing + "; mw-pages-fallback-user=1" : "mw-pages-fallback-user=1",
);
return NextResponse.next({ request: { headers } });
}
// Middleware headers take precedence over next.config.js headers for the same key.
// Middleware sets e2e-headers=middleware; config sets e2e-headers=next.config.js via /(.*).
// Ref: opennextjs-cloudflare headers.test.ts — "Middleware headers override next.config.js headers"
if (pathname === "/headers/override-from-middleware") {
const res = NextResponse.next();
res.headers.set("e2e-headers", "middleware");
return res;
}
if (pathname === "/header-override-delete" || pathname === "/api/header-override-delete") {
const headers = new Headers(request.headers);
headers.delete("authorization");
headers.delete("cookie");
headers.set("x-from-middleware", "hello-from-middleware");
return NextResponse.next({ request: { headers } });
}
// Regression for a bug where a middleware that reads `next/headers` →
// `headers()` before returning a `NextResponse.next({ request: { headers } })`
// override leaked the pre-override snapshot into the Server Component.
//
// Discovered with @clerk/nextjs, whose internal `clerkClient()` calls
// `await headers()` via `buildRequestLike()` during middleware execution.
// That call cached the sealed read-only Headers view on the shared
// HeadersContext. Afterwards, `applyMiddlewareRequestHeaders()` replaced
// `ctx.headers` with the override view but never invalidated the cached
// sealed snapshot, so the Server Component's later `headers()` call
// returned the original request headers — `x-from-middleware` was missing
// and deleted credential headers were still visible.
if (pathname === "/header-override-after-prior-access") {
// 1. Prime the sealed Headers cache via an early `headers()` read — this
// is the step that a real-world middleware like Clerk performs under
// the covers.
await nextHeaders();
// 2. Apply the header override. A correct implementation must invalidate
// the cached sealed snapshot so this override reaches the render.
const headers = new Headers(request.headers);
headers.delete("authorization");
headers.delete("cookie");
headers.set("x-from-middleware", "hello-from-middleware");
return NextResponse.next({ request: { headers } });
}
if (pathname === "/pages-header-override-delete") {
const headers = new Headers(request.headers);
headers.delete("authorization");
headers.delete("cookie");
headers.set("x-from-middleware", "hello-from-middleware");
return NextResponse.next({ request: { headers } });
}
// Locale rewrite for interception-dynamic-segment-middleware suite.
// Scoped exclusively to /interception-mw/* to avoid interfering with other tests.
// Mirrors Next.js: test/e2e/app-dir/interception-dynamic-segment-middleware/middleware.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/interception-dynamic-segment-middleware/middleware.ts
if (pathname.startsWith("/interception-mw/")) {
const withoutPrefix = pathname.slice("/interception-mw".length); // → /foo/p/1
const locale = "en";
const hasLocale = withoutPrefix.startsWith(`/${locale}/`) || withoutPrefix === `/${locale}`;
if (!hasLocale) {
const target = request.nextUrl.clone();
target.pathname = `/interception-mw/${locale}${withoutPrefix}`;
return NextResponse.rewrite(target);
}
}
// Forward search params as a header for RSC testing
// Ref: opennextjs-cloudflare middleware.ts — search-params header
const requestHeaders = new Headers(request.headers);
requestHeaders.set(
"x-search-params",
`mw/${request.nextUrl.searchParams.get("searchParams") || ""}`,
);
const r = NextResponse.next({
request: { headers: requestHeaders },
});
if (
pathname.startsWith("/use-client-page-pathname") &&
request.nextUrl.searchParams.has("csp-nonce")
) {
r.headers.set(
"content-security-policy",
"script-src 'nonce-vinext-test-nonce' 'strict-dynamic';",
);
}
if (
pathname.startsWith("/use-client-page-pathname") &&
request.nextUrl.searchParams.has("csp-default-src")
) {
r.headers.set("content-security-policy", "default-src 'nonce-vinext-test-nonce';");
}
if (
pathname.startsWith("/use-client-page-pathname") &&
request.nextUrl.searchParams.has("csp-report-only")
) {
r.headers.set(
"content-security-policy-report-only",
"script-src 'nonce-vinext-test-nonce' 'strict-dynamic';",
);
}
if (pathname === "/script-nonce" || pathname.startsWith("/script-nonce/")) {
r.headers.set(
"content-security-policy",
"script-src 'nonce-vinext-test-nonce' 'strict-dynamic';",
);
}
if (
(pathname === "/revalidate-test" || pathname.startsWith("/beforeinteractive-head-ordering")) &&
request.nextUrl.searchParams.has("csp-nonce")
) {
const nonce = request.nextUrl.searchParams.get("csp-nonce") ?? "vinext-test-nonce";
r.headers.set("content-security-policy", `script-src 'nonce-${nonce}' 'strict-dynamic';`);
}
if (
pathname.startsWith("/nextjs-compat/dynamic") &&
request.nextUrl.searchParams.has("csp-nonce")
) {
r.headers.set(
"content-security-policy",
"script-src 'nonce-vinext-test-nonce' 'strict-dynamic';",
);
}
r.headers.set("x-mw-pathname", pathname);
r.headers.set("x-mw-ran", "true");
if (sessionToken) {
r.headers.set("x-mw-has-session", "true");
}
return r;
}
export const config = {
// Declare the Node.js runtime so the `/nextjs-compat/action-node-mw`
// server-action regression (cloudflare/vinext#1480) faithfully mirrors
// Next.js' `middleware-node.js` fixture (`export const config = { runtime:
// 'nodejs' }`). vinext runs all middleware on Node regardless of this value:
// middleware dispatch reads config only via `middlewareMatcher()` (i.e.
// `config.matcher`) with no `config.runtime` branch — see
// packages/vinext/src/server/middleware-runtime.ts (resolveMiddlewareModuleHandler /
// middlewareMatcher). So this is a behavioural no-op for the other matcher
// entries — but it guards the named scenario and documents intent.
runtime: "nodejs",
matcher: [
"/about",
"/middleware-redirect",
"/middleware-rewrite",
"/middleware-rewritten-use-pathname",
"/middleware-external-rewrite",
"/middleware-rewrite-query",
"/middleware-rewrite-keep-original-query",
"/middleware-rewrite-status",
"/middleware-blocked",
"/middleware-throw",
"/middleware-event",
"/middleware-fetch-dedupe",
"/search-query",
"/headers/override-from-middleware",
"/header-override-delete",
"/api/header-override-delete",
"/api/pages-og",
"/header-override-after-prior-access",
"/pages-header-override-delete",
"/revalidate-test",
"/script-nonce/:path*",
"/script-manual-nonce",
"/pages-script-manual-nonce",
"/nextjs-compat/dynamic/:path*",
"/nextjs-compat/action-forward-loop",
"/nextjs-compat/action-node-mw",
"/use-client-page-pathname/:path*",
"/rsc-fetch-redirect-src",
"/rsc-fetch-error-target",
"/",
"/mw-gated-before",
"/mw-gated-fallback",
"/_next/static/middleware-rewrite.js",
{
source: "/mw-object-gated",
has: [{ type: "header", key: "x-mw-allow", value: "1" }],
missing: [{ type: "cookie", key: "mw-blocked" }],
},
"/mw-gated-fallback-pages",
"/photos/:path*",
"/interception-mw/:path*",
"/actions",
"/beforeinteractive-head-ordering/:path*",
"/beforeinteractive-head-ordering",
],
};