Files
cloudflare__vinext/tests/pages-api-route.test.ts
T
Nathan Nguyen 3253aafd77 fix(pages-router): pass rewrite URL to edge API requests (#1998)
* fix(pages-router): pass rewrite URL to edge API requests

* fix(pages-router): preserve edge API request URL parity

* fix(pages-router): configure edge API NextRequest

* fix(pages-router): preserve edge API locale config

* fix(pages-router): preserve edge request metadata

* fix(pages-router): align edge API URL formatting

* fix(pages-router): match API locale casing

* fix(pages-router): match domain locales by locale

* docs(pages-router): clarify edge API basePath parity

* test(deploy): expect metadata-preserving URL clone

* docs(pages-router): note basePath rewrite parity

---------

Co-authored-by: James <james@eli.cx>
2026-06-13 22:05:31 +01:00

799 lines
27 KiB
TypeScript

import { AsyncLocalStorage } from "node:async_hooks";
import { PassThrough } from "node:stream";
import { describe, expect, it, vi } from "vite-plus/test";
import {
handlePagesApiRoute,
type PagesApiRouteMatch,
} from "../packages/vinext/src/server/pages-api-route.js";
type PagesApiRouteModule = PagesApiRouteMatch["route"]["module"];
function createMatch(
handler: PagesApiRouteModule["default"],
params: Record<string, string | string[]> = {},
moduleConfig?: PagesApiRouteModule["config"],
): PagesApiRouteMatch {
return {
params,
route: {
pattern: "/api/test",
module: {
config: moduleConfig,
default: handler,
},
},
};
}
describe("pages api route", () => {
it("merges dynamic params with duplicate query-string values", async () => {
const response = await handlePagesApiRoute({
match: createMatch(
(req, res) => {
res.json(req.query);
},
{ id: "123" },
),
request: new Request("https://example.com/api/users/123?tag=a&tag=b"),
url: "/api/users/123?tag=a&tag=b",
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
id: "123",
tag: ["a", "b"],
});
});
it("keeps dynamic params ahead of same-key query-string values", async () => {
const response = await handlePagesApiRoute({
match: createMatch(
(req, res) => {
res.json(req.query);
},
{ id: "123" },
),
request: new Request("https://example.com/api/users/123?id=evil&tag=a"),
url: "/api/users/123?id=evil&tag=a",
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
id: "123",
tag: "a",
});
});
it("returns 400 with an Invalid JSON statusText for malformed JSON bodies", async () => {
const response = await handlePagesApiRoute({
match: createMatch((req, res) => {
res.json(req.body ?? null);
}),
request: new Request("https://example.com/api/parse", {
method: "POST",
headers: { "content-type": "application/json" },
body: '{"message":Invalid"}',
}),
url: "/api/parse",
});
expect(response.status).toBe(400);
expect(response.statusText).toBe("Invalid JSON");
await expect(response.text()).resolves.toBe("Invalid JSON");
});
it("preserves duplicate urlencoded keys and parses empty JSON bodies as {}", async () => {
const parseHandler = (req: { body: unknown }, res: { json: (data: unknown) => void }) => {
res.json(req.body ?? null);
};
const urlencodedResponse = await handlePagesApiRoute({
match: createMatch(parseHandler),
request: new Request("https://example.com/api/parse", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: "tag=a&tag=b&tag=c",
}),
url: "/api/parse",
});
await expect(urlencodedResponse.json()).resolves.toEqual({ tag: ["a", "b", "c"] });
const emptyJsonResponse = await handlePagesApiRoute({
match: createMatch(parseHandler),
request: new Request("https://example.com/api/parse", {
method: "POST",
headers: { "content-type": "application/json" },
body: "",
}),
url: "/api/parse",
});
await expect(emptyJsonResponse.json()).resolves.toEqual({});
});
it("sends Buffer payloads with octet-stream content-type and content-length", async () => {
const response = await handlePagesApiRoute({
match: createMatch((_req, res) => {
res.send(Buffer.from([1, 2, 3]));
}),
request: new Request("https://example.com/api/send-buffer"),
url: "/api/send-buffer",
});
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("application/octet-stream");
expect(response.headers.get("content-length")).toBe("3");
expect(Buffer.from(await response.arrayBuffer()).equals(Buffer.from([1, 2, 3]))).toBe(true);
});
it("reports thrown handler errors and returns a 500 response", async () => {
const reportRequestError = vi.fn();
const response = await handlePagesApiRoute({
match: createMatch(() => {
throw new Error("boom");
}),
reportRequestError,
request: new Request("https://example.com/api/fail"),
url: "/api/fail",
});
expect(response.status).toBe(500);
await expect(response.text()).resolves.toBe("Internal Server Error");
expect(reportRequestError).toHaveBeenCalledWith(expect.any(Error), "/api/test");
});
it("returns 413 when the API body exceeds the default size limit", async () => {
const response = await handlePagesApiRoute({
match: createMatch((_req, res) => {
res.status(200).json({ ok: true });
}),
request: new Request("https://example.com/api/parse", {
method: "POST",
headers: {
"content-length": String(2 * 1024 * 1024),
"content-type": "application/json",
},
body: "{}",
}),
url: "/api/parse",
});
expect(response.status).toBe(413);
await expect(response.text()).resolves.toBe("Request body too large");
});
it("returns 404 when match is null", async () => {
const response = await handlePagesApiRoute({
match: null,
request: new Request("https://example.com/api/not-found"),
url: "/api/not-found",
});
expect(response.status).toBe(404);
await expect(response.text()).resolves.toBe("404 - API route not found");
});
it("returns 500 when the route module has no default export", async () => {
const response = await handlePagesApiRoute({
match: {
params: {},
route: {
pattern: "/api/no-export",
module: {},
},
},
request: new Request("https://example.com/api/no-export"),
url: "/api/no-export",
});
expect(response.status).toBe(500);
await expect(response.text()).resolves.toBe("API route does not export a default function");
});
it("res.redirect() uses 307 by default and 2-arg form uses the given status", async () => {
const defaultRedirectResponse = await handlePagesApiRoute({
match: createMatch((_req, res) => {
res.redirect("/new-path");
}),
request: new Request("https://example.com/api/redir"),
url: "/api/redir",
});
expect(defaultRedirectResponse.status).toBe(307);
expect(defaultRedirectResponse.headers.get("location")).toBe("/new-path");
const customRedirectResponse = await handlePagesApiRoute({
match: createMatch((_req, res) => {
res.redirect(301, "/permanent");
}),
request: new Request("https://example.com/api/redir"),
url: "/api/redir",
});
expect(customRedirectResponse.status).toBe(301);
expect(customRedirectResponse.headers.get("location")).toBe("/permanent");
});
it("res.writeHead() lowercases header keys and joins array values", async () => {
const response = await handlePagesApiRoute({
match: createMatch((_req, res) => {
res.writeHead(200, { "X-Custom": "value", "X-Multi": ["a", "b"] });
res.end();
}),
request: new Request("https://example.com/api/headers"),
url: "/api/headers",
});
expect(response.status).toBe(200);
expect(response.headers.get("x-custom")).toBe("value");
expect(response.headers.get("x-multi")).toBe("a, b");
});
it("res.setHeader and res.getHeader round-trip correctly", async () => {
const response = await handlePagesApiRoute({
match: createMatch((_req, res) => {
res.setHeader("x-foo", "bar");
const val = res.getHeader("x-foo");
res.json({ val });
}),
request: new Request("https://example.com/api/roundtrip"),
url: "/api/roundtrip",
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ val: "bar" });
});
it("res.setHeader replaces set-cookie on repeated calls (Node.js parity)", async () => {
const response = await handlePagesApiRoute({
match: createMatch((_req, res) => {
res.setHeader("set-cookie", "session=abc");
res.setHeader("set-cookie", "session=xyz"); // should replace, not append
res.end();
}),
request: new Request("https://example.com/api/cookie"),
url: "/api/cookie",
});
expect(response.status).toBe(200);
// Only one set-cookie header — the replacement
const cookies = response.headers.getSetCookie();
expect(cookies).toEqual(["session=xyz"]);
});
it("calls edge API route handlers with a Fetch Request and returns their Response", async () => {
// Ported from Next.js: test/e2e/edge-async-local-storage/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/edge-async-local-storage/index.test.ts
const response = await handlePagesApiRoute({
match: createMatch(
(request: Request) => {
const id = request.headers.get("req-id");
return Response.json({ id });
},
{},
{ runtime: "edge" },
),
request: new Request("https://example.com/api/test", {
headers: { "req-id": "req-42" },
}),
url: "/api/test",
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ id: "req-42" });
});
it("passes a NextRequest with nextUrl.searchParams to edge API handlers", async () => {
// Ported from Next.js: test/e2e/edge-pages-support/app/pages/api/hello.js
// https://github.com/vercel/next.js/blob/canary/test/e2e/edge-pages-support/app/pages/api/hello.js
// Next.js wraps the request in a NextRequest before invoking the user's
// edge API handler, so handlers can use `req.nextUrl.searchParams`.
const response = await handlePagesApiRoute({
match: createMatch(
(request: Request) => {
const nextUrl = (request as Request & { nextUrl?: URL }).nextUrl;
if (!nextUrl) {
return new Response("missing nextUrl", { status: 500 });
}
return Response.json({
hello: "world",
query: Object.fromEntries(nextUrl.searchParams),
});
},
{},
{ runtime: "edge" },
),
request: new Request("https://example.com/api/hello?a=b"),
url: "/api/hello?a=b",
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
hello: "world",
query: { a: "b" },
});
});
it("passes the resolved query while preserving the original edge API pathname", async () => {
// 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
//
// Upstream middleware mutates request.nextUrl.searchParams and rewrites to
// the same edge Pages API route. The handler's req.nextUrl must reflect the
// resolved rewrite URL, not the original incoming Request URL.
const response = await handlePagesApiRoute({
match: createMatch(
(request: Request) => {
const nextUrl = (request as Request & { nextUrl?: URL }).nextUrl;
if (!nextUrl) {
return new Response("missing nextUrl", { status: 500 });
}
return Response.json({
pathname: nextUrl.pathname,
query: Object.fromEntries(nextUrl.searchParams),
});
},
{},
{ runtime: "edge" },
),
request: new Request("https://example.com/public-edge-path?a=b"),
url: "/api/edge-search-params?a=b&foo=bar",
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
pathname: "/public-edge-path",
query: {
a: "b",
foo: "bar",
},
});
});
it("includes dynamic route params in edge API nextUrl.searchParams", async () => {
// Ported from Next.js: test/e2e/edge-pages-support/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/edge-pages-support/index.test.ts
const response = await handlePagesApiRoute({
match: createMatch(
(request: Request) => {
const nextUrl = (request as Request & { nextUrl?: URL }).nextUrl;
if (!nextUrl) {
return new Response("missing nextUrl", { status: 500 });
}
return Response.json(Object.fromEntries(nextUrl.searchParams));
},
{ id: "id-1" },
{ runtime: "edge" },
),
request: new Request("https://example.com/api/id-1?a=b"),
url: "/api/id-1?a=b",
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
a: "b",
id: "id-1",
});
});
it("applies basePath and i18n config to edge API nextUrl", async () => {
const response = await handlePagesApiRoute({
match: createMatch(
(request: Request) => {
const nextUrl = (
request as Request & {
nextUrl?: { basePath: string; locale: string; pathname: string };
}
).nextUrl;
return Response.json({
basePath: nextUrl?.basePath,
locale: nextUrl?.locale,
pathname: nextUrl?.pathname,
});
},
{},
{ runtime: "edge" },
),
nextConfig: {
basePath: "/docs",
i18n: { defaultLocale: "en", locales: ["en", "fr"] },
},
request: new Request("https://example.com/docs/fr/api/hello?a=b"),
url: "/fr/api/hello?a=b",
});
await expect(response.json()).resolves.toMatchObject({
basePath: "/docs",
locale: "fr",
pathname: "/api/hello",
});
});
it("preserves edge API request properties when applying the resolved URL", async () => {
const request = new Request("https://example.com/api/edge-search-params?a=b", {
body: "payload",
headers: { "x-test": "1" },
method: "POST",
});
Object.defineProperty(request, "cf", {
configurable: true,
enumerable: true,
value: { country: "US" },
});
const response = await handlePagesApiRoute({
match: createMatch(
async (request: Request) => {
const nextUrl = (request as Request & { nextUrl?: URL }).nextUrl;
if (!nextUrl) {
return new Response("missing nextUrl", { status: 500 });
}
return Response.json({
body: await request.text(),
header: request.headers.get("x-test"),
method: request.method,
query: Object.fromEntries(nextUrl.searchParams),
cf: Reflect.get(request, "cf"),
});
},
{},
{ runtime: "edge" },
),
request,
url: "/api/edge-search-params?a=b&foo=bar",
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
body: "payload",
cf: { country: "US" },
header: "1",
method: "POST",
query: {
a: "b",
foo: "bar",
},
});
});
it("recognises bare \"export const runtime = 'edge'\" as an edge API route", async () => {
// Ported from Next.js: packages/next/src/build/analysis/get-page-static-info.ts
// Both `export const runtime = "edge"` and `export const config = { runtime: "edge" }`
// are valid ways to mark a Pages Router API route as edge. Next.js resolves
// via `config.runtime ?? config.config?.runtime`.
const response = await handlePagesApiRoute({
match: {
params: {},
route: {
pattern: "/api/edge-bare",
module: {
runtime: "edge",
default: (request: Request) => Response.json({ ok: true, kind: typeof request }),
} as unknown as PagesApiRouteModule,
},
},
request: new Request("https://example.com/api/edge-bare"),
url: "/api/edge-bare",
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ ok: true, kind: "object" });
});
it("preserves nested AsyncLocalStorage state across concurrent edge API requests", async () => {
// Ported from Next.js: test/e2e/edge-async-local-storage/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/edge-async-local-storage/index.test.ts
const topStorage = new AsyncLocalStorage<{ id: string }>();
const ids = Array.from({ length: 100 }, (_, i) => `req-${i}`);
const responses = await Promise.all(
ids.map((id) =>
handlePagesApiRoute({
match: createMatch(
(request: Request) => {
const requestId = request.headers.get("req-id") ?? "";
return topStorage.run({ id: requestId }, async () => {
const nestedStorage = new AsyncLocalStorage<string>();
const nested = await nestedStorage.run(`nested-${requestId}`, async () => {
await Promise.resolve();
return { nestedId: nestedStorage.getStore() };
});
await Promise.resolve();
return Response.json({ ...nested, ...topStorage.getStore() });
});
},
{},
{ runtime: "experimental-edge" },
),
request: new Request("https://example.com/api/test", {
headers: { "req-id": id },
}),
url: "/api/test",
}),
),
);
for (const [index, response] of responses.entries()) {
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
id: ids[index],
nestedId: `nested-${ids[index]}`,
});
}
});
it("auto-ends the response when a handler returns a non-stream value and does not call res.end()", async () => {
// Regression: handlers that return a plain value (e.g. a number) and
// forget to call res.end() must not hang the request. Auto-end is
// gated on the "pipe" event only — return values do not defer it.
const response = await handlePagesApiRoute({
match: createMatch((_req, res) => {
res.status(202);
return 42;
}),
request: new Request("https://example.com/api/non-stream-return"),
url: "/api/non-stream-return",
});
expect(response.status).toBe(202);
await expect(response.text()).resolves.toBe("");
});
it("auto-ends when a handler returns the response object without piping or sending", async () => {
// Regression: chainable helpers like `return res.status(202)` return
// `this` (the response object), but that does not mean streaming is
// in progress. Only the "pipe" event should defer auto-ending.
const response = await handlePagesApiRoute({
match: createMatch((_req, res) => res.status(202)),
request: new Request("https://example.com/api/return-res-status"),
url: "/api/return-res-status",
});
expect(response.status).toBe(202);
await expect(response.text()).resolves.toBe("");
});
it("streams multi-chunk res.write() / res.end() through a ReadableStream body", async () => {
let headersSentAfterWrite = false;
const response = await handlePagesApiRoute({
match: createMatch((_req, res) => {
res.write("chunk-1");
headersSentAfterWrite = res.headersSent;
res.write("chunk-2");
res.write("chunk-3");
res.end();
}),
request: new Request("https://example.com/api/multi-chunk"),
url: "/api/multi-chunk",
});
expect(response.status).toBe(200);
expect(headersSentAfterWrite).toBe(true);
await expect(response.text()).resolves.toBe("chunk-1chunk-2chunk-3");
});
it("does not accumulate chunks after the response body is cancelled", async () => {
let resRef: any;
const response = await handlePagesApiRoute({
match: createMatch((_req, res) => {
resRef = res;
res.write("first");
// Keep the stream open so the test can cancel the body before the
// Node side finishes.
}),
request: new Request("https://example.com/api/cancel"),
url: "/api/cancel",
});
const reader = response.body!.getReader();
const { value } = await reader.read();
expect(new TextDecoder().decode(value)).toBe("first");
await reader.cancel();
// After the Fetch body is cancelled, the Node-compatible writable must
// reject further writes instead of silently buffering them.
const writeErr = await new Promise<Error | null>((resolve) => {
resRef.write("second", (err: Error | null) => resolve(err));
});
expect(writeErr).toBeInstanceOf(Error);
expect(writeErr!.message).toMatch(/Cannot call write after a stream was destroyed/);
});
it("returns a 500 when the response stream is destroyed with an error before any body has been written", async () => {
const reportRequestError = vi.fn();
const response = await handlePagesApiRoute({
match: createMatch(
(_req, res) => {
// Simulate a proxy handler where the upstream errors and the
// handler forwards the error to the response stream before
// anything is written. In this case the responsePromise should
// reject and the normal 500 error path in handlePagesApiRoute
// should surface.
res.destroy(new Error("upstream exploded"));
return res;
},
{},
{ api: { bodyParser: false } },
),
reportRequestError,
request: new Request("https://example.com/api/stream-error", {
method: "POST",
body: "some-body",
}),
url: "/api/stream-error",
});
expect(response.status).toBe(500);
await expect(response.text()).resolves.toBe("Internal Server Error");
expect(reportRequestError).toHaveBeenCalledWith(expect.any(Error), "/api/test");
});
it("does not hang when the response stream is destroyed after partial output has started", async () => {
// Regression: after the first write, the Fetch Response has already been
// resolved. Destroying the Node-compatible res must error the body
// ReadableStream so the consumer sees a failure instead of an open
// stream that never terminates.
const response = await handlePagesApiRoute({
match: createMatch(
(_req, res) => {
res.write("partial");
res.destroy(new Error("upstream exploded"));
return res;
},
{},
{ api: { bodyParser: false } },
),
request: new Request("https://example.com/api/stream-error-partial"),
url: "/api/stream-error-partial",
});
expect(response.status).toBe(200);
// The body stream should reject because it was destroyed mid-stream.
await expect(response.text()).rejects.toThrow("upstream exploded");
});
it("does not hang when res.destroy() is called without an error before any body is written", async () => {
// Regression: destroy() with no error before resolveOnce() must still
// resolve the response promise so the request does not hang.
const response = await handlePagesApiRoute({
match: createMatch((_req, res) => {
res.destroy();
return res;
}),
request: new Request("https://example.com/api/destroy-no-error"),
url: "/api/destroy-no-error",
});
expect(response.status).toBe(200);
await expect(response.text()).resolves.toBe("");
});
it("settles the response only once when res.destroy() is called repeatedly", async () => {
const response = await handlePagesApiRoute({
match: createMatch((_req, res) => {
res.destroy();
res.destroy(new Error("second destroy must be ignored"));
return res;
}),
request: new Request("https://example.com/api/destroy-idempotent"),
url: "/api/destroy-idempotent",
});
expect(response.status).toBe(200);
await expect(response.text()).resolves.toBe("");
});
it("does not auto-end when the handler pipes into res without returning it", async () => {
const body = "raw-body-data";
const response = await handlePagesApiRoute({
match: createMatch(
(req, res) => {
// Handler pipes req through a transform into res, matching the
// real-world pattern: a webhook handler forwarding the raw body
// upstream without returning the pipe chain.
req.pipe(new PassThrough()).pipe(res);
// Note: does NOT return res — handler returns undefined.
},
{},
{ api: { bodyParser: false } },
),
request: new Request("https://example.com/api/pipe-no-return", {
method: "POST",
body,
}),
url: "/api/pipe-no-return",
});
expect(response.status).toBe(200);
await expect(response.text()).resolves.toBe(body);
});
it("streams a piped request body through to the response", async () => {
const body = "piped-body-content";
const response = await handlePagesApiRoute({
match: createMatch(
(req, res) => {
// Handler pipes req directly to res and returns the result,
// matching the fixture pattern in the upstream PR.
const result = req.pipe(new PassThrough()).pipe(res);
return result;
},
{},
{ api: { bodyParser: false } },
),
request: new Request("https://example.com/api/pipe-with-return", {
method: "POST",
body,
}),
url: "/api/pipe-with-return",
});
expect(response.status).toBe(200);
await expect(response.text()).resolves.toBe(body);
});
it("exposes the raw body as a byte-mode stream of Buffer chunks", async () => {
// Parity: Node `IncomingMessage` is a non-objectMode stream yielding
// `Buffer`s. Handlers routinely call `chunk.toString("utf8")` per chunk
// (webhook signature verification etc.) — on a raw Uint8Array that
// comma-joins byte values instead of decoding.
const body = "buffer-parity-body";
let objectMode: boolean | undefined;
let allBuffers = true;
const response = await handlePagesApiRoute({
match: createMatch(
async (req, res) => {
objectMode = req.readableObjectMode;
let text = "";
for await (const chunk of req) {
allBuffers &&= Buffer.isBuffer(chunk);
text += chunk.toString("utf8");
}
res.send(text);
},
{},
{ api: { bodyParser: false } },
),
request: new Request("https://example.com/api/buffer-chunks", {
method: "POST",
body,
}),
url: "/api/buffer-chunks",
});
expect(response.status).toBe(200);
expect(objectMode).toBe(false);
expect(allBuffers).toBe(true);
await expect(response.text()).resolves.toBe(body);
});
it("does not auto-end when config.api.externalResolver is true and the handler responds late", async () => {
// Parity: Next.js never auto-ends a response — `externalResolver: true`
// is the documented contract for handlers (e.g. proxy middleware) that
// send the response after the handler's promise settles. Without the
// flag the auto-end safety net would resolve an empty 200 first.
const response = await handlePagesApiRoute({
match: createMatch(
(_req, res) => {
setTimeout(() => {
res.status(201).json({ late: true });
}, 10);
// Returns before anything is written or piped.
},
{},
{ api: { externalResolver: true } },
),
request: new Request("https://example.com/api/external-resolver"),
url: "/api/external-resolver",
});
expect(response.status).toBe(201);
await expect(response.json()).resolves.toEqual({ late: true });
});
});