Files
cloudflare__vinext/tests/pages-request-pipeline.test.ts
James Anderson 049f7e3628 refactor(pages): extract request stage (#3146)
* refactor(pages): extract request stage

* fix(pages): update server entry options call

* fix(pages): conservatively classify document mutation

* fix(pages): lazily register middleware data cache

* fix(pages): keep request adapter facade lightweight

* refactor(pages): conservatively bypass custom documents

* fix(pages): recompose shared page headers

* fix(pages): resolve staged entries when activated

* fix(build): finalize extracted Pages server output

* test(cache): prove Pages adapter initialization stays lazy

* test(build): defer staged export assertion
2026-09-07 21:48:25 +01:00

2453 lines
84 KiB
TypeScript

import { describe, it, expect, vi } from "vite-plus/test";
import {
runPagesRequest,
wrapMiddlewareWithBasePath,
type PagesPipelineDeps,
type MiddlewareResult,
type PagesRenderOptions,
} from "../packages/vinext/src/server/pages-request-pipeline.js";
import { MIDDLEWARE_SKIP_HEADER } from "../packages/vinext/src/server/headers.js";
import { PRERENDER_REVALIDATE_HEADER } from "../packages/vinext/src/utils/protocol-headers.js";
import { runWithExecutionContext } from "../packages/vinext/src/shims/request-context.js";
import {
CACHEABILITY_REQUEST_STATE,
type RouteCacheabilityState,
} from "../packages/vinext/src/shims/cacheability-classification.js";
// Helpers
function makeRequest(pathname: string, headers?: Record<string, string>): Request {
return new Request(`http://localhost${pathname}`, { headers });
}
function baseDeps(overrides?: Partial<PagesPipelineDeps>): PagesPipelineDeps {
return {
basePath: "",
trailingSlash: false,
i18nConfig: null,
configRedirects: [],
configRewrites: { beforeFiles: [], afterFiles: [], fallback: [] },
configHeaders: [],
hadBasePath: true,
isDataReq: false,
isDataRequest: false,
hasMiddleware: false,
...overrides,
};
}
function makeMiddleware(result: Partial<MiddlewareResult>) {
return vi.fn(async (_req: Request, _ctx: unknown, _opts: { isDataRequest: boolean }) => ({
continue: true,
...result,
}));
}
function makeRenderPage(status = 200, body = "ok") {
return vi.fn(
async (_req: Request, _url: string, _opts?: PagesRenderOptions) =>
new Response(body, { status }),
);
}
async function cacheabilityReasonFor(
request: Request,
overrides: Partial<PagesPipelineDeps>,
): Promise<string | undefined> {
const state: RouteCacheabilityState = {
captureDeadlineAt: Date.now() + 1_000,
mode: "probe",
};
const context = {
[CACHEABILITY_REQUEST_STATE]: state,
waitUntil() {},
};
await runWithExecutionContext(context, () =>
runPagesRequest(request, baseDeps({ renderPage: makeRenderPage(), ...overrides })),
);
return state.forcedDynamicReason;
}
describe("on-demand revalidation middleware bypass", () => {
it("uses the runtime adapter's authoritative credential verifier", async () => {
const runMiddleware = makeMiddleware({});
const authorizeOnDemandRevalidate = vi.fn((value: string | null) => value === "build-secret");
const request = makeRequest("/revalidate-target", {
[PRERENDER_REVALIDATE_HEADER]: "build-secret",
});
const result = await runPagesRequest(
request,
baseDeps({
authorizeOnDemandRevalidate,
hasMiddleware: true,
renderPage: makeRenderPage(),
runMiddleware,
}),
);
expect(result.type).toBe("response");
expect(authorizeOnDemandRevalidate).toHaveBeenCalledWith("build-secret");
expect(runMiddleware).not.toHaveBeenCalled();
});
it("does not bypass middleware when the authoritative verifier rejects the header", async () => {
const runMiddleware = makeMiddleware({});
const authorizeOnDemandRevalidate = vi.fn(() => false);
const request = makeRequest("/revalidate-target", {
[PRERENDER_REVALIDATE_HEADER]: "forged-secret",
});
await runPagesRequest(
request,
baseDeps({
authorizeOnDemandRevalidate,
hasMiddleware: true,
renderPage: makeRenderPage(),
runMiddleware,
}),
);
expect(runMiddleware).toHaveBeenCalledOnce();
});
});
// 1. Trailing-slash: /foo/ with trailingSlash: false → {type:"response"} with status 308
describe("trailing slash normalization", () => {
it("redirects /foo/ to /foo when trailingSlash is false", async () => {
const req = makeRequest("/foo/");
const result = await runPagesRequest(req, baseDeps({ trailingSlash: false }));
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(308);
expect(result.response.headers.get("Location")).toBe("/foo");
});
it("does not redirect /foo when trailingSlash is false", async () => {
const req = makeRequest("/foo");
const result = await runPagesRequest(req, baseDeps({ renderPage: makeRenderPage() }));
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(200);
});
it("preserves raw encoded spelling in trailing-slash redirects", async () => {
const result = await runPagesRequest(
makeRequest("/%61bout"),
baseDeps({ trailingSlash: true }),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(308);
expect(result.response.headers.get("Location")).toBe("/%61bout/");
});
});
// 2. Config redirect: permanent redirect → status 308 with Location
describe("config redirects", () => {
it("fails probing closed when an unkeyed redirect condition misses", async () => {
expect(
await cacheabilityReasonFor(makeRequest("/conditional"), {
configRedirects: [
{
source: "/conditional",
destination: "/private",
permanent: false,
has: [{ type: "cookie", key: "variant", value: "private" }],
},
],
}),
).toBe("next.config redirect depends on request headers, cookies, or hostnames");
});
it("permanent redirect returns 308", async () => {
const req = makeRequest("/old");
const result = await runPagesRequest(
req,
baseDeps({
configRedirects: [{ source: "/old", destination: "/new", permanent: true }],
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(308);
expect(result.response.headers.get("Location")).toBe("/new");
});
// 3. Config redirect: non-permanent → status 307
it("non-permanent redirect returns 307", async () => {
const req = makeRequest("/old");
const result = await runPagesRequest(
req,
baseDeps({
configRedirects: [{ source: "/old", destination: "/new", permanent: false }],
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(307);
expect(result.response.headers.get("Location")).toBe("/new");
});
it("keeps the real redirect status for trusted data requests", async () => {
// Ported from Next.js: test/e2e/middleware-general/test/index.test.ts
// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/middleware-general/test/index.test.ts
const result = await runPagesRequest(
makeRequest("/old"),
baseDeps({
configRedirects: [{ source: "/old", destination: "/new", permanent: true }],
isDataReq: true,
isDataRequest: true,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(308);
expect(result.response.headers.get("Location")).toBe("/new");
expect(result.response.headers.get("x-nextjs-redirect")).toBeNull();
});
it("does not use the soft redirect protocol for forged data headers", async () => {
const result = await runPagesRequest(
makeRequest("/old", { "x-nextjs-data": "1" }),
baseDeps({
configRedirects: [{ source: "/old", destination: "/new", permanent: true }],
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(308);
expect(result.response.headers.get("Location")).toBe("/new");
expect(result.response.headers.get("x-nextjs-redirect")).toBeNull();
});
it("does not match when source does not match", async () => {
const req = makeRequest("/other");
const result = await runPagesRequest(
req,
baseDeps({
configRedirects: [{ source: "/old", destination: "/new", permanent: false }],
renderPage: makeRenderPage(),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(200);
});
it("uses raw request identity and captures for config redirects", async () => {
const encodedAlias = await runPagesRequest(
makeRequest("/old"),
baseDeps({
configMatchPathname: "/%6Fld",
configRedirects: [{ source: "/old", destination: "/new", permanent: false }],
renderPage: makeRenderPage(),
}),
);
expect(encodedAlias.type).toBe("response");
if (encodedAlias.type !== "response") return;
expect(encodedAlias.response.status).toBe(200);
expect(encodedAlias.response.headers.get("Location")).toBeNull();
const rawCapture = await runPagesRequest(
makeRequest("/repeat/a%2Fb"),
baseDeps({
configMatchPathname: "/repeat/a%252Fb",
configRedirects: [
{
source: "/repeat/:id",
destination: "/target/:id/:id",
permanent: false,
},
],
}),
);
expect(rawCapture.type).toBe("response");
if (rawCapture.type !== "response") return;
expect(rawCapture.response.headers.get("Location")).toBe("/target/a%252Fb/a%252Fb");
});
});
// 4. Middleware redirect short-circuit → {type:"response"} status 307
describe("middleware", () => {
it("fails cacheability closed for a middleware-eligible pathname", async () => {
const state: RouteCacheabilityState = {
captureDeadlineAt: Date.now() + 1_000,
mode: "admit",
};
const context = {
[CACHEABILITY_REQUEST_STATE]: state,
waitUntil() {},
};
await runWithExecutionContext(context, () =>
runPagesRequest(
makeRequest("/conditional"),
baseDeps({
hasMiddleware: true,
renderPage: makeRenderPage(),
runMiddleware: makeMiddleware({ continue: true, pathnameEligible: true }),
}),
),
);
expect(state.forcedDynamicReason).toBe("middleware can match this pathname");
});
it("can present the raw data URL to middleware while routing the normalized page", async () => {
// Ported from Next.js: packages/next/src/server/next-server.ts
// (`skipProxyUrlNormalize` selects request meta `initURL` for middleware).
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/next-server.ts
const runMiddleware = makeMiddleware({ continue: true });
const renderPage = makeRenderPage();
await runPagesRequest(
makeRequest("/journal?x=1"),
baseDeps({
isDataReq: true,
isDataRequest: true,
middlewareRequest: makeRequest("/_next/data/build-id/journal.json?x=1"),
runMiddleware,
matchPageRoute: vi.fn().mockReturnValue({ route: { isDynamic: false } }),
renderPage,
}),
);
expect(runMiddleware.mock.calls[0]?.[0].url).toBe(
"http://localhost/_next/data/build-id/journal.json?x=1",
);
expect(renderPage.mock.calls[0]?.[1]).toBe("/journal?x=1");
});
it("lets raw data middleware intercept a stale build ID before the JSON 404", async () => {
const runMiddleware = makeMiddleware({
continue: false,
response: Response.json({ hardNavTo: "/journal?x=1" }),
});
const result = await runPagesRequest(
makeRequest("/_next/data/stale/journal.json?x=1"),
baseDeps({
dataNotFoundResponse: new Response("{}", { status: 404 }),
isDataReq: true,
isDataRequest: true,
runMiddleware,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(200);
await expect(result.response.json()).resolves.toEqual({ hardNavTo: "/journal?x=1" });
});
it("returns the deferred data 404 when raw middleware continues", async () => {
const result = await runPagesRequest(
makeRequest("/_next/data/stale/journal.json"),
baseDeps({
dataNotFoundResponse: new Response("{}", { status: 404 }),
isDataReq: true,
isDataRequest: true,
runMiddleware: makeMiddleware({
continue: true,
responseHeaders: [["x-middleware-seen", "1"]],
}),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(404);
expect(result.response.headers.get("x-middleware-seen")).toBe("1");
});
it("adds the final matched path to rewritten data responses", async () => {
const result = await runPagesRequest(
makeRequest("/ssr-page"),
baseDeps({
isDataReq: true,
isDataRequest: true,
runMiddleware: makeMiddleware({ rewriteUrl: "/ssr-page-2" }),
matchPageRoute: vi
.fn()
.mockReturnValue({ route: { isDynamic: false, pattern: "/ssr-page-2" } }),
renderPage: makeRenderPage(),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.headers.get("x-nextjs-rewrite")).toBe("/ssr-page-2");
expect(result.response.headers.get("x-nextjs-matched-path")).toBe("/ssr-page-2");
});
it("locale-prefixes the final matched path on i18n data responses", async () => {
const result = await runPagesRequest(
makeRequest("/ssr-page"),
baseDeps({
i18nConfig: { locales: ["en", "fr"], defaultLocale: "en" },
isDataReq: true,
isDataRequest: true,
runMiddleware: makeMiddleware({ rewriteUrl: "/ssr-page-2" }),
matchPageRoute: vi
.fn()
.mockReturnValue({ route: { isDynamic: false, pattern: "/ssr-page-2" } }),
renderPage: makeRenderPage(),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.headers.get("x-nextjs-matched-path")).toBe("/en/ssr-page-2");
});
it("preserves an explicit locale on dynamic data responses", async () => {
const result = await runPagesRequest(
makeRequest("/fr/source"),
baseDeps({
i18nConfig: { locales: ["en", "fr"], defaultLocale: "en" },
isDataReq: true,
isDataRequest: true,
runMiddleware: makeMiddleware({ rewriteUrl: "/fr/blog/example" }),
matchPageRoute: vi
.fn()
.mockReturnValue({ route: { isDynamic: true, pattern: "/blog/:slug" } }),
renderPage: makeRenderPage(),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.headers.get("x-nextjs-matched-path")).toBe("/fr/blog/[slug]");
});
it("does not add matched-path routing metadata to HTML responses", async () => {
const result = await runPagesRequest(
makeRequest("/ssr-page"),
baseDeps({
runMiddleware: makeMiddleware({ rewriteUrl: "/ssr-page-2" }),
matchPageRoute: vi.fn().mockReturnValue({ route: { isDynamic: false } }),
renderPage: makeRenderPage(),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.headers.get("x-nextjs-matched-path")).toBeNull();
});
it("returns middleware data misses as JSON with the requested matched path", async () => {
const result = await runPagesRequest(
makeRequest("/unknown"),
baseDeps({
isDataReq: true,
isDataRequest: true,
hasMiddleware: true,
runMiddleware: makeMiddleware({ continue: true }),
matchPageRoute: vi.fn().mockReturnValue(null),
renderPage: makeRenderPage(404, "not found"),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(200);
expect(result.response.headers.get("content-type")).toContain("application/json");
expect(result.response.headers.get("x-nextjs-matched-path")).toBe("/unknown");
expect(await result.response.text()).toBe("{}");
});
it("keeps data misses as JSON 404 when generated wiring provides a no-op middleware", async () => {
const result = await runPagesRequest(
makeRequest("/unknown"),
baseDeps({
isDataReq: true,
isDataRequest: true,
hasMiddleware: false,
runMiddleware: makeMiddleware({ continue: true }),
matchPageRoute: vi.fn().mockReturnValue(null),
renderPage: makeRenderPage(404, "{}"),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(404);
expect(result.response.headers.get("x-nextjs-matched-path")).toBeNull();
});
it("skips middleware data prefetches for matched non-SSG pages", async () => {
// Ported from Next.js: test/e2e/middleware-rewrites/test/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-rewrites/test/index.test.ts
const renderPage = makeRenderPage(200, '{"pageProps":{"message":"from gssp"}}');
const result = await runPagesRequest(
makeRequest("/ssr", { "x-middleware-prefetch": "1" }),
baseDeps({
isDataReq: true,
isDataRequest: true,
hasMiddleware: true,
runMiddleware: makeMiddleware({ continue: true }),
matchPageRoute: vi
.fn()
.mockReturnValue({ route: { isDynamic: false, pattern: "/ssr", dataKind: "server" } }),
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(renderPage).not.toHaveBeenCalled();
expect(result.response.status).toBe(200);
expect(result.response.headers.get("content-type")).toBe("application/json");
expect(result.response.headers.get("x-matched-path")).toBe("/ssr");
expect(result.response.headers.get(MIDDLEWARE_SKIP_HEADER)).toBe("1");
expect(await result.response.json()).toEqual({});
});
it("falls back to normal data handling when route data kind is unknown", async () => {
const renderPage = makeRenderPage(200, '{"pageProps":{"message":"from gssp"}}');
const result = await runPagesRequest(
makeRequest("/ssr", { "x-middleware-prefetch": "1" }),
baseDeps({
isDataReq: true,
isDataRequest: true,
hasMiddleware: true,
runMiddleware: makeMiddleware({ continue: true }),
matchPageRoute: vi.fn().mockReturnValue({ route: { isDynamic: false, pattern: "/ssr" } }),
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(renderPage).toHaveBeenCalledOnce();
expect(result.response.headers.get(MIDDLEWARE_SKIP_HEADER)).toBeNull();
expect(await result.response.text()).toBe('{"pageProps":{"message":"from gssp"}}');
});
it("does not skip middleware data prefetches for unexpected route data kinds", async () => {
const renderPage = makeRenderPage(200, '{"pageProps":{"message":"from gssp"}}');
const result = await runPagesRequest(
makeRequest("/ssr", { "x-middleware-prefetch": "1" }),
baseDeps({
isDataReq: true,
isDataRequest: true,
hasMiddleware: true,
runMiddleware: makeMiddleware({ continue: true }),
matchPageRoute: vi
.fn()
.mockReturnValue({ route: { isDynamic: false, pattern: "/ssr", dataKind: "none" } }),
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(renderPage).toHaveBeenCalledOnce();
expect(result.response.headers.get(MIDDLEWARE_SKIP_HEADER)).toBeNull();
expect(await result.response.text()).toBe('{"pageProps":{"message":"from gssp"}}');
});
it("does not skip middleware data prefetches for matched SSG pages", async () => {
const renderPage = makeRenderPage(200, '{"pageProps":{"message":"from gsp"}}');
const result = await runPagesRequest(
makeRequest("/ssg", { "x-middleware-prefetch": "1" }),
baseDeps({
isDataReq: true,
isDataRequest: true,
hasMiddleware: true,
runMiddleware: makeMiddleware({
continue: true,
responseHeaders: [["x-middleware-cache", "no-cache"]],
}),
matchPageRoute: vi
.fn()
.mockReturnValue({ route: { isDynamic: false, pattern: "/ssg", dataKind: "static" } }),
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(renderPage).toHaveBeenCalledOnce();
expect(result.response.headers.get(MIDDLEWARE_SKIP_HEADER)).toBeNull();
expect(result.response.headers.get("x-middleware-cache")).toBe("no-cache");
expect(result.response.headers.get("x-nextjs-matched-path")).toBe("/ssg");
expect(await result.response.text()).toBe('{"pageProps":{"message":"from gsp"}}');
});
it("uses the matched route pattern for dynamic data responses", async () => {
const result = await runPagesRequest(
makeRequest("/source"),
baseDeps({
isDataReq: true,
isDataRequest: true,
runMiddleware: makeMiddleware({ rewriteUrl: "/blog/example" }),
matchPageRoute: vi
.fn()
.mockReturnValue({ route: { isDynamic: true, pattern: "/blog/:slug" } }),
renderPage: makeRenderPage(),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.headers.get("x-nextjs-matched-path")).toBe("/blog/[slug]");
});
it("does not add matched-path routing metadata to failed data responses", async () => {
const result = await runPagesRequest(
makeRequest("/ssr-page"),
baseDeps({
isDataReq: true,
isDataRequest: true,
runMiddleware: makeMiddleware({ rewriteUrl: "/ssr-page-2" }),
matchPageRoute: vi
.fn()
.mockReturnValue({ route: { isDynamic: false, pattern: "/ssr-page-2" } }),
renderPage: makeRenderPage(500, "failed"),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(500);
expect(result.response.headers.get("x-nextjs-matched-path")).toBeNull();
});
it("does not add matched-path metadata when middleware overrides the response status", async () => {
const result = await runPagesRequest(
makeRequest("/ssr-page"),
baseDeps({
isDataReq: true,
isDataRequest: true,
runMiddleware: makeMiddleware({
rewriteUrl: "/ssr-page-2",
status: 418,
}),
matchPageRoute: vi
.fn()
.mockReturnValue({ route: { isDynamic: false, pattern: "/ssr-page-2" } }),
renderPage: makeRenderPage(),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(418);
expect(result.response.headers.get("x-nextjs-matched-path")).toBeNull();
});
it("middleware redirect short-circuits with 307", async () => {
const req = makeRequest("/foo");
const result = await runPagesRequest(
req,
baseDeps({
runMiddleware: makeMiddleware({
continue: false,
redirectUrl: "http://localhost/bar",
redirectStatus: 307,
}),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(307);
expect(result.response.headers.get("Location")).toBe("http://localhost/bar");
});
it("middleware redirect uses default 307 when no redirectStatus given", async () => {
const req = makeRequest("/foo");
const result = await runPagesRequest(
req,
baseDeps({
runMiddleware: makeMiddleware({ continue: false, redirectUrl: "http://localhost/bar" }),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(307);
});
it("preserves unconsumed request-header values on middleware redirects", async () => {
const result = await runPagesRequest(
makeRequest("/foo"),
baseDeps({
runMiddleware: makeMiddleware({
continue: false,
redirectUrl: "http://localhost/bar",
redirectStatus: 307,
responseHeaders: new Headers({
"x-middleware-request-x-added": "forged-by-middleware",
}),
}),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.headers.get("x-middleware-request-x-added")).toBe(
"forged-by-middleware",
);
});
// Next.js stages matching headers() rules before middleware and retains them
// on terminal middleware responses.
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/router-utils/resolve-routes.ts
it("preserves matching config headers on terminal middleware redirects", async () => {
const result = await runPagesRequest(
makeRequest("/foo"),
baseDeps({
configHeaders: [{ source: "/foo", headers: [{ key: "x-config", value: "config" }] }],
runMiddleware: makeMiddleware({
continue: false,
redirectUrl: "http://localhost/bar",
responseHeaders: new Headers({ "x-middleware": "middleware" }),
}),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.headers.get("x-config")).toBe("config");
expect(result.response.headers.get("x-middleware")).toBe("middleware");
});
it("preserves matching config headers on terminal middleware bodies", async () => {
const result = await runPagesRequest(
makeRequest("/foo"),
baseDeps({
configHeaders: [{ source: "/foo", headers: [{ key: "x-config", value: "config" }] }],
runMiddleware: makeMiddleware({
continue: false,
response: new Response("blocked", { headers: { "x-config": "middleware" } }),
}),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(await result.response.text()).toBe("blocked");
expect(result.response.headers.get("x-config")).toBe("middleware");
});
// Ported from Next.js: test/e2e/middleware-general/test/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-general/test/index.test.ts
it("does not classify a normal request as data from x-nextjs-data alone", async () => {
const runMiddleware = vi.fn(async () => ({
continue: false,
redirectUrl: "http://localhost/somewhere",
redirectStatus: 307,
}));
const result = await runPagesRequest(
makeRequest("/redirect-to-somewhere", { "x-nextjs-data": "1" }),
baseDeps({ runMiddleware }),
);
expect(runMiddleware).toHaveBeenCalledWith(expect.any(Request), null, {
isDataRequest: false,
});
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(307);
expect(result.response.headers.get("Location")).toBe("http://localhost/somewhere");
expect(result.response.headers.get("x-nextjs-redirect")).toBeNull();
});
// 5. Middleware rewrite: resolvedUrl changes, pipeline continues
it("middleware rewrite changes resolved URL, pipeline continues to render", async () => {
const renderPage = makeRenderPage(200, "rewrite target");
const req = makeRequest("/foo");
const result = await runPagesRequest(
req,
baseDeps({
runMiddleware: makeMiddleware({ continue: true, rewriteUrl: "/bar" }),
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(200);
// renderPage should have been called with the rewritten URL
expect(renderPage).toHaveBeenCalledWith(
expect.any(Request),
"/bar",
undefined,
expect.any(Headers),
);
});
it.each([
{ i18nConfig: null, requestPath: "/ssr-page", rewritePath: "/ssr-page-2" },
{
i18nConfig: { locales: ["en", "fr"], defaultLocale: "en" },
requestPath: "/en/ssr-page",
rewritePath: "/en/ssr-page-2",
},
])(
"exposes the middleware rewrite target on real Pages data responses ($requestPath)",
async ({ i18nConfig, requestPath, rewritePath }) => {
const result = await runPagesRequest(
makeRequest(requestPath),
baseDeps({
isDataReq: true,
isDataRequest: true,
i18nConfig,
runMiddleware: makeMiddleware({
continue: true,
rewriteUrl: rewritePath,
}),
renderPage: makeRenderPage(200, '{"pageProps":{"message":"Bye Cruel World"}}'),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.headers.get("x-nextjs-rewrite")).toBe(rewritePath);
expect(result.response.headers.get("x-middleware-rewrite")).toBeNull();
},
);
it("does not expose the middleware rewrite target on HTML responses", async () => {
const result = await runPagesRequest(
makeRequest("/to-blog/post"),
baseDeps({
runMiddleware: makeMiddleware({
continue: true,
rewriteUrl: "/fallback-true-blog/post",
}),
renderPage: makeRenderPage(200),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.headers.get("x-nextjs-rewrite")).toBeNull();
});
it("exposes the final config rewrite URL with appended source params", async () => {
const result = await runPagesRequest(
makeRequest("/config-rewrite-to-dynamic-static/post-2"),
baseDeps({
isDataRequest: true,
configRewrites: {
beforeFiles: [
{
source: "/config-rewrite-to-dynamic-static/:rewriteSlug",
destination: "/ssg",
},
],
afterFiles: [],
fallback: [],
},
renderPage: makeRenderPage(200),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.headers.get("x-nextjs-rewrite")).toBe("/ssg?rewriteSlug=post-2");
});
// 6. Middleware response short-circuit → {type:"response"} with middleware response
it("middleware response short-circuit returns the middleware response", async () => {
const middlewareResponse = new Response("blocked", { status: 403 });
const req = makeRequest("/foo");
const result = await runPagesRequest(
req,
baseDeps({
runMiddleware: makeMiddleware({ continue: false, response: middlewareResponse }),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response).toBe(middlewareResponse);
expect(result.response.status).toBe(403);
// Passthrough response: no content-type default → Node sends it verbatim.
expect(result.defaultContentType).toBeUndefined();
});
// 18. middlewareStatus reconciliation: result.status takes priority over result.rewriteStatus
it("middlewareStatus: result.status takes priority over result.rewriteStatus", async () => {
const renderPage = makeRenderPage(200);
const req = makeRequest("/foo");
const result = await runPagesRequest(
req,
baseDeps({
runMiddleware: makeMiddleware({ continue: true, status: 404, rewriteStatus: 403 }),
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
// middlewareStatus=404 from result.status should override rewriteStatus=403
expect(result.response.status).toBe(404);
});
// 20. Multi-value Set-Cookie accumulation from middleware response headers
it("accumulates multiple Set-Cookie headers from middleware", async () => {
const renderPage = makeRenderPage(200);
const req = makeRequest("/foo");
const responseHeaders = new Headers();
responseHeaders.append("set-cookie", "a=1");
responseHeaders.append("set-cookie", "b=2");
const result = await runPagesRequest(
req,
baseDeps({
runMiddleware: vi.fn(async () => ({
continue: true,
responseHeaders: [...responseHeaders.entries()],
})),
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
const cookies = result.response.headers.getSetCookie?.() ?? [];
expect(cookies).toContain("a=1");
expect(cookies).toContain("b=2");
});
});
// 7. Config headers staged
describe("config headers", () => {
it("stages config headers into middleware headers", async () => {
const renderPage = makeRenderPage(200);
const req = makeRequest("/foo");
const result = await runPagesRequest(
req,
baseDeps({
configHeaders: [{ source: "/foo", headers: [{ key: "X-Custom", value: "test" }] }],
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.headers.get("X-Custom")).toBe("test");
});
it("does not match percent-encoded static aliases", async () => {
const result = await runPagesRequest(
makeRequest("/%61bout"),
baseDeps({
configHeaders: [{ source: "/about", headers: [{ key: "X-Custom", value: "test" }] }],
renderPage: makeRenderPage(),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.headers.get("X-Custom")).toBeNull();
});
});
// 8. External proxy after middleware rewrite
describe("external proxy", () => {
it("proxies to external URL when middleware rewrites to external", async () => {
const req = makeRequest("/proxy-me");
// Use fetchMock or just observe isExternalUrl → proxyExternalRequest
// Since proxyExternalRequest will actually fetch, we mock globalThis.fetch
const mockFetch = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(new Response("external response", { status: 200 }));
const result = await runPagesRequest(
req,
baseDeps({
runMiddleware: makeMiddleware({
continue: true,
rewriteUrl: "https://example.com/proxied",
}),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(200);
mockFetch.mockRestore();
});
});
// 9. beforeFiles rewrite with external URL → {type:"response"} from proxy
describe("beforeFiles rewrites", () => {
it.each(["beforeFiles", "afterFiles", "fallback"] as const)(
"fails probing closed when an unkeyed %s rewrite condition misses",
async (phase) => {
const rewrite = {
source: "/conditional",
destination: "/private",
has: [{ type: "header" as const, key: "x-variant", value: "private" }],
};
const reason = await cacheabilityReasonFor(makeRequest("/conditional"), {
configRewrites: {
beforeFiles: phase === "beforeFiles" ? [rewrite] : [],
afterFiles: phase === "afterFiles" ? [rewrite] : [],
fallback: phase === "fallback" ? [rewrite] : [],
},
...(phase === "afterFiles"
? { matchPageRoute: vi.fn().mockReturnValue({ route: { isDynamic: true } }) }
: phase === "fallback"
? {
matchPageRoute: vi.fn().mockReturnValue(null),
renderPage: makeRenderPage(404, "not found"),
}
: {}),
});
expect(reason).toBe("next.config rewrite depends on request headers, cookies, or hostnames");
},
);
it("does not match decoded literal aliases from the normalized route pathname", async () => {
const renderPage = makeRenderPage();
const result = await runPagesRequest(
makeRequest("/alias"),
baseDeps({
configMatchPathname: "/%61lias",
configRewrites: {
beforeFiles: [{ source: "/alias", destination: "/about" }],
afterFiles: [],
fallback: [],
},
renderPage,
}),
);
expect(result.type).toBe("response");
expect(renderPage).toHaveBeenCalledWith(
expect.any(Request),
"/alias",
undefined,
expect.any(Headers),
);
});
it("beforeFiles external rewrite proxies the request", async () => {
const req = makeRequest("/proxy-me");
const mockFetch = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(new Response("from proxy", { status: 200 }));
const result = await runPagesRequest(
req,
baseDeps({
configRewrites: {
beforeFiles: [{ source: "/proxy-me", destination: "https://example.com/proxied" }],
afterFiles: [],
fallback: [],
},
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(200);
mockFetch.mockRestore();
});
it("proxies basePath:false afterFiles external rewrites before rejecting outside basePath", async () => {
// Ported from Next.js: test/e2e/basepath/redirect-and-rewrite.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/basepath/redirect-and-rewrite.test.ts
const req = makeRequest("/rewrite-no-basePath");
const proxyExternal = vi.fn(async () => new Response("from external rewrite", { status: 200 }));
const result = await runPagesRequest(
req,
baseDeps({
basePath: "/docs",
hadBasePath: false,
configRewrites: {
beforeFiles: [],
afterFiles: [
{
source: "/rewrite-no-basepath",
destination: "https://example.vercel.sh",
basePath: false,
},
],
fallback: [],
},
proxyExternal,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(200);
expect(await result.response.text()).toBe("from external rewrite");
expect(proxyExternal).toHaveBeenCalledWith(expect.any(Request), "https://example.vercel.sh");
});
it("applies default afterFiles rewrites for requests inside basePath", async () => {
// Ported from Next.js: test/e2e/basepath/redirect-and-rewrite.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/basepath/redirect-and-rewrite.test.ts
const renderPage = makeRenderPage(200, "getServerSideProps");
const matchPageRoute = vi.fn((pathname: string) =>
pathname === "/rewrite-1"
? { route: { isDynamic: true, pattern: "/:slug" } }
: pathname === "/gssp"
? { route: { isDynamic: false, pattern: "/gssp" } }
: null,
);
const result = await runPagesRequest(
makeRequest("/rewrite-1"),
baseDeps({
basePath: "/docs",
hadBasePath: true,
configRewrites: {
beforeFiles: [],
afterFiles: [{ source: "/rewrite-1", destination: "/gssp" }],
fallback: [],
},
matchPageRoute,
renderPage,
}),
);
expect(result.type).toBe("response");
expect(renderPage).toHaveBeenCalledWith(
expect.any(Request),
"/gssp",
undefined,
expect.any(Headers),
);
expect(matchPageRoute).toHaveBeenCalledWith("/gssp", expect.any(Request));
});
it("does not apply basePath:false afterFiles rewrites for requests inside basePath", async () => {
// Ported from Next.js: test/e2e/basepath/redirect-and-rewrite.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/basepath/redirect-and-rewrite.test.ts
const renderPage = makeRenderPage(200, "slug");
const matchPageRoute = vi.fn((pathname: string) =>
pathname === "/rewrite-no-basePath"
? { route: { isDynamic: true, pattern: "/:slug" } }
: null,
);
const proxyExternal = vi.fn(async () => new Response("from external rewrite", { status: 200 }));
const result = await runPagesRequest(
makeRequest("/rewrite-no-basePath"),
baseDeps({
basePath: "/docs",
hadBasePath: true,
configRewrites: {
beforeFiles: [],
afterFiles: [
{
source: "/rewrite-no-basepath",
destination: "https://example.vercel.sh",
basePath: false,
},
],
fallback: [],
},
matchPageRoute,
proxyExternal,
renderPage,
}),
);
expect(result.type).toBe("response");
expect(renderPage).toHaveBeenCalledWith(
expect.any(Request),
"/rewrite-no-basePath",
undefined,
expect.any(Headers),
);
expect(proxyExternal).not.toHaveBeenCalled();
});
it("beforeFiles rewrite changes resolved URL", async () => {
const renderPage = makeRenderPage(200);
const req = makeRequest("/from");
const result = await runPagesRequest(
req,
baseDeps({
configRewrites: {
beforeFiles: [{ source: "/from", destination: "/to" }],
afterFiles: [],
fallback: [],
},
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(renderPage).toHaveBeenCalledWith(
expect.any(Request),
"/to",
undefined,
expect.any(Headers),
);
});
it("applies chained beforeFiles rewrites with accumulated query conditions", async () => {
const renderPage = makeRenderPage(200);
const result = await runPagesRequest(
makeRequest("/from?keep=1"),
baseDeps({
configRewrites: {
beforeFiles: [
{ source: "/from", destination: "/middle?stage=1" },
{
source: "/middle",
destination: "/to",
has: [{ type: "query", key: "stage", value: "1" }],
},
],
afterFiles: [],
fallback: [],
},
renderPage,
}),
);
expect(result.type).toBe("response");
expect(renderPage).toHaveBeenCalledWith(
expect.any(Request),
"/to?keep=1&stage=1",
undefined,
expect.any(Headers),
);
});
it("applies every matching beforeFiles rewrite in sequence", async () => {
const renderPage = makeRenderPage(200);
const result = await runPagesRequest(
makeRequest("/start"),
baseDeps({
isDataRequest: true,
configRewrites: {
beforeFiles: [
{ source: "/start", destination: "/middle?first=1" },
{ source: "/middle", destination: "/destination?second=2" },
],
afterFiles: [],
fallback: [],
},
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(renderPage).toHaveBeenCalledWith(
expect.any(Request),
"/destination?first=1&second=2",
undefined,
expect.any(Headers),
);
expect(result.response.headers.get("x-nextjs-rewrite")).toBe("/destination?first=1&second=2");
});
it("excludes beforeFiles fragments from Pages route matching", async () => {
const matchPageRoute = vi.fn((pathname: string) =>
pathname === "/to" ? { route: { isDynamic: false } } : null,
);
await runPagesRequest(
makeRequest("/from"),
baseDeps({
configRewrites: {
beforeFiles: [{ source: "/from", destination: "/to#section" }],
afterFiles: [],
fallback: [],
},
matchPageRoute,
renderPage: makeRenderPage(200),
}),
);
expect(matchPageRoute).toHaveBeenCalledWith("/to", expect.any(Request));
expect(matchPageRoute).not.toHaveBeenCalledWith("/to#section", expect.any(Request));
});
});
// 10. Out-of-basePath reject when basePath: "/base" and hadBasePath: false and no configRewrite fired
describe("out-of-basePath rejection", () => {
it("rejects requests outside basePath with 404 when no rewrite fires", async () => {
const req = makeRequest("/outside/page");
const result = await runPagesRequest(
req,
baseDeps({
basePath: "/base",
hadBasePath: false,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(404);
});
it("allows requests outside basePath when beforeFiles rewrite fires", async () => {
const renderPage = makeRenderPage(200);
const req = makeRequest("/outside");
const result = await runPagesRequest(
req,
baseDeps({
basePath: "/base",
hadBasePath: false,
configRewrites: {
beforeFiles: [{ source: "/outside", destination: "/inside", basePath: false }],
afterFiles: [],
fallback: [],
},
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(200);
});
});
// 11. API route with renderPage absent → {type:"api"} intent
describe("API routes", () => {
it("emits api intent when handleApi absent", async () => {
const req = makeRequest("/api/users");
const result = await runPagesRequest(req, baseDeps());
expect(result.type).toBe("api");
if (result.type !== "api") return;
expect(result.apiUrl).toBe("/api/users");
expect(result.configRewriteFired).toBe(false);
});
it("distinguishes locale-normalized API lookup from a config rewrite", async () => {
const result = await runPagesRequest(
makeRequest("/fr/api/users"),
baseDeps({
i18nConfig: {
defaultLocale: "en",
locales: ["en", "fr"],
},
}),
);
expect(result.type).toBe("api");
if (result.type !== "api") return;
expect(result.apiUrl).toBe("/api/users");
expect(result.configRewriteFired).toBe(false);
});
it("marks API intents reached through config rewrites", async () => {
const result = await runPagesRequest(
makeRequest("/legacy/users"),
baseDeps({
configRewrites: {
beforeFiles: [{ source: "/legacy/:path*", destination: "/api/:path*" }],
afterFiles: [],
fallback: [],
},
}),
);
expect(result.type).toBe("api");
if (result.type !== "api") return;
expect(result.apiUrl).toBe("/api/users");
expect(result.configRewriteFired).toBe(true);
});
// 12. API route with handleApi present → {type:"response"}
it("returns response when handleApi present", async () => {
const req = makeRequest("/api/users");
const handleApi = vi.fn(async () => new Response('{"ok":true}', { status: 200 }));
const result = await runPagesRequest(req, baseDeps({ handleApi }));
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(200);
expect(handleApi).toHaveBeenCalledWith(
expect.any(Request),
"/api/users",
null,
expect.any(Headers),
);
// API responses default a missing content-type to octet-stream, not text/html.
expect(result.defaultContentType).toBe("application/octet-stream");
});
it("passes staged middleware and config response headers to API dispatch", async () => {
const handleApi = vi.fn(
async (_request: Request, _apiUrl: string, _ctx: unknown, stagedHeaders: Headers) => {
expect(stagedHeaders.get("cache-control")).toBe("private, no-store");
expect(stagedHeaders.get("x-visitor-id")).toBe("visitor-a");
return new Response("api", { headers: { "x-inner": "kept" } });
},
);
const result = await runPagesRequest(
makeRequest("/api/users"),
baseDeps({
configHeaders: [
{
source: "/api/users",
headers: [{ key: "Cache-Control", value: "private, no-store" }],
},
],
handleApi,
hasMiddleware: true,
runMiddleware: makeMiddleware({
responseHeaders: [["x-visitor-id", "visitor-a"]],
}),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.headers.get("cache-control")).toBe("private, no-store");
expect(result.response.headers.get("x-visitor-id")).toBe("visitor-a");
expect(result.response.headers.get("x-inner")).toBe("kept");
});
it("continues to fallback rewrites when an API path has no route match", async () => {
const matchApiRoute = vi.fn().mockReturnValue(null);
const proxyExternal = vi.fn(async () => new Response("upstream"));
const result = await runPagesRequest(
makeRequest("/api/session?client=vinext", { "x-use-fallback": "yes" }),
baseDeps({
configRewrites: {
beforeFiles: [],
afterFiles: [],
fallback: [
{
source: "/api/:path*",
has: [{ type: "header", key: "x-use-fallback", value: "yes" }],
destination: "https://upstream.example/auth/:path*?from=config",
},
],
},
matchApiRoute,
proxyExternal,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
await expect(result.response.text()).resolves.toBe("upstream");
expect(matchApiRoute).toHaveBeenCalledWith("/api/session?client=vinext", expect.any(Request));
expect(proxyExternal).toHaveBeenCalledWith(
expect.any(Request),
"https://upstream.example/auth/session?from=config",
);
});
it("does not apply fallback rewrites when an API route matches", async () => {
const matchApiRoute = vi.fn().mockReturnValue({
route: { isDynamic: false, pattern: "/api/session" },
});
const handleApi = vi.fn(async () => new Response("local api"));
const proxyExternal = vi.fn(async () => new Response("upstream"));
const result = await runPagesRequest(
makeRequest("/api/session"),
baseDeps({
configRewrites: {
beforeFiles: [],
afterFiles: [],
fallback: [
{
source: "/api/:path*",
destination: "https://upstream.example/:path*",
},
],
},
handleApi,
matchApiRoute,
proxyExternal,
}),
);
expect(result.type).toBe("response");
expect(handleApi).toHaveBeenCalledOnce();
expect(proxyExternal).not.toHaveBeenCalled();
});
it("tags page renders with a text/html content-type default", async () => {
const req = makeRequest("/page");
const result = await runPagesRequest(req, baseDeps({ renderPage: makeRenderPage(200) }));
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.defaultContentType).toBe("text/html; charset=utf-8");
});
it("matches /api exactly", async () => {
const req = makeRequest("/api");
const result = await runPagesRequest(req, baseDeps());
expect(result.type).toBe("api");
});
});
// Filesystem/static route serving supplied by each runtime adapter.
describe("serveFilesystemRoute", () => {
it("returns {type:'handled'} when serveFilesystemRoute serves the request", async () => {
const renderPage = makeRenderPage(200);
const serveFilesystemRoute = vi.fn(async () => true);
const req = makeRequest("/favicon.ico");
const result = await runPagesRequest(req, baseDeps({ serveFilesystemRoute, renderPage }));
expect(result.type).toBe("handled");
// The static file short-circuits — the renderer is never invoked.
expect(renderPage).not.toHaveBeenCalled();
});
it("strips stale middleware body headers from static 405 responses", async () => {
const serveFilesystemRoute = vi.fn(
async () =>
new Response("Method Not Allowed", {
status: 405,
headers: { Allow: "GET, HEAD" },
}),
);
const middleware = makeMiddleware({
status: 404,
responseHeaders: [
["content-encoding", "gzip"],
["content-length", "999"],
["content-type", "application/wrong"],
["transfer-encoding", "chunked"],
["x-from-middleware", "1"],
],
});
const result = await runPagesRequest(
new Request("https://example.com/file.txt", { method: "POST" }),
baseDeps({ serveFilesystemRoute, runMiddleware: middleware }),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(405);
expect(result.response.headers.get("allow")).toBe("GET, HEAD");
expect(result.response.headers.get("content-encoding")).toBeNull();
expect(result.response.headers.get("content-length")).toBeNull();
expect(result.response.headers.get("content-type")).toBe("text/plain; charset=utf-8");
expect(result.response.headers.get("transfer-encoding")).toBeNull();
expect(result.response.headers.get("x-from-middleware")).toBe("1");
});
it("falls through to render when serveFilesystemRoute returns false", async () => {
const renderPage = makeRenderPage(200);
const serveFilesystemRoute = vi.fn(async () => false);
const req = makeRequest("/page");
const result = await runPagesRequest(req, baseDeps({ serveFilesystemRoute, renderPage }));
expect(serveFilesystemRoute).toHaveBeenCalledTimes(1);
expect(result.type).toBe("response");
expect(renderPage).toHaveBeenCalled();
});
it("passes the original (pre-rewrite) pathname and staged headers", async () => {
const serveFilesystemRoute = vi.fn(async () => true);
const middleware = makeMiddleware({ responseHeaders: [["set-cookie", "a=1"]] });
const req = makeRequest("/robots.txt");
await runPagesRequest(req, baseDeps({ serveFilesystemRoute, runMiddleware: middleware }));
expect(serveFilesystemRoute).toHaveBeenCalledWith(
"/robots.txt",
{ "set-cookie": ["a=1"] },
"direct",
"/robots.txt",
);
});
it.each([
["beforeFiles", null],
["middleware", "/_next/image?url=%2Ffoo.png&w=828&q=75"],
])("preserves image query parameters after a %s rewrite", async (_kind, middlewareRewrite) => {
const destination = "/_next/image?url=%2Ffoo.png&w=828&q=75";
const serveFilesystemRoute = vi.fn(async () => true);
const result = await runPagesRequest(
makeRequest("/image-alias"),
baseDeps({
configRewrites: middlewareRewrite
? { beforeFiles: [], afterFiles: [], fallback: [] }
: {
beforeFiles: [{ source: "/image-alias", destination }],
afterFiles: [],
fallback: [],
},
runMiddleware: middlewareRewrite
? makeMiddleware({ rewriteUrl: middlewareRewrite })
: undefined,
serveFilesystemRoute,
}),
);
expect(result.type).toBe("handled");
expect(serveFilesystemRoute).toHaveBeenCalledWith(
"/_next/image",
{},
"beforeFiles",
destination,
);
});
it("runs after middleware — a middleware redirect wins over a public file", async () => {
const serveFilesystemRoute = vi.fn(async () => true);
const middleware = makeMiddleware({
continue: false,
redirectUrl: "/elsewhere",
redirectStatus: 307,
});
const req = makeRequest("/favicon.ico");
const result = await runPagesRequest(
req,
baseDeps({ serveFilesystemRoute, runMiddleware: middleware }),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(307);
expect(result.response.headers.get("Location")).toBe("/elsewhere");
expect(serveFilesystemRoute).not.toHaveBeenCalled();
});
// Ported from Next.js: test/e2e/i18n-ignore-rewrite-source-locale/rewrites.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/i18n-ignore-rewrite-source-locale/rewrites.test.ts
it("re-enters filesystem matching after a locale:false beforeFiles rewrite", async () => {
const serveFilesystemRoute = vi.fn(async (pathname: string) => pathname === "/file.txt");
const result = await runPagesRequest(
makeRequest("/sv/rewrite-files/file.txt"),
baseDeps({
i18nConfig: { locales: ["en", "sv", "nl"], defaultLocale: "en" },
configRewrites: {
beforeFiles: [
{
source: "/:locale/rewrite-files/:path*",
destination: "/:path*",
locale: false,
},
],
afterFiles: [],
fallback: [],
},
serveFilesystemRoute,
}),
);
expect(result.type).toBe("handled");
expect(serveFilesystemRoute).toHaveBeenCalledOnce();
expect(serveFilesystemRoute).toHaveBeenCalledWith("/file.txt", {}, "beforeFiles", "/file.txt");
});
// Next.js runs beforeFiles rewrites before check_fs:
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/router-utils/resolve-routes.ts
it("lets a beforeFiles rewrite move a mutation away from an existing public file", async () => {
const serveFilesystemRoute = vi.fn(async (pathname: string) =>
pathname === "/asset.txt"
? new Response("Method Not Allowed", {
status: 405,
headers: { Allow: "GET, HEAD" },
})
: false,
);
const handleApi = vi.fn(async () => new Response("rewritten api", { status: 201 }));
const result = await runPagesRequest(
new Request("http://localhost/asset.txt", { method: "POST" }),
baseDeps({
configRewrites: {
beforeFiles: [{ source: "/asset.txt", destination: "/api/rewritten" }],
afterFiles: [],
fallback: [],
},
handleApi,
serveFilesystemRoute,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(201);
await expect(result.response.text()).resolves.toBe("rewritten api");
expect(serveFilesystemRoute).toHaveBeenCalledOnce();
expect(serveFilesystemRoute).toHaveBeenCalledWith(
"/api/rewritten",
{},
"beforeFiles",
"/api/rewritten",
);
expect(handleApi).toHaveBeenCalledWith(
expect.any(Request),
"/api/rewritten",
null,
expect.any(Headers),
);
});
it("lets a middleware rewrite move a mutation away from an existing public file", async () => {
const serveFilesystemRoute = vi.fn(async (pathname: string) =>
pathname === "/asset.txt"
? new Response("Method Not Allowed", {
status: 405,
headers: { Allow: "GET, HEAD" },
})
: false,
);
const handleApi = vi.fn(async () => new Response("middleware api", { status: 202 }));
const result = await runPagesRequest(
new Request("http://localhost/asset.txt", { method: "POST" }),
baseDeps({
handleApi,
runMiddleware: makeMiddleware({ rewriteUrl: "/api/from-middleware" }),
serveFilesystemRoute,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(202);
await expect(result.response.text()).resolves.toBe("middleware api");
expect(serveFilesystemRoute).toHaveBeenCalledOnce();
expect(serveFilesystemRoute).toHaveBeenCalledWith(
"/api/from-middleware",
{},
"beforeFiles",
"/api/from-middleware",
);
expect(handleApi).toHaveBeenCalledWith(
expect.any(Request),
"/api/from-middleware",
null,
expect.any(Headers),
);
});
it("re-enters filesystem matching after a middleware rewrite", async () => {
const serveFilesystemRoute = vi.fn(async (pathname: string, _headers, phase) =>
pathname === "/asset.txt" && phase === "beforeFiles"
? new Response("rewritten asset")
: false,
);
const result = await runPagesRequest(
makeRequest("/source"),
baseDeps({
runMiddleware: makeMiddleware({ rewriteUrl: "/asset.txt" }),
serveFilesystemRoute,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
await expect(result.response.text()).resolves.toBe("rewritten asset");
expect(serveFilesystemRoute).toHaveBeenCalledOnce();
expect(serveFilesystemRoute).toHaveBeenCalledWith(
"/asset.txt",
{},
"beforeFiles",
"/asset.txt",
);
});
it("returns a Worker-style asset response after a beforeFiles rewrite", async () => {
const serveFilesystemRoute = vi.fn(async (pathname: string) =>
pathname === "/file.txt"
? new Response("worker asset", { headers: { "content-type": "text/plain" } })
: false,
);
const result = await runPagesRequest(
makeRequest("/en/rewrite-files/file.txt"),
baseDeps({
i18nConfig: { locales: ["en", "sv", "nl"], defaultLocale: "en" },
configRewrites: {
beforeFiles: [
{
source: "/:locale/rewrite-files/:path*",
destination: "/:path*",
locale: false,
},
],
afterFiles: [],
fallback: [],
},
serveFilesystemRoute,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(200);
await expect(result.response.text()).resolves.toBe("worker asset");
});
it("continues rewritten API and page routing when no filesystem target exists", async () => {
const serveFilesystemRoute = vi.fn(async () => false);
const handleApi = vi.fn(async () => new Response("api"));
const renderPage = makeRenderPage(200, "page");
const rewrites = {
beforeFiles: [
{ source: "/rewrite-api/:path*", destination: "/api/:path*" },
{ source: "/rewrite-page", destination: "/about" },
],
afterFiles: [],
fallback: [],
};
const apiResult = await runPagesRequest(
makeRequest("/rewrite-api/hello"),
baseDeps({ configRewrites: rewrites, serveFilesystemRoute, handleApi, renderPage }),
);
const pageResult = await runPagesRequest(
makeRequest("/rewrite-page"),
baseDeps({ configRewrites: rewrites, serveFilesystemRoute, handleApi, renderPage }),
);
expect(apiResult.type).toBe("response");
expect(handleApi).toHaveBeenCalledWith(
expect.any(Request),
"/api/hello",
null,
expect.any(Headers),
);
expect(pageResult.type).toBe("response");
expect(renderPage).toHaveBeenCalledWith(
expect.any(Request),
"/about",
undefined,
expect.any(Headers),
);
});
});
// 13. afterFiles rewrite: dynamic page match is re-queried
describe("afterFiles rewrites", () => {
it("re-enters Worker assets after afterFiles rewrites", async () => {
const serveFilesystemRoute = vi.fn(async (pathname: string, _headers, phase) =>
pathname === "/file.txt" && phase === "afterFiles"
? new Response("worker afterFiles asset")
: false,
);
const result = await runPagesRequest(
makeRequest("/sv/after-files/file.txt"),
baseDeps({
i18nConfig: { locales: ["en", "sv", "nl"], defaultLocale: "en" },
matchPageRoute: vi.fn().mockReturnValue(null),
configRewrites: {
beforeFiles: [],
afterFiles: [
{
source: "/:locale/after-files/:path*",
destination: "/:path*",
locale: false,
},
],
fallback: [],
},
serveFilesystemRoute,
renderPage: makeRenderPage(404),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
await expect(result.response.text()).resolves.toBe("worker afterFiles asset");
expect(serveFilesystemRoute).toHaveBeenLastCalledWith(
"/file.txt",
{},
"afterFiles",
"/file.txt",
);
});
it("does not run afterFiles filesystem re-entry when a static page matches", async () => {
const serveFilesystemRoute = vi.fn(async () => false);
const renderPage = makeRenderPage(200, "page wins");
const result = await runPagesRequest(
makeRequest("/after-control"),
baseDeps({
matchPageRoute: vi.fn().mockReturnValue({ route: { isDynamic: false } }),
configRewrites: {
beforeFiles: [],
afterFiles: [{ source: "/after-control", destination: "/file.txt" }],
fallback: [],
},
serveFilesystemRoute,
renderPage,
}),
);
expect(result.type).toBe("response");
expect(serveFilesystemRoute).toHaveBeenCalledOnce();
expect(serveFilesystemRoute).toHaveBeenCalledWith(
"/after-control",
{},
"direct",
"/after-control",
);
expect(renderPage).toHaveBeenCalledWith(
expect.any(Request),
"/after-control",
undefined,
expect.any(Headers),
);
});
it("dispatches rewritten API routes after afterFiles filesystem misses", async () => {
const serveFilesystemRoute = vi.fn(async () => false);
const handleApi = vi.fn(async () => new Response("worker afterFiles api"));
const result = await runPagesRequest(
makeRequest("/sv/after-files/api/hello"),
baseDeps({
i18nConfig: { locales: ["en", "sv", "nl"], defaultLocale: "en" },
matchPageRoute: vi.fn().mockReturnValue(null),
configRewrites: {
beforeFiles: [],
afterFiles: [
{
source: "/:locale/after-files/:path*",
destination: "/:path*",
locale: false,
},
],
fallback: [],
},
serveFilesystemRoute,
handleApi,
}),
);
expect(result.type).toBe("response");
expect(handleApi).toHaveBeenCalledWith(
expect.any(Request),
"/api/hello",
null,
expect.any(Headers),
);
});
it("applies afterFiles rewrite when page match is dynamic", async () => {
const renderPage = makeRenderPage(200);
const req = makeRequest("/dynamic-route");
const result = await runPagesRequest(
req,
baseDeps({
matchPageRoute: vi.fn().mockReturnValue({ route: { isDynamic: true } }),
configRewrites: {
beforeFiles: [],
afterFiles: [{ source: "/dynamic-route", destination: "/rewritten" }],
fallback: [],
},
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(renderPage).toHaveBeenCalledWith(
expect.any(Request),
"/rewritten",
undefined,
expect.any(Headers),
);
});
it("skips afterFiles rewrite when static page match exists", async () => {
const renderPage = makeRenderPage(200);
const req = makeRequest("/static-page");
const result = await runPagesRequest(
req,
baseDeps({
matchPageRoute: vi.fn().mockReturnValue({ route: { isDynamic: false } }),
configRewrites: {
beforeFiles: [],
afterFiles: [{ source: "/static-page", destination: "/rewritten" }],
fallback: [],
},
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
// Should be called with original URL, not rewritten
expect(renderPage).toHaveBeenCalledWith(
expect.any(Request),
"/static-page",
undefined,
expect.any(Headers),
);
});
it("continues afterFiles rewrites until a Pages destination resolves", async () => {
const renderPage = makeRenderPage(200);
const matchPageRoute = vi.fn((pathname: string) =>
pathname === "/resolved" ? { route: { isDynamic: false } } : null,
);
await runPagesRequest(
makeRequest("/from"),
baseDeps({
configRewrites: {
beforeFiles: [],
afterFiles: [
{ source: "/from", destination: "/missing" },
{ source: "/missing", destination: "/resolved#section" },
],
fallback: [],
},
matchPageRoute,
renderPage,
}),
);
expect(matchPageRoute).toHaveBeenCalledWith("/resolved", expect.any(Request));
expect(renderPage).toHaveBeenCalledWith(
expect.any(Request),
"/resolved#section",
undefined,
expect.any(Headers),
);
});
it("classifies middleware prefetches after afterFiles rewrites to SSG pages", async () => {
// Ported from Next.js: test/e2e/middleware-rewrites/test/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-rewrites/test/index.test.ts
const renderPage = makeRenderPage(200, '{"pageProps":{"message":"from gsp"}}');
const matchPageRoute = vi.fn((pathname: string) => {
if (pathname === "/article/first") {
return {
route: { isDynamic: true, pattern: "/article/[slug]", dataKind: "server" as const },
};
}
if (pathname === "/ssg") {
return { route: { isDynamic: false, pattern: "/ssg", dataKind: "static" as const } };
}
return null;
});
const result = await runPagesRequest(
makeRequest("/article/first", { "x-middleware-prefetch": "1" }),
baseDeps({
isDataReq: true,
isDataRequest: true,
hasMiddleware: true,
runMiddleware: makeMiddleware({ continue: true }),
configRewrites: {
beforeFiles: [],
afterFiles: [{ source: "/article/:slug", destination: "/ssg" }],
fallback: [],
},
matchPageRoute,
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(renderPage).toHaveBeenCalledWith(
expect.any(Request),
"/ssg?slug=first",
{ isDataReq: true },
expect.any(Headers),
);
expect(result.response.headers.get(MIDDLEWARE_SKIP_HEADER)).toBeNull();
expect(result.response.headers.get("x-nextjs-matched-path")).toBe("/ssg");
});
it("skips middleware prefetches after afterFiles rewrites to non-SSG pages", async () => {
const renderPage = makeRenderPage(200, '{"pageProps":{"message":"from gssp"}}');
const matchPageRoute = vi.fn((pathname: string) =>
pathname === "/ssr"
? { route: { isDynamic: false, pattern: "/ssr", dataKind: "server" as const } }
: null,
);
const result = await runPagesRequest(
makeRequest("/afterfiles-ssr", { "x-middleware-prefetch": "1" }),
baseDeps({
isDataReq: true,
isDataRequest: true,
hasMiddleware: true,
runMiddleware: makeMiddleware({ continue: true }),
configRewrites: {
beforeFiles: [],
afterFiles: [{ source: "/afterfiles-ssr", destination: "/ssr" }],
fallback: [],
},
matchPageRoute,
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(renderPage).not.toHaveBeenCalled();
expect(result.response.headers.get("x-matched-path")).toBe("/ssr");
expect(result.response.headers.get(MIDDLEWARE_SKIP_HEADER)).toBe("1");
expect(await result.response.json()).toEqual({});
});
});
// 14. Render intent when renderPage absent → {type:"render"}
describe("render intent", () => {
it("emits render intent when renderPage absent", async () => {
const req = makeRequest("/page");
const result = await runPagesRequest(req, baseDeps());
expect(result.type).toBe("render");
if (result.type !== "render") return;
expect(result.resolvedUrl).toBe("/page");
expect(result.isDataReq).toBe(false);
});
it("emits render intent with isDataReq when isDataReq is true", async () => {
const req = makeRequest("/page");
const result = await runPagesRequest(req, baseDeps({ isDataReq: true }));
expect(result.type).toBe("render");
if (result.type !== "render") return;
expect(result.isDataReq).toBe(true);
expect(result.renderOptions).toEqual({ isDataReq: true });
});
it("stages the final dev fallback rewrite target for data navigation", async () => {
const result = await runPagesRequest(
makeRequest("/to-blog/post"),
baseDeps({
isDataReq: true,
matchPageRoute: () => null,
configRewrites: {
beforeFiles: [],
afterFiles: [],
fallback: [
{
source: "/to-blog/:slug",
destination: "/fallback-true-blog/:slug",
},
],
},
}),
);
expect(result.type).toBe("render");
if (result.type !== "render") return;
expect(result.resolvedUrl).toBe("/fallback-true-blog/post");
expect(result.stagedHeaders["x-nextjs-rewrite"]).toBe("/fallback-true-blog/post");
});
});
// 15. {type:"response"} from renderPage (happy path)
describe("render via renderPage callback", () => {
it("returns response from renderPage", async () => {
const renderPage = makeRenderPage(200, "Hello World");
const req = makeRequest("/about");
const result = await runPagesRequest(req, baseDeps({ renderPage }));
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(200);
});
});
// 16. shouldDeferErrorPageOnMiss: 404 → fallback rewrite → re-render
describe("fallback rewrites on 404", () => {
it("re-enters Worker assets after fallback rewrites", async () => {
const serveFilesystemRoute = vi.fn(async (pathname: string, _headers, phase) =>
pathname === "/file.txt" && phase === "fallback"
? new Response("worker fallback asset")
: false,
);
const result = await runPagesRequest(
makeRequest("/sv/fallback-files/file.txt"),
baseDeps({
i18nConfig: { locales: ["en", "sv", "nl"], defaultLocale: "en" },
matchPageRoute: vi.fn().mockReturnValue(null),
configRewrites: {
beforeFiles: [],
afterFiles: [],
fallback: [
{
source: "/:locale/fallback-files/:path*",
destination: "/:path*",
locale: false,
},
],
},
serveFilesystemRoute,
renderPage: makeRenderPage(404),
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
await expect(result.response.text()).resolves.toBe("worker fallback asset");
expect(serveFilesystemRoute).toHaveBeenLastCalledWith("/file.txt", {}, "fallback", "/file.txt");
});
it("dispatches rewritten API routes after fallback filesystem misses", async () => {
const serveFilesystemRoute = vi.fn(async () => false);
const handleApi = vi.fn(async () => new Response("worker fallback api"));
const result = await runPagesRequest(
makeRequest("/sv/fallback-files/api/hello"),
baseDeps({
i18nConfig: { locales: ["en", "sv", "nl"], defaultLocale: "en" },
matchPageRoute: vi.fn().mockReturnValue(null),
configRewrites: {
beforeFiles: [],
afterFiles: [],
fallback: [
{
source: "/:locale/fallback-files/:path*",
destination: "/:path*",
locale: false,
},
],
},
serveFilesystemRoute,
handleApi,
matchApiRoute: (url) =>
url === "/api/hello" ? { route: { isDynamic: false, pattern: "/api/hello" } } : null,
renderPage: makeRenderPage(404),
}),
);
expect(result.type).toBe("response");
expect(handleApi).toHaveBeenCalledWith(
expect.any(Request),
"/api/hello",
null,
expect.any(Headers),
);
});
it("uses fallback rewrite when page misses and renders 404", async () => {
let callCount = 0;
const renderPage = vi.fn(async (_req: Request, url: string) => {
callCount++;
if (callCount === 1) {
// First call: return 404
return new Response("not found", { status: 404 });
}
// After fallback rewrite: return 200
return new Response(`ok ${url}`, { status: 200 });
});
const req = makeRequest("/missing-page");
const result = await runPagesRequest(
req,
baseDeps({
matchPageRoute: vi.fn().mockReturnValue(null), // no page match
configRewrites: {
beforeFiles: [],
afterFiles: [],
fallback: [{ source: "/missing-page", destination: "/fallback" }],
},
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(200);
expect(callCount).toBe(2);
});
it("continues fallback rewrites after an unresolved destination", async () => {
const renderPage = vi.fn(
async (_req: Request, resolvedUrl: string) =>
new Response(resolvedUrl, { status: resolvedUrl.startsWith("/resolved") ? 200 : 404 }),
);
const result = await runPagesRequest(
makeRequest("/from"),
baseDeps({
matchPageRoute: vi.fn().mockReturnValue(null),
configRewrites: {
beforeFiles: [],
afterFiles: [],
fallback: [
{ source: "/from", destination: "/missing" },
{ source: "/missing", destination: "/resolved#section" },
],
},
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(200);
expect(renderPage).toHaveBeenLastCalledWith(
expect.any(Request),
"/resolved#section",
undefined,
expect.any(Headers),
);
});
});
// 17. shouldDeferErrorPageOnMiss: 404 → no fallback → deferred error page re-render
describe("deferred error page re-render on 404", () => {
it("re-renders without renderErrorPageOnMiss when no fallback matches", async () => {
let callCount = 0;
const renderPage = vi.fn(async (_req: Request, _url: string, opts?: PagesRenderOptions) => {
callCount++;
if (opts?.renderErrorPageOnMiss === false) {
// Initial deferred call: return 404
return new Response("not found", { status: 404 });
}
// Second call: render actual error page
return new Response("error page", { status: 404 });
});
const req = makeRequest("/missing-page");
const result = await runPagesRequest(
req,
baseDeps({
matchPageRoute: vi.fn().mockReturnValue(null), // no page match
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(result.response.status).toBe(404);
expect(callCount).toBe(2);
// Second call should NOT have renderErrorPageOnMiss: false
expect(renderPage.mock.calls[1][2]).toBeUndefined();
});
it("does not defer or run fallback rewrites for a data request (x-nextjs-data, worker path)", async () => {
// Worker scenario: it never normalizes /_next/data paths (isDataReq stays
// false) but flags the request via the x-nextjs-data header (isDataRequest).
// A data-request miss must render once directly, not defer + run fallback.
const renderPage = makeRenderPage(404, "not found");
const fallback = [{ source: "/missing-page", destination: "/fallback" }];
const req = makeRequest("/missing-page");
const result = await runPagesRequest(
req,
baseDeps({
isDataReq: false,
isDataRequest: true,
matchPageRoute: vi.fn().mockReturnValue(null), // no page match
configRewrites: { beforeFiles: [], afterFiles: [], fallback },
renderPage,
}),
);
expect(result.type).toBe("response");
// Rendered exactly once with no defer option, and the fallback never fired.
expect(renderPage).toHaveBeenCalledTimes(1);
expect(renderPage.mock.calls[0][2]).toBeUndefined();
});
it("resolves fallback rewrites before rendering a production data request", async () => {
const renderPage = vi.fn(
async (_request: Request, url: string) => new Response(`data ${url}`, { status: 200 }),
);
const matchPageRoute = vi.fn((pathname: string) =>
pathname === "/fallback-target" ? ({ route: { isDynamic: false } } as any) : null,
);
const result = await runPagesRequest(
makeRequest("/missing-page"),
baseDeps({
isDataRequest: true,
matchPageRoute,
configRewrites: {
beforeFiles: [],
afterFiles: [],
fallback: [{ source: "/missing-page", destination: "/fallback-target?from=fallback" }],
},
renderPage,
}),
);
expect(result.type).toBe("response");
if (result.type !== "response") return;
expect(renderPage).toHaveBeenCalledOnce();
expect(renderPage).toHaveBeenCalledWith(
expect.any(Request),
"/fallback-target?from=fallback",
undefined,
expect.any(Headers),
);
expect(result.response.headers.get("x-nextjs-rewrite")).toBe("/fallback-target?from=fallback");
});
});
// 19. Credential headers on external rewrites follow the middleware override list
describe("external rewrite credential headers", () => {
it("forwards credentials when middleware sends no override list", async () => {
// No `x-middleware-override-headers` means middleware left the request
// headers alone, so Authorization reaches the external upstream.
const mockFetch = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(new Response("proxied", { status: 200 }));
const req = makeRequest("/internal", { Authorization: "Bearer token" });
const result = await runPagesRequest(
req,
baseDeps({
runMiddleware: makeMiddleware({
continue: true,
rewriteUrl: "https://external.com/api",
responseHeaders: [["x-middleware-request-authorization", "Bearer token"]],
}),
}),
);
expect(result.type).toBe("response");
const init = mockFetch.mock.calls[0][1] as RequestInit;
expect(new Headers(init.headers).get("authorization")).toBe("Bearer token");
mockFetch.mockRestore();
});
it("does not forward credentials the middleware deleted from the override list", async () => {
const mockFetch = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(new Response("proxied", { status: 200 }));
const req = makeRequest("/internal", { Authorization: "Bearer token", Cookie: "session=abc" });
const result = await runPagesRequest(
req,
baseDeps({
runMiddleware: makeMiddleware({
continue: true,
rewriteUrl: "https://external.com/api",
responseHeaders: [
["x-middleware-override-headers", "x-added"],
["x-middleware-request-x-added", "1"],
],
}),
}),
);
expect(result.type).toBe("response");
const headers = new Headers((mockFetch.mock.calls[0][1] as RequestInit).headers);
expect(headers.get("authorization")).toBeNull();
expect(headers.get("cookie")).toBeNull();
expect(headers.get("x-added")).toBe("1");
mockFetch.mockRestore();
});
});
// 20. wrapMiddlewareWithBasePath: shared adapter helper that re-adds the basePath
// to the middleware request URL. Used by prod-server.ts and the generated worker
// entry (deploy.ts) so middleware sees the original (pre-stripping) URL.
describe("wrapMiddlewareWithBasePath", () => {
it("passes through unchanged when hadBasePath is false (out-of-basePath request)", async () => {
const runMiddleware = makeMiddleware({ continue: true });
const wrapped = wrapMiddlewareWithBasePath(runMiddleware, "/root", false);
// Out-of-basePath requests stay bare so middleware sees nextUrl.basePath === "" (#1830).
expect(wrapped).toBe(runMiddleware);
await wrapped(makeRequest("/dashboard"), null, { isDataRequest: false });
expect(runMiddleware.mock.calls[0][0].url).toBe("http://localhost/dashboard");
});
it("passes through unchanged when basePath is empty", () => {
const runMiddleware = makeMiddleware({ continue: true });
expect(wrapMiddlewareWithBasePath(runMiddleware, "", true)).toBe(runMiddleware);
});
it("re-adds the basePath to the request URL when hadBasePath is true", async () => {
const runMiddleware = makeMiddleware({ continue: true });
const wrapped = wrapMiddlewareWithBasePath(runMiddleware, "/root", true);
expect(wrapped).not.toBe(runMiddleware);
const result = await wrapped(makeRequest("/dashboard?q=1", { "x-test": "kept" }), "ctx", {
isDataRequest: true,
});
expect(result.continue).toBe(true);
const [mwReq, ctx, opts] = runMiddleware.mock.calls[0];
expect(mwReq.url).toBe("http://localhost/root/dashboard?q=1");
// Headers, ctx, and opts are forwarded untouched.
expect(mwReq.headers.get("x-test")).toBe("kept");
expect(ctx).toBe("ctx");
expect(opts).toEqual({ isDataRequest: true });
});
it("does not double-add an already-present basePath (addBasePathToPathname is idempotent)", async () => {
const runMiddleware = makeMiddleware({ continue: true });
const wrapped = wrapMiddlewareWithBasePath(runMiddleware, "/root", true);
await wrapped(makeRequest("/root/dashboard"), null, { isDataRequest: false });
expect(runMiddleware.mock.calls[0][0].url).toBe("http://localhost/root/dashboard");
});
});