mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
a3d2f92152
* fix(router): honor hybrid pages route priority * fix(router): share hybrid owner decision with client navigation PR #1997 fixed the server-side route ownership for direct document loads, but the same invariant broke for client-side soft navigations and the matching prefetch path: - navigateClientSide() always delegated to the App runtime's RSC fetch, even when the Pages route had higher priority. renderPagesFallback() short-circuits RSC requests with null, so the App catch-all won. - prefetchUrl() prefetched an RSC stream for any URL that matched an App route, again ignoring Pages ownership. - The Pages entry was loaded on every hybrid request, even when a static App route had already matched (Pages can never win in that case). Expose the Pages route manifest on the client via a new __VINEXT_PAGES_LINK_PREFETCH_ROUTES__ window global emitted by both the App and Pages browser entries. The link shim consults a shared resolveHybridClientRouteOwner helper that mirrors the server-side pagesRouteHasPriorityOverAppRoute comparison. When Pages owns the URL, the click handler issues a window.location navigation and the prefetch path returns early. Gate the renderPagesFallback call behind a static-App-route check in handleAppRscRequest: when a static App route matches, the bridge cannot win, so skip the eager Pages entry load. Centralise the comparison in a new resolveHybridRouteOwner helper so server and client reach the same answer for the same (URL, route pair). Adds the missing client-navigation coverage to the use-params e2e fixture (Link from /app/ to /pages-dir/foobar) and unit tests for the shared owner decision. * fix(router): mirror server hybrid owner decision on the client PR review flagged two split-brain bugs in the previous hybrid ownership fix: a hand-copied client comparator and a Link-only ownership gate that left programmatic App Router navigations on the wrong path. The hand-copied routePrecedence in hybrid-client-route-owner.ts omitted the static-prefix reduction that lives in routing/utils.ts#routePrecedence, and used a strict-less-than comparison that returned App for identical dynamic patterns. The server returns Pages for both cases (Pages providers sort ahead of App providers, and routePrecedence subtracts 50 per static prefix segment). The split produced a real ownership disagreement on overlapping patterns like /_sites/:slug* (Pages) vs /:slug* (App). Add a shared compareHybridRoutePatterns to routing/utils.ts as the single source of truth: the static/dynamic short-circuits plus a sortRoutes call (which carries the static-prefix reduction and the Pages-first equal-pattern tiebreak). The server pagesRouteHasPriorityOverAppRoute and the client resolveHybridClientRouteOwner both delegate to it, so they cannot diverge. Drop the hand-copied routePrecedence entirely. Wire the ownership check at the App navigation runtime boundary so useRouter().push, useRouter().replace, gesturePush, and form submits all get the same hard-nav contract as the Link click handler. Specifically: - navigateClientSide: after same-origin normalization, if Pages owns the URL, hard-navigate via window.location and return (matching the existing external-URL branch). - _appRouter.prefetch: short-circuit RSC URL construction for Pages-owned targets so we do not warm an unusable cache entry. Centralise the existing two inline hard-nav branches in navigateClientSide into a hardNavigateTo helper for clarity. Tests: - Direct unit tests for compareHybridRoutePatterns covering identical-dynamic tiebreak, static-prefix dynamic overlap, static-prefix catch-all overlap, and infix-static bonus. - Direct unit tests for resolveHybridClientRouteOwner mirroring the server assertions plus a basePath-stripping test. - e2e: useRouter().push('/pages-dir/foobar') from an App page resolves to the Pages document. - e2e: useRouter().prefetch('/pages-dir/foobar') issues zero RSC requests for the target URL. * docs(router): fix stale score in static-prefix catch-all test comment The hand-copied comparator note quoted the dynamic-segment scores (51 / 1000) for the optional-catch-all example (/_sites/:slug* vs /:slug*). The current optional-catch-all scoring actually produces 1951 vs 2000. The test assertion was correct; only the comment was stale. * test(use-params): fix direct-load single dynamic param to use /a instead of /a/b * fix(router): compare hybrid routes structurally * fix(router): reject hybrid route conflicts * fix(router): refresh hybrid route ownership * fix(router): preserve hybrid routing lifecycle * fix(router): recheck pages routes after rewrites * fix(router): preserve rewritten pages queries * fix(router): preserve rewritten route ownership * fix(router): resolve client rewrites sequentially * fix(router): apply rewrite phases sequentially * fix(router): preserve rewrite params and endpoints * fix(router): hand off endpoint navigations * fix(router): preserve rewrite fragment params * test(e2e): avoid hybrid fixture route conflicts * test(pages): await async config validation * test(hybrid): avoid duplicate page fixtures * chore(router): clarify hybrid priority semantics * refactor(router): remove duplicate app route matcher * docs(router): clarify client hybrid comparator --------- Co-authored-by: James <james@eli.cx>
951 lines
33 KiB
TypeScript
951 lines
33 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";
|
|
|
|
// 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,
|
|
...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 }),
|
|
);
|
|
}
|
|
|
|
// 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);
|
|
});
|
|
});
|
|
|
|
// 2. Config redirect: permanent redirect → status 308 with Location
|
|
describe("config redirects", () => {
|
|
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("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);
|
|
});
|
|
});
|
|
|
|
// 4. Middleware redirect short-circuit → {type:"response"} status 307
|
|
describe("middleware", () => {
|
|
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);
|
|
});
|
|
|
|
// 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("exposes the middleware rewrite target on Pages data responses", async () => {
|
|
const result = await runPagesRequest(
|
|
makeRequest("/to-blog/post"),
|
|
baseDeps({
|
|
isDataRequest: true,
|
|
runMiddleware: makeMiddleware({
|
|
continue: true,
|
|
rewriteUrl: "/fallback-true-blog/post",
|
|
}),
|
|
renderPage: makeRenderPage(200, '{"pageProps":{"slug":"post"}}'),
|
|
}),
|
|
);
|
|
|
|
expect(result.type).toBe("response");
|
|
if (result.type !== "response") return;
|
|
expect(result.response.headers.get("x-nextjs-rewrite")).toBe("/fallback-true-blog/post");
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|
|
|
|
// 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("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("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");
|
|
});
|
|
|
|
// 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);
|
|
// API responses default a missing content-type to octet-stream, not text/html.
|
|
expect(result.defaultContentType).toBe("application/octet-stream");
|
|
});
|
|
|
|
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");
|
|
});
|
|
|
|
it("matches /api exactly", async () => {
|
|
const req = makeRequest("/api");
|
|
const result = await runPagesRequest(req, baseDeps());
|
|
expect(result.type).toBe("api");
|
|
});
|
|
});
|
|
|
|
// Public-directory static file serving (Node prod only, post-middleware).
|
|
describe("serveStaticFile", () => {
|
|
it("returns {type:'handled'} when serveStaticFile serves the request", async () => {
|
|
const renderPage = makeRenderPage(200);
|
|
const serveStaticFile = vi.fn(async () => true);
|
|
const req = makeRequest("/favicon.ico");
|
|
const result = await runPagesRequest(req, baseDeps({ serveStaticFile, renderPage }));
|
|
expect(result.type).toBe("handled");
|
|
// The static file short-circuits — the renderer is never invoked.
|
|
expect(renderPage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("falls through to render when serveStaticFile returns false", async () => {
|
|
const renderPage = makeRenderPage(200);
|
|
const serveStaticFile = vi.fn(async () => false);
|
|
const req = makeRequest("/page");
|
|
const result = await runPagesRequest(req, baseDeps({ serveStaticFile, renderPage }));
|
|
expect(serveStaticFile).toHaveBeenCalledTimes(1);
|
|
expect(result.type).toBe("response");
|
|
expect(renderPage).toHaveBeenCalled();
|
|
});
|
|
|
|
it("passes the original (pre-rewrite) pathname and staged headers", async () => {
|
|
const serveStaticFile = vi.fn(async () => true);
|
|
const middleware = makeMiddleware({ responseHeaders: [["set-cookie", "a=1"]] });
|
|
const req = makeRequest("/robots.txt");
|
|
await runPagesRequest(req, baseDeps({ serveStaticFile, runMiddleware: middleware }));
|
|
expect(serveStaticFile).toHaveBeenCalledWith("/robots.txt", { "set-cookie": ["a=1"] });
|
|
});
|
|
|
|
it("runs after middleware — a middleware redirect wins over a public file", async () => {
|
|
const serveStaticFile = vi.fn(async () => true);
|
|
const middleware = makeMiddleware({
|
|
continue: false,
|
|
redirectUrl: "/elsewhere",
|
|
redirectStatus: 307,
|
|
});
|
|
const req = makeRequest("/favicon.ico");
|
|
const result = await runPagesRequest(
|
|
req,
|
|
baseDeps({ serveStaticFile, 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(serveStaticFile).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// 13. afterFiles rewrite: dynamic page match is re-queried
|
|
describe("afterFiles rewrites", () => {
|
|
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),
|
|
);
|
|
});
|
|
});
|
|
|
|
// 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("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. preserveCredentialHeaders: isExternalUrl(resolvedUrl) → passed to applyMiddlewareRequestHeaders
|
|
describe("preserveCredentialHeaders", () => {
|
|
it("preserves credential headers when resolvedUrl is external", async () => {
|
|
// When middleware rewrites to an external URL, the Authorization header
|
|
// should be forwarded. We verify by ensuring the pipeline reaches external proxy.
|
|
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");
|
|
// Verify fetch was called (external proxy triggered)
|
|
expect(mockFetch).toHaveBeenCalled();
|
|
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");
|
|
});
|
|
});
|