Files
cloudflare__vinext/tests/app-pages-bridge.test.ts
T
James Anderson 600ba69b4d fix(routing): propagate middleware draft cookie to pages/api fallback (#1520) (#1845)
* fix(routing): propagate middleware draft cookie to pages/api fallback (#1520)

In a hybrid app (app/ + pages/api/*), requests with no matching App Router
route fall through to the Pages Router via renderPagesFallback. The bridge
forwarded middleware-mutated request headers and response headers, but dropped
the __prerender_bypass Set-Cookie emitted by draftMode().enable()/disable()
inside middleware — that cookie lives in a request-scoped side channel that
App Router route handlers and page renders read via getDraftModeCookieHeader(),
but the Pages fallback path never did.

Thread getDraftModeCookieHeader through the bridge and append the draft cookie
to the Pages Router fallback response, matching how App Router responses surface
it. Closes the draft-mode flow called out in #1520.

Closes #1520

* test(routing): cover draft-cookie fallback in app-pages-bridge unit test

Add getDraftModeCookieHeader to the renderPagesFallback dependency stub and
assert the draft cookie is appended (API + renderPage branches) and omitted
when middleware did not enable draft mode. Keeps the unit test in sync with
the new required dependency.

* docs(routing): document hybrid-dev draft-mode limitation in pages bridge

Address review feedback on #1845: clarify in app-pages-bridge that the
draft-cookie fallback covers production (inline RSC handler), while hybrid dev
runs middleware in a separate Vite Pages SSR runner where draftMode() inside
middleware is not yet permitted — a pre-existing limitation tracked separately
from #1520. The unit-test regression flagged by review was already fixed in
ae5978a4.
2026-06-08 19:34:28 +01:00

298 lines
9.9 KiB
TypeScript

import { describe, expect, it, vi } from "vite-plus/test";
import {
renderPagesFallback,
type PagesEntry,
} from "../packages/vinext/src/server/app-pages-bridge.js";
import type { AppMiddlewareContext } from "../packages/vinext/src/server/app-middleware.js";
describe("renderPagesFallback", () => {
const defaultDeps = {
loadPagesEntry: () => ({}) as PagesEntry,
buildRequestHeaders: (reqHeaders: Headers, mwHeaders: Headers) => {
const merged = new Headers(reqHeaders);
for (const [k, v] of mwHeaders) {
merged.set(k, v);
}
return merged;
},
decodePathParams: (pathname: string) => decodeURIComponent(pathname),
applyRouteHandlerMiddlewareContext: (res: Response, mwCtx: AppMiddlewareContext) => {
const mergedHeaders = new Headers(res.headers);
if (mwCtx.headers) {
for (const [k, v] of mwCtx.headers) {
mergedHeaders.set(k, v);
}
}
return new Response(res.body, {
status: mwCtx.status ?? res.status,
headers: mergedHeaders,
});
},
getDraftModeCookieHeader: (): string | null | undefined => null,
};
it("returns null for RSC requests and does not call the Pages loader", async () => {
const loadPagesEntry = vi.fn(() => ({}) as PagesEntry);
const res = await renderPagesFallback(
{
isRscRequest: true,
middlewareContext: { headers: null, requestHeaders: null, status: null },
request: new Request("http://localhost/about"),
url: new URL("http://localhost/about"),
},
{
...defaultDeps,
loadPagesEntry,
},
);
expect(res).toBeNull();
expect(loadPagesEntry).not.toHaveBeenCalled();
});
it("rebuilds request when middleware request headers are present", async () => {
const handleApiRoute = vi.fn((_req: Request, _url: string) => new Response("api"));
const deps = {
...defaultDeps,
loadPagesEntry: () => ({ handleApiRoute }) as PagesEntry,
};
const request = new Request("http://localhost/api/test", {
headers: { "x-original": "value" },
});
const url = new URL("http://localhost/api/test");
const mwRequestHeaders = new Headers({ "x-middleware": "injected" });
await renderPagesFallback(
{
isRscRequest: false,
middlewareContext: { headers: null, requestHeaders: mwRequestHeaders, status: null },
request,
url,
},
deps,
);
expect(handleApiRoute).toHaveBeenCalledTimes(1);
const forwardedReq = handleApiRoute.mock.calls[0][0];
expect(forwardedReq.headers.get("x-original")).toBe("value");
expect(forwardedReq.headers.get("x-middleware")).toBe("injected");
});
it("forwards the original request unchanged when buildRequestHeaders returns null", async () => {
const handleApiRoute = vi.fn((_req: Request, _url: string) => new Response("api"));
const buildRequestHeaders = vi.fn((_req: Headers, _mw: Headers): Headers | null => null);
const deps = {
...defaultDeps,
buildRequestHeaders,
loadPagesEntry: () => ({ handleApiRoute }) as PagesEntry,
};
const request = new Request("http://localhost/api/test", {
headers: { "x-original": "value" },
});
const url = new URL("http://localhost/api/test");
const mwRequestHeaders = new Headers({ "x-middleware": "injected" });
await renderPagesFallback(
{
isRscRequest: false,
middlewareContext: { headers: null, requestHeaders: mwRequestHeaders, status: null },
request,
url,
},
deps,
);
expect(buildRequestHeaders).toHaveBeenCalledTimes(1);
expect(handleApiRoute).toHaveBeenCalledTimes(1);
// The original request object is forwarded as-is (not rebuilt).
expect(handleApiRoute.mock.calls[0][0]).toBe(request);
const forwardedReq = handleApiRoute.mock.calls[0][0];
expect(forwardedReq.headers.get("x-original")).toBe("value");
expect(forwardedReq.headers.get("x-middleware")).toBeNull();
});
it("preserves method, body, and duplex for non-GET/HEAD requests", async () => {
const handleApiRoute = vi.fn((_req: Request, _url: string) => new Response("api"));
const deps = {
...defaultDeps,
loadPagesEntry: () => ({ handleApiRoute }) as PagesEntry,
};
const request = new Request("http://localhost/api/test", {
method: "POST",
headers: { "x-original": "value" },
body: "test-body",
});
const url = new URL("http://localhost/api/test");
const mwRequestHeaders = new Headers({ "x-middleware": "injected" });
await renderPagesFallback(
{
isRscRequest: false,
middlewareContext: { headers: null, requestHeaders: mwRequestHeaders, status: null },
request,
url,
},
deps,
);
expect(handleApiRoute).toHaveBeenCalledTimes(1);
const forwardedReq = handleApiRoute.mock.calls[0][0];
expect(forwardedReq.method).toBe("POST");
expect(await forwardedReq.text()).toBe("test-body");
});
it("routes /api and /api/* through handleApiRoute and applies middleware context", async () => {
const handleApiRoute = vi.fn((_req: Request, _url: string) => new Response("api-response"));
const mwHeaders = new Headers({ "x-res-mw": "value" });
const deps = {
...defaultDeps,
loadPagesEntry: () => ({ handleApiRoute }) as PagesEntry,
};
const request = new Request("http://localhost/api/foo/bar");
const url = new URL("http://localhost/api/foo/bar");
const res = await renderPagesFallback(
{
isRscRequest: false,
middlewareContext: { headers: mwHeaders, requestHeaders: null, status: 201 },
request,
url,
},
deps,
);
expect(handleApiRoute).toHaveBeenCalledTimes(1);
expect(handleApiRoute.mock.calls[0][1]).toBe("/api/foo/bar");
expect(res).not.toBeNull();
expect(res!.status).toBe(201);
expect(res!.headers.get("x-res-mw")).toBe("value");
expect(await res!.text()).toBe("api-response");
});
it("routes normal paths through renderPage and passes decoded pathname + search", async () => {
const renderPage = vi.fn((_req: Request, _url: string) => new Response("page-response"));
const deps = {
...defaultDeps,
loadPagesEntry: () => ({ renderPage }) as PagesEntry,
};
const request = new Request("http://localhost/about%20us?foo=bar");
const url = new URL("http://localhost/about%20us?foo=bar");
const res = await renderPagesFallback(
{
isRscRequest: false,
middlewareContext: { headers: null, requestHeaders: null, status: null },
request,
url,
},
deps,
);
expect(renderPage).toHaveBeenCalledTimes(1);
expect(renderPage.mock.calls[0][1]).toBe("/about us?foo=bar");
expect(res).not.toBeNull();
expect(await res!.text()).toBe("page-response");
});
it("appends the middleware draft cookie to an API fallback response (#1520)", async () => {
const handleApiRoute = vi.fn((_req: Request, _url: string) => new Response("api-response"));
const deps = {
...defaultDeps,
loadPagesEntry: () => ({ handleApiRoute }) as PagesEntry,
getDraftModeCookieHeader: () => "__prerender_bypass=secret; Path=/; HttpOnly",
};
const request = new Request("http://localhost/api/draft");
const url = new URL("http://localhost/api/draft");
const res = await renderPagesFallback(
{
isRscRequest: false,
middlewareContext: { headers: null, requestHeaders: null, status: null },
request,
url,
},
deps,
);
expect(res).not.toBeNull();
const setCookies = res!.headers.getSetCookie();
expect(setCookies.some((c) => c.includes("__prerender_bypass"))).toBe(true);
});
it("does not append a draft cookie when middleware did not enable draft mode", async () => {
const handleApiRoute = vi.fn((_req: Request, _url: string) => new Response("api-response"));
const deps = {
...defaultDeps,
loadPagesEntry: () => ({ handleApiRoute }) as PagesEntry,
getDraftModeCookieHeader: () => null,
};
const res = await renderPagesFallback(
{
isRscRequest: false,
middlewareContext: { headers: null, requestHeaders: null, status: null },
request: new Request("http://localhost/api/draft"),
url: new URL("http://localhost/api/draft"),
},
deps,
);
expect(res).not.toBeNull();
expect(res!.headers.has("set-cookie")).toBe(false);
});
it("appends the middleware draft cookie to a renderPage fallback response (#1520)", async () => {
const renderPage = vi.fn((_req: Request, _url: string) => new Response("page-response"));
const deps = {
...defaultDeps,
loadPagesEntry: () => ({ renderPage }) as PagesEntry,
getDraftModeCookieHeader: () => "__prerender_bypass=secret; Path=/; HttpOnly",
};
const res = await renderPagesFallback(
{
isRscRequest: false,
middlewareContext: { headers: null, requestHeaders: null, status: null },
request: new Request("http://localhost/page"),
url: new URL("http://localhost/page"),
},
deps,
);
expect(res).not.toBeNull();
expect(res!.headers.getSetCookie().some((c) => c.includes("__prerender_bypass"))).toBe(true);
});
it("returns null when Pages renderPage returns 404 status", async () => {
const renderPage = vi.fn(
(_req: Request, _url: string) => new Response("not found", { status: 404 }),
);
const deps = {
...defaultDeps,
loadPagesEntry: () => ({ renderPage }) as PagesEntry,
};
const request = new Request("http://localhost/nonexistent");
const url = new URL("http://localhost/nonexistent");
const res = await renderPagesFallback(
{
isRscRequest: false,
middlewareContext: { headers: null, requestHeaders: null, status: null },
request,
url,
},
deps,
);
expect(renderPage).toHaveBeenCalledTimes(1);
expect(res).toBeNull();
});
});