diff --git a/packages/vinext/src/server/edge-runtime-globals.ts b/packages/vinext/src/server/edge-runtime-globals.ts new file mode 100644 index 000000000..175104403 --- /dev/null +++ b/packages/vinext/src/server/edge-runtime-globals.ts @@ -0,0 +1,12 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +type EdgeRuntimeGlobal = typeof globalThis & { + AsyncLocalStorage?: typeof AsyncLocalStorage; +}; + +export function installEdgeRuntimeGlobals(target: typeof globalThis = globalThis): void { + const edgeGlobal = target as EdgeRuntimeGlobal; + edgeGlobal.AsyncLocalStorage ??= AsyncLocalStorage; +} + +installEdgeRuntimeGlobals(); diff --git a/packages/vinext/src/server/pages-api-route.ts b/packages/vinext/src/server/pages-api-route.ts index a8abad0fd..e7963ae60 100644 --- a/packages/vinext/src/server/pages-api-route.ts +++ b/packages/vinext/src/server/pages-api-route.ts @@ -1,17 +1,25 @@ import type { Route } from "../routing/pages-router.js"; -import { mergeRouteParamsIntoQuery, parseQueryString } from "../utils/query.js"; +import { addQueryParam } from "../utils/query.js"; +import { NextRequest } from "vinext/shims/server"; import { createPagesReqRes, + parsePagesBodySizeLimit, parsePagesApiBody, type PagesRequestQuery, type PagesReqResRequest, type PagesReqResResponse, PagesApiBodyParseError, } from "./pages-node-compat.js"; -import { internalServerErrorResponse } from "./http-error-responses.js"; type PagesApiRouteModule = { - default?: (req: PagesReqResRequest, res: PagesReqResResponse) => void | Promise; + config?: { + api?: { + bodyParser?: false | { sizeLimit?: number | string }; + }; + runtime?: string; + }; + runtime?: string; + default?: unknown; }; export type PagesApiRouteMatch = { @@ -23,13 +31,70 @@ export type PagesApiRouteMatch = { type HandlePagesApiRouteOptions = { match: PagesApiRouteMatch | null; + onRevalidate?: ( + urlPath: string, + options?: { unstable_onlyGenerated?: boolean }, + ) => Promise | void; reportRequestError?: (error: Error, routePattern: string) => void | Promise; request: Request; url: string; }; +const warnedEdgeRuntimeRoutes = new Set(); + +function normalizeEdgeRuntimeResponse(response: Response): Response { + if (!response.headers.has("content-encoding") && !response.headers.has("content-length")) { + return response; + } + + const headers = new Headers(response.headers); + // Node's fetch decodes compressed upstream bodies but keeps the original + // encoding headers. The deploy harness runs Pages edge routes in Node, so + // strip body metadata before the production server optionally recompresses. + headers.delete("content-encoding"); + headers.delete("content-length"); + + return new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }); +} + function buildPagesApiQuery(url: string, params: PagesRequestQuery): PagesRequestQuery { - return mergeRouteParamsIntoQuery(parseQueryString(url), params); + const query: PagesRequestQuery = { ...params }; + const search = url.split("?")[1]; + if (!search) { + return query; + } + + for (const [key, value] of new URLSearchParams(search)) { + addQueryParam(query, key, value); + } + + return query; +} + +function appendRouteParams(searchParams: URLSearchParams, params: PagesRequestQuery): void { + for (const [key, value] of Object.entries(params)) { + if (Array.isArray(value)) { + for (const item of value) { + searchParams.append(key, item); + } + } else { + searchParams.append(key, value); + } + } +} + +function requestWithResolvedUrl( + request: Request, + url: string, + params: PagesRequestQuery = {}, +): Request { + const resolvedUrl = new URL(url, request.url); + appendRouteParams(resolvedUrl.searchParams, params); + return new Request(resolvedUrl, request); } export async function handlePagesApiRoute(options: HandlePagesApiRouteOptions): Promise { @@ -44,17 +109,51 @@ export async function handlePagesApiRoute(options: HandlePagesApiRouteOptions): } try { + const runtime = route.module.config?.runtime ?? route.module.runtime; + if (runtime === "edge" || runtime === "experimental-edge") { + if (!warnedEdgeRuntimeRoutes.has(route.pattern)) { + warnedEdgeRuntimeRoutes.add(route.pattern); + console.warn( + `[vinext] Pages API route ${route.pattern} exports config.runtime = "edge". ` + + "vinext does not implement Next.js Edge Runtime isolation; this route will run " + + "as a best-effort Web Request/Response handler on the normal vinext runtime. " + + "Prefer the default Node.js Pages API runtime, or migrate request-boundary logic " + + "to proxy.ts. See https://nextjs.org/blog/next-16#proxyts-formerly-middlewarets", + ); + } + const resolvedRequest = requestWithResolvedUrl(options.request, options.url, params); + const edgeRequest = + resolvedRequest instanceof NextRequest ? resolvedRequest : new NextRequest(resolvedRequest); + const edgeHandler = handler as (req: NextRequest) => unknown; + const result = await edgeHandler(edgeRequest); + if (result instanceof Response) { + return normalizeEdgeRuntimeResponse(result); + } + return new Response(null, { status: 204 }); + } + const query = buildPagesApiQuery(options.url, params); - const body = await parsePagesApiBody(options.request); - const { req, res, responsePromise } = createPagesReqRes({ + const apiConfig = route.module.config?.api; + const shouldParseBody = apiConfig?.bodyParser !== false; + const sizeLimit = + shouldParseBody && typeof apiConfig?.bodyParser === "object" + ? parsePagesBodySizeLimit(apiConfig.bodyParser.sizeLimit) + : undefined; + const body = shouldParseBody ? await parsePagesApiBody(options.request, sizeLimit) : undefined; + const { isResponsePiped, req, res, responsePromise } = createPagesReqRes({ body, + onRevalidate: options.onRevalidate, + preserveRequestBodyStream: !shouldParseBody, query, request: options.request, url: options.url, }); - await handler(req, res); - res.end(); + const nodeHandler = handler as (req: PagesReqResRequest, res: PagesReqResResponse) => unknown; + await nodeHandler(req, res); + if (!res.headersSent && !isResponsePiped()) { + res.end(); + } return await responsePromise; } catch (error) { if (error instanceof PagesApiBodyParseError) { @@ -68,6 +167,6 @@ export async function handlePagesApiRoute(options: HandlePagesApiRouteOptions): error instanceof Error ? error : new Error(String(error)), route.pattern, ); - return internalServerErrorResponse(); + return new Response("Internal Server Error", { status: 500 }); } } diff --git a/packages/vinext/src/server/pages-node-compat.ts b/packages/vinext/src/server/pages-node-compat.ts index db52e1f99..b161dc199 100644 --- a/packages/vinext/src/server/pages-node-compat.ts +++ b/packages/vinext/src/server/pages-node-compat.ts @@ -1,6 +1,6 @@ import { decode as decodeQueryString } from "node:querystring"; +import { Readable, Writable } from "node:stream"; import { parseCookies } from "../config/config-matchers.js"; -import { readStreamAsTextWithLimit } from "../utils/text-stream.js"; import { PagesBodyParseError, getMediaType, isJsonMediaType } from "./pages-media-type.js"; const MAX_PAGES_API_BODY_SIZE = 1 * 1024 * 1024; @@ -20,7 +20,8 @@ export type PagesReqResRequest = { query: PagesRequestQuery; body: unknown; cookies: Record; -}; + [Symbol.asyncIterator]: () => AsyncIterableIterator; +} & Readable; export type PagesReqResHeaders = { [key: string]: string | number | boolean | string[]; @@ -32,22 +33,32 @@ export type PagesReqResResponse = { writeHead: (code: number, headers?: PagesReqResHeaders) => PagesReqResResponse; setHeader: (name: string, value: string | number | boolean | string[]) => PagesReqResResponse; getHeader: (name: string) => string | number | boolean | string[] | undefined; + write: (chunk: string | Uint8Array | Buffer) => boolean; end: (data?: BodyInit | null) => void; status: (code: number) => PagesReqResResponse; json: (data: unknown) => void; send: (data: unknown) => void; redirect: (statusOrUrl: number | string, url?: string) => void; + setPreviewData: (data: unknown) => PagesReqResResponse; + clearPreviewData: () => PagesReqResResponse; + revalidate: (urlPath: string, options?: { unstable_onlyGenerated?: boolean }) => Promise; getHeaders: () => PagesReqResHeaders; -}; +} & Writable; type CreatePagesReqResOptions = { body: unknown; + onRevalidate?: ( + urlPath: string, + options?: { unstable_onlyGenerated?: boolean }, + ) => Promise | void; + preserveRequestBodyStream?: boolean; query: PagesRequestQuery; request: Request; url: string; }; type CreatePagesReqResResult = { + isResponsePiped: () => boolean; req: PagesReqResRequest; res: PagesReqResResponse; responsePromise: Promise; @@ -58,9 +69,56 @@ async function readPagesRequestBodyWithLimit(request: Request, maxBytes: number) return ""; } - return readStreamAsTextWithLimit(request.body, maxBytes, () => { - throw new PagesBodyParseError("Request body too large", 413); - }); + const reader = request.body.getReader(); + const decoder = new TextDecoder(); + const chunks: string[] = []; + let totalSize = 0; + + for (;;) { + const result = await reader.read(); + if (result.done) { + break; + } + + totalSize += result.value.byteLength; + if (totalSize > maxBytes) { + await reader.cancel(); + throw new PagesBodyParseError("Request body too large", 413); + } + + chunks.push(decoder.decode(result.value, { stream: true })); + } + + chunks.push(decoder.decode()); + return chunks.join(""); +} + +export function parsePagesBodySizeLimit( + sizeLimit: number | string | undefined, + fallback = MAX_PAGES_API_BODY_SIZE, +): number { + if (typeof sizeLimit === "number" && Number.isFinite(sizeLimit) && sizeLimit >= 0) { + return sizeLimit; + } + + if (typeof sizeLimit !== "string") { + return fallback; + } + + const match = sizeLimit + .trim() + .toLowerCase() + .match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/); + if (!match) { + return fallback; + } + + const value = Number.parseFloat(match[1]); + const unit = match[2] ?? "b"; + const multiplier = + unit === "gb" ? 1024 * 1024 * 1024 : unit === "mb" ? 1024 * 1024 : unit === "kb" ? 1024 : 1; + + return Math.floor(value * multiplier); } export async function parsePagesApiBody( @@ -112,36 +170,91 @@ export function createPagesReqRes(options: CreatePagesReqResOptions): CreatePage headersObj[key.toLowerCase()] = value; } - const req: PagesReqResRequest = { - method: options.request.method, - url: options.url, - headers: headersObj, - query: options.query, - body: options.body, - cookies: parseCookies(options.request.headers.get("cookie")), - }; + const reqStream = + options.preserveRequestBodyStream && options.request.body + ? Readable.fromWeb( + options.request.body as import("node:stream/web").ReadableStream, + ) + : Readable.from([]); + const req = reqStream as PagesReqResRequest; + req.method = options.request.method; + req.url = options.url; + req.headers = headersObj; + req.query = options.query; + req.body = options.body; + req.cookies = parseCookies(options.request.headers.get("cookie")); let resStatusCode = 200; const resHeaders: Record = {}; const setCookieHeaders: string[] = []; - let resBody: BodyInit | null = null; + const resBodyChunks: Buffer[] = []; + let responsePiped = false; let ended = false; let resolveResponse!: (value: Response) => void; const responsePromise = new Promise((resolve) => { resolveResponse = resolve; }); - const res: PagesReqResResponse = { - get statusCode() { - return resStatusCode; + function normalizeResponseChunk(data: BodyInit): Buffer { + if (Buffer.isBuffer(data)) return data; + if (data instanceof Uint8Array) return Buffer.from(data); + if (data instanceof ArrayBuffer) return Buffer.from(data); + if (typeof data === "string") return Buffer.from(data); + if (data instanceof URLSearchParams) return Buffer.from(data.toString()); + return Buffer.from(JSON.stringify(data) ?? ""); + } + + function resolveOnce(): void { + if (ended) { + return; + } + ended = true; + const headers = new Headers(); + for (const [key, value] of Object.entries(resHeaders)) { + headers.set(key, String(value)); + } + for (const cookie of setCookieHeaders) { + headers.append("set-cookie", cookie); + } + const body = resBodyChunks.length > 0 ? Buffer.concat(resBodyChunks) : null; + resolveResponse(new Response(body, { status: resStatusCode, headers })); + } + + const resStream = new Writable({ + write(chunk, _encoding, callback) { + if (chunk !== undefined && chunk !== null) { + resBodyChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + callback(); }, - set statusCode(code) { - resStatusCode = code; + }); + resStream.on("pipe", () => { + responsePiped = true; + }); + resStream.on("finish", resolveOnce); + + const streamWrite = resStream.write.bind(resStream); + const streamEnd = resStream.end.bind(resStream); + const res = resStream as PagesReqResResponse; + + Object.defineProperties(res, { + statusCode: { + get() { + return resStatusCode; + }, + set(code: number) { + resStatusCode = code; + }, }, - get headersSent() { - return ended; + headersSent: { + get() { + return ended || res.writableEnded; + }, }, - writeHead(code, headers) { + }); + + Object.assign(res, { + writeHead(code: number, headers?: PagesReqResHeaders) { resStatusCode = code; if (headers) { for (const [key, value] of Object.entries(headers)) { @@ -158,7 +271,10 @@ export function createPagesReqRes(options: CreatePagesReqResOptions): CreatePage } return res; }, - setHeader(name, value) { + write(chunk: string | Uint8Array | Buffer) { + return streamWrite(chunk); + }, + setHeader(name: string, value: string | number | boolean | string[]) { if (name.toLowerCase() === "set-cookie") { // Node.js res.setHeader() replaces the existing value entirely. setCookieHeaders.length = 0; @@ -172,38 +288,31 @@ export function createPagesReqRes(options: CreatePagesReqResOptions): CreatePage } return res; }, - getHeader(name) { + getHeader(name: string) { if (name.toLowerCase() === "set-cookie") { return setCookieHeaders.length > 0 ? setCookieHeaders : undefined; } return resHeaders[name.toLowerCase()]; }, - end(data) { - if (ended) { + end(data?: BodyInit | null) { + if (ended || res.writableEnded) { return; } - ended = true; if (data !== undefined && data !== null) { - resBody = data; + resBodyChunks.push(normalizeResponseChunk(data)); } - const headers = new Headers(); - for (const [key, value] of Object.entries(resHeaders)) { - headers.set(key, String(value)); - } - for (const cookie of setCookieHeaders) { - headers.append("set-cookie", cookie); - } - resolveResponse(new Response(resBody, { status: resStatusCode, headers })); + streamEnd(); + resolveOnce(); }, - status(code) { + status(code: number) { resStatusCode = code; return res; }, - json(data) { + json(data: unknown) { resHeaders["content-type"] = "application/json"; res.end(JSON.stringify(data)); }, - send(data) { + send(data: unknown) { if (Buffer.isBuffer(data)) { if (!resHeaders["content-type"]) { resHeaders["content-type"] = "application/octet-stream"; @@ -224,7 +333,7 @@ export function createPagesReqRes(options: CreatePagesReqResOptions): CreatePage } res.end(String(data)); }, - redirect(statusOrUrl, url) { + redirect(statusOrUrl: number | string, url?: string) { if (typeof statusOrUrl === "string") { res.writeHead(307, { Location: statusOrUrl }); } else { @@ -232,6 +341,20 @@ export function createPagesReqRes(options: CreatePagesReqResOptions): CreatePage } res.end(); }, + setPreviewData(data: unknown) { + const encoded = Buffer.from(JSON.stringify(data)).toString("base64url"); + setCookieHeaders.push(`__prerender_bypass=vinext-preview; Path=/; SameSite=Lax`); + setCookieHeaders.push(`__next_preview_data=${encoded}; Path=/; SameSite=Lax; HttpOnly`); + return res; + }, + clearPreviewData() { + setCookieHeaders.push(`__prerender_bypass=; Path=/; Max-Age=0; SameSite=Lax`); + setCookieHeaders.push(`__next_preview_data=; Path=/; Max-Age=0; SameSite=Lax; HttpOnly`); + return res; + }, + async revalidate(urlPath: string, revalidateOptions?: { unstable_onlyGenerated?: boolean }) { + await options.onRevalidate?.(urlPath, revalidateOptions); + }, getHeaders() { const headers: PagesReqResHeaders = { ...resHeaders }; if (setCookieHeaders.length > 0) { @@ -239,7 +362,7 @@ export function createPagesReqRes(options: CreatePagesReqResOptions): CreatePage } return headers; }, - }; + }); - return { req, res, responsePromise }; + return { isResponsePiped: () => responsePiped, req, res, responsePromise }; } diff --git a/tests/pages-api-route.test.ts b/tests/pages-api-route.test.ts index 9c7e1897a..36819bd75 100644 --- a/tests/pages-api-route.test.ts +++ b/tests/pages-api-route.test.ts @@ -1,18 +1,28 @@ +import { Transform } 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"; +import type { + PagesReqResRequest, + PagesReqResResponse, +} from "../packages/vinext/src/server/pages-node-compat.js"; +import type { NextRequest } from "../packages/vinext/src/shims/server.js"; + +type TestPagesApiHandler = (req: PagesReqResRequest, res: PagesReqResResponse) => unknown; function createMatch( - handler: PagesApiRouteMatch["route"]["module"]["default"], + handler: TestPagesApiHandler, params: Record = {}, + moduleOverrides: Partial = {}, ): PagesApiRouteMatch { return { params, route: { pattern: "/api/test", module: { + ...moduleOverrides, default: handler, }, }, @@ -39,23 +49,179 @@ describe("pages api route", () => { }); }); - it("keeps dynamic params ahead of same-key query-string values", async () => { + it("calls edge runtime Pages API routes with NextRequest", async () => { + // Ported from Next.js deploy fixture: + // test/e2e/middleware-general/app/pages/api/edge-search-params.js + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const response = await handlePagesApiRoute({ - match: createMatch( - (req, res) => { - res.json(req.query); + match: { + params: {}, + route: { + pattern: "/api/edge-search-params", + module: { + config: { runtime: "edge" }, + default(req: NextRequest) { + return Response.json(Object.fromEntries((req as any).nextUrl.searchParams)); + }, + }, }, - { id: "123" }, - ), - request: new Request("https://example.com/api/users/123?id=evil&tag=a"), - url: "/api/users/123?id=evil&tag=a", + }, + request: new Request("https://example.com/api/edge-search-params?hello=world"), + url: "/api/edge-search-params?hello=world&foo=bar", }); expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ - id: "123", - tag: "a", + await expect(response.json()).resolves.toEqual({ foo: "bar", hello: "world" }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('config.runtime = "edge"')); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("https://nextjs.org/blog/next-16")); + warn.mockRestore(); + }); + + it("adds dynamic params to edge runtime Pages API nextUrl search params", 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 warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const response = await handlePagesApiRoute({ + match: { + params: { id: "id-1" }, + route: { + pattern: "/api/[id]", + module: { + config: { runtime: "edge" }, + default(req: NextRequest) { + return Response.json(Object.fromEntries((req as any).nextUrl.searchParams)); + }, + }, + }, + }, + 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" }); + warn.mockRestore(); + }); + + it("exposes AsyncLocalStorage as an edge runtime global", 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 descriptor = Object.getOwnPropertyDescriptor(globalThis, "AsyncLocalStorage"); + Reflect.deleteProperty(globalThis, "AsyncLocalStorage"); + + try { + const { installEdgeRuntimeGlobals } = + await import("../packages/vinext/src/server/edge-runtime-globals.js"); + installEdgeRuntimeGlobals(); + + const AsyncLocalStorageGlobal = ( + globalThis as typeof globalThis & { + AsyncLocalStorage: new () => { + getStore(): T | undefined; + run(store: T, callback: () => R): R; + }; + } + ).AsyncLocalStorage; + const storage = new AsyncLocalStorageGlobal<{ id: string }>(); + + await storage.run({ id: "req-1" }, async () => { + await Promise.resolve(); + expect(storage.getStore()).toEqual({ id: "req-1" }); + }); + } finally { + if (descriptor) { + Object.defineProperty(globalThis, "AsyncLocalStorage", descriptor); + } else { + Reflect.deleteProperty(globalThis, "AsyncLocalStorage"); + } + } + }); + + it("recognizes top-level runtime = edge exports", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const response = await handlePagesApiRoute({ + match: { + params: {}, + route: { + pattern: "/api/top-level-edge", + module: { + runtime: "edge", + default() { + return Response.json({ ok: true }); + }, + }, + }, + }, + request: new Request("https://example.com/api/top-level-edge"), + url: "/api/top-level-edge", + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ ok: true }); + warn.mockRestore(); + }); + + it("treats config.runtime = experimental-edge as an edge-style Pages API route", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const response = await handlePagesApiRoute({ + match: { + params: {}, + route: { + pattern: "/api/experimental-edge", + module: { + config: { runtime: "experimental-edge" }, + default() { + return Response.json({ ok: true }); + }, + }, + }, + }, + request: new Request("https://example.com/api/experimental-edge"), + url: "/api/experimental-edge", + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ ok: true }); + warn.mockRestore(); + }); + + it("strips encoded body headers from edge runtime Pages API responses", async () => { + // Ported from Next.js: test/e2e/edge-compiler-can-import-blob-assets/index.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/edge-compiler-can-import-blob-assets/index.test.ts + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const response = await handlePagesApiRoute({ + match: { + params: {}, + route: { + pattern: "/api/edge", + module: { + config: { runtime: "edge" }, + default() { + return new Response("Example Domain", { + headers: { + "content-encoding": "br", + "content-length": "999", + "content-type": "text/html; charset=utf-8", + }, + }); + }, + }, + }, + }, + request: new Request("https://example.com/api/edge"), + url: "/api/edge", }); + + expect(response.headers.has("content-encoding")).toBe(false); + expect(response.headers.has("content-length")).toBe(false); + expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8"); + await expect(response.text()).resolves.toContain("Example Domain"); + warn.mockRestore(); }); it("returns 400 with an Invalid JSON statusText for malformed JSON bodies", async () => { @@ -156,6 +322,84 @@ describe("pages api route", () => { await expect(response.text()).resolves.toBe("Request body too large"); }); + it("honors route-level bodyParser sizeLimit config", async () => { + const response = await handlePagesApiRoute({ + match: createMatch( + (_req, res) => { + res.status(200).json({ ok: true }); + }, + {}, + { config: { api: { bodyParser: { sizeLimit: "5kb" } } } }, + ), + request: new Request("https://example.com/api/parse", { + method: "POST", + headers: { + "content-length": String(5 * 1024 + 1), + "content-type": "text/plain", + }, + body: "x", + }), + url: "/api/parse", + }); + + expect(response.status).toBe(413); + await expect(response.text()).resolves.toBe("Request body too large"); + }); + + it("leaves body unparsed and exposes a raw async iterable when bodyParser is false", async () => { + const response = await handlePagesApiRoute({ + match: createMatch( + async (req, res) => { + const chunks: Buffer[] = []; + for await (const chunk of req as AsyncIterable) { + chunks.push(chunk); + } + res.json({ body: req.body, rawBody: Buffer.concat(chunks).toString("utf8") }); + }, + {}, + { config: { api: { bodyParser: false } } }, + ), + request: new Request("https://example.com/api/raw", { + method: "POST", + headers: { "content-type": "text/plain" }, + body: "hello raw body", + }), + url: "/api/raw", + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ rawBody: "hello raw body" }); + }); + + it("supports piping raw Pages API requests into streamed responses", async () => { + // Ported from Next.js: test/e2e/proxy-request-with-middleware/test/index.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/proxy-request-with-middleware/test/index.test.ts + const response = await handlePagesApiRoute({ + match: createMatch( + (req, res) => { + const passthrough = new Transform({ + transform(chunk, _encoding, callback) { + callback(null, chunk); + }, + }); + + return req.pipe(passthrough).pipe(res); + }, + {}, + { config: { api: { bodyParser: false } } }, + ), + request: new Request("https://example.com/api/raw", { + method: "POST", + headers: { "content-type": "application/json" }, + body: '{"key":"value"}', + }), + url: "/api/raw", + }); + + expect(response.status).toBe(200); + await expect(response.text()).resolves.toBe('{"key":"value"}'); + }); + it("returns 404 when match is null", async () => { const response = await handlePagesApiRoute({ match: null, @@ -208,6 +452,24 @@ describe("pages api route", () => { expect(customRedirectResponse.headers.get("location")).toBe("/permanent"); }); + it("forwards res.revalidate calls to the Pages runtime", async () => { + const onRevalidate = vi.fn(async () => {}); + + const response = await handlePagesApiRoute({ + match: createMatch(async (_req, res) => { + await res.revalidate("/posts/one", { unstable_onlyGenerated: true }); + res.json({ revalidated: true }); + }), + onRevalidate, + request: new Request("https://example.com/api/revalidate"), + url: "/api/revalidate", + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ revalidated: true }); + expect(onRevalidate).toHaveBeenCalledWith("/posts/one", { unstable_onlyGenerated: true }); + }); + it("res.writeHead() lowercases header keys and joins array values", async () => { const response = await handlePagesApiRoute({ match: createMatch((_req, res) => {