Files
cloudflare__vinext/tests/middleware-runtime.test.ts
T
James Anderson 24a1bd4edc fix(middleware): redirect protocol — relative Location and x-nextjs-redirect (#1377)
* fix(middleware): redirect protocol — relative Location and x-nextjs-redirect

Match Next.js's edge adapter behaviour for middleware redirects:

1. Relativize the `Location` header for same-host redirects. Next.js only
   emits absolute URLs when the redirect target is cross-origin; vinext
   was always emitting `http://host/path` for same-host targets, which
   broke deploy-suite parity tests.

2. Translate redirects to the `x-nextjs-redirect` soft-redirect protocol
   when the request carries `x-nextjs-data: 1`. The client router uses
   this header instead of a raw HTTP 3xx to avoid CORS issues on
   cross-origin data fetches.

Both fixes live in `executeMiddleware`, so all four call sites
(prod-server, dev plugin, deploy worker, app-middleware) inherit them.

Refs #1334

* fix(middleware): thread isDataRequest through all callers

Address Bonk review. The previous commit checked `x-nextjs-data` on the
middleware request, but that header is in INTERNAL_HEADERS and is
stripped by `filterInternalHeaders` before any caller constructs the
middleware request -- so the soft-redirect branch was unreachable.

Make `isDataRequest` an explicit option on `ExecuteMiddlewareOptions`
and `ApplyAppMiddlewareOptions`, and capture it from the raw incoming
headers in each of the four entry points (dev plugin, prod server,
deploy worker, App Router RSC handler) before filtering. The generated
Pages Router runMiddleware now accepts a third `options` argument
carrying the flag through to the runtime helper.

Also: drop redundant lowercase `headers.delete("location")` -- the
Fetch spec makes Headers.delete case-insensitive.

Add a regression test ensuring a forged `x-nextjs-data: 1` request
header cannot trigger the soft-redirect path without the caller
explicitly opting in.

Refs #1334
2026-05-21 11:34:52 +01:00

165 lines
6.0 KiB
TypeScript

import { describe, expect, it } from "vite-plus/test";
import { executeMiddleware } from "../packages/vinext/src/server/middleware-runtime.js";
// Tests for the redirect protocol implemented in `executeMiddleware`. These
// fixtures mirror the behaviour Next.js's edge adapter applies after a
// middleware returns a redirect Response:
// - Same-host Location headers are made relative.
// - When the original request carries `x-nextjs-data: 1`, the redirect is
// translated into a 200 response with `x-nextjs-redirect`.
// Reference: packages/next/src/server/web/adapter.ts (canary)
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/web/adapter.ts
describe("middleware redirect protocol", () => {
it("relativizes the Location header for same-host redirects", async () => {
const module = {
default: (req: Request) => {
const target = new URL("/another", req.url);
return Response.redirect(target.toString(), 302);
},
};
const result = await executeMiddleware({
isProxy: false,
module,
request: new Request("http://127.0.0.1:39063/to?pathname=/another"),
});
expect(result.continue).toBe(false);
expect(result.redirectUrl).toBe("/another");
expect(result.redirectStatus).toBe(302);
expect(result.response?.headers.get("Location")).toBe("/another");
});
it("preserves the search string when relativizing the Location header", async () => {
const module = {
default: (req: Request) => {
const target = new URL("/another?foo=bar", req.url);
return Response.redirect(target.toString(), 307);
},
};
const result = await executeMiddleware({
isProxy: false,
module,
request: new Request("http://localhost:3000/start"),
});
expect(result.redirectUrl).toBe("/another?foo=bar");
expect(result.response?.headers.get("Location")).toBe("/another?foo=bar");
});
it("preserves the hash fragment when relativizing the Location header", async () => {
const module = {
default: (req: Request) =>
Response.redirect(new URL("/new-home#fragment", req.url).toString(), 307),
};
const result = await executeMiddleware({
isProxy: false,
module,
request: new Request("http://localhost:3000/with-fragment"),
});
expect(result.redirectUrl).toBe("/new-home#fragment");
});
it("leaves cross-origin Location headers absolute", async () => {
const module = {
default: () => Response.redirect("https://example.vercel.sh/", 307),
};
const result = await executeMiddleware({
isProxy: false,
module,
request: new Request("http://127.0.0.1:39063/old-home?override=external"),
});
expect(result.continue).toBe(false);
expect(result.redirectUrl).toBe("https://example.vercel.sh/");
expect(result.response?.headers.get("Location")).toBe("https://example.vercel.sh/");
});
it("translates same-host redirects to x-nextjs-redirect for data requests", async () => {
const module = {
default: (req: Request) => Response.redirect(new URL("/new-home", req.url).toString(), 307),
};
const result = await executeMiddleware({
isProxy: false,
module,
isDataRequest: true,
request: new Request("http://localhost:3000/old-home"),
});
// The protocol: 200 response, no Location, x-nextjs-redirect header set.
expect(result.continue).toBe(false);
expect(result.response).toBeDefined();
expect(result.response?.status).toBe(200);
expect(result.response?.headers.get("x-nextjs-redirect")).toBe("/new-home");
expect(result.response?.headers.get("Location")).toBeNull();
// No HTTP redirect should be surfaced to upstream callers.
expect(result.redirectUrl).toBeUndefined();
expect(result.redirectStatus).toBeUndefined();
});
it("translates external redirects to x-nextjs-redirect for data requests", async () => {
const module = {
default: () => Response.redirect("https://example.vercel.sh/", 307),
};
const result = await executeMiddleware({
isDataRequest: true,
isProxy: false,
module,
request: new Request("http://localhost:3000/old-home?override=external"),
});
expect(result.continue).toBe(false);
expect(result.response?.status).toBe(200);
expect(result.response?.headers.get("x-nextjs-redirect")).toBe("https://example.vercel.sh/");
expect(result.response?.headers.get("Location")).toBeNull();
expect(result.redirectUrl).toBeUndefined();
});
it("ignores a forged x-nextjs-data header when the caller did not opt in", async () => {
// `x-nextjs-data` is in INTERNAL_HEADERS and gets stripped by the caller
// before this function runs. The soft-redirect protocol is gated on the
// explicit `isDataRequest` flag rather than the header on the request, so
// forged headers can never reach the redirect translator.
const module = {
default: (req: Request) => Response.redirect(new URL("/new-home", req.url).toString(), 307),
};
const result = await executeMiddleware({
isProxy: false,
module,
// The flag is intentionally NOT set — only the (forged) header is.
request: new Request("http://localhost:3000/old-home", {
headers: { "x-nextjs-data": "1" },
}),
});
expect(result.redirectUrl).toBe("/new-home");
expect(result.response?.status).toBe(307);
expect(result.response?.headers.get("x-nextjs-redirect")).toBeNull();
});
it("does not translate redirects to x-nextjs-redirect when x-nextjs-data is absent", async () => {
const module = {
default: (req: Request) => Response.redirect(new URL("/new-home", req.url).toString(), 307),
};
const result = await executeMiddleware({
isProxy: false,
module,
request: new Request("http://localhost:3000/old-home"),
});
expect(result.continue).toBe(false);
expect(result.redirectUrl).toBe("/new-home");
expect(result.response?.status).toBe(307);
expect(result.response?.headers.get("x-nextjs-redirect")).toBeNull();
});
});