diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index 0985de50..65108616 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -39,6 +39,16 @@ const _pagesPageResponsePath = fileURLToPath( const _pagesPageDataPath = fileURLToPath( new URL("../server/pages-page-data.js", import.meta.url), ).replace(/\\/g, "/"); +const _pagesNodeCompatPath = fileURLToPath( + new URL("../server/pages-node-compat.js", import.meta.url), +).replace(/\\/g, "/"); +const _pagesApiRoutePath = fileURLToPath( + new URL("../server/pages-api-route.js", import.meta.url), +).replace(/\\/g, "/"); +const _isrCachePath = fileURLToPath(new URL("../server/isr-cache.js", import.meta.url)).replace( + /\\/g, + "/", +); /** * Generate the virtual SSR server entry module. @@ -269,7 +279,7 @@ import { renderToReadableStream } from "react-dom/server.edge"; import { resetSSRHead, getSSRHeadHTML } from "next/head"; import { flushPreloads } from "next/dynamic"; import { setSSRContext, wrapWithRouterContext } from "next/router"; -import { getCacheHandler, _runWithCacheState } from "next/cache"; +import { _runWithCacheState } from "next/cache"; import { runWithPrivateCache } from "vinext/cache-runtime"; import { ensureFetchPatch, runWithFetchCache } from "vinext/fetch-cache"; import { runWithRequestContext as _runWithUnifiedCtx, createRequestContext as _createUnifiedCtx } from "vinext/unified-request-context"; @@ -279,14 +289,21 @@ import { runWithHeadState } from "vinext/head-state"; import "vinext/i18n-state"; import { setI18nContext } from "vinext/i18n-context"; import { safeJsonStringify } from "vinext/html"; -import { decode as decodeQueryString } from "node:querystring"; import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google"; import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local"; -import { parseCookies, sanitizeDestination as sanitizeDestinationLocal } from ${JSON.stringify(path.resolve(__dirname, "../config/config-matchers.js").replace(/\\/g, "/"))}; +import { sanitizeDestination as sanitizeDestinationLocal } from ${JSON.stringify(path.resolve(__dirname, "../config/config-matchers.js").replace(/\\/g, "/"))}; import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from ${JSON.stringify(_requestContextShimPath)}; import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from ${JSON.stringify(_routeTriePath)}; import { reportRequestError as _reportRequestError } from "vinext/instrumentation"; import { resolvePagesI18nRequest } from ${JSON.stringify(_pagesI18nPath)}; +import { createPagesReqRes as __createPagesReqRes } from ${JSON.stringify(_pagesNodeCompatPath)}; +import { handlePagesApiRoute as __handlePagesApiRoute } from ${JSON.stringify(_pagesApiRoutePath)}; +import { + isrGet as __sharedIsrGet, + isrSet as __sharedIsrSet, + isrCacheKey as __sharedIsrCacheKey, + triggerBackgroundRegeneration as __sharedTriggerBackgroundRegeneration, +} from ${JSON.stringify(_isrCachePath)}; import { resolvePagesPageData as __resolvePagesPageData } from ${JSON.stringify(_pagesPageDataPath)}; import { renderPagesPageResponse as __renderPagesPageResponse } from ${JSON.stringify(_pagesPageResponsePath)}; ${instrumentationImportCode} @@ -303,69 +320,17 @@ const buildId = ${buildIdJson}; // Full resolved config for production server (embedded at build time) export const vinextConfig = ${vinextConfigJson}; -class ApiBodyParseError extends Error { - constructor(message, statusCode) { - super(message); - this.statusCode = statusCode; - this.name = "ApiBodyParseError"; - } -} - -// ISR cache helpers (inlined for the server entry) -async function isrGet(key) { - const handler = getCacheHandler(); - const result = await handler.get(key); - if (!result || !result.value) return null; - return { value: result, isStale: result.cacheState === "stale" }; +function isrGet(key) { + return __sharedIsrGet(key); } -async function isrSet(key, data, revalidateSeconds, tags) { - const handler = getCacheHandler(); - await handler.set(key, data, { revalidate: revalidateSeconds, tags: tags || [] }); +function isrSet(key, data, revalidateSeconds, tags) { + return __sharedIsrSet(key, data, revalidateSeconds, tags); } -const pendingRegenerations = new Map(); function triggerBackgroundRegeneration(key, renderFn) { - if (pendingRegenerations.has(key)) return; - const promise = renderFn() - .catch((err) => console.error("[vinext] ISR regen failed for " + key + ":", err)) - .finally(() => pendingRegenerations.delete(key)); - pendingRegenerations.set(key, promise); - // Register with the Workers ExecutionContext so the isolate is kept alive - // until the regeneration finishes, even after the Response has been sent. - const ctx = _getRequestExecutionContext(); - if (ctx && typeof ctx.waitUntil === "function") ctx.waitUntil(promise); -} - -function fnv1a64(input) { - let h1 = 0x811c9dc5; - for (let i = 0; i < input.length; i++) { - h1 ^= input.charCodeAt(i); - h1 = (h1 * 0x01000193) >>> 0; - } - let h2 = 0x050c5d1f; - for (let i = 0; i < input.length; i++) { - h2 ^= input.charCodeAt(i); - h2 = (h2 * 0x01000193) >>> 0; - } - return h1.toString(36) + h2.toString(36); + return __sharedTriggerBackgroundRegeneration(key, renderFn); } -// Keep prefix construction and hashing logic in sync with isrCacheKey() in server/isr-cache.ts. -// buildId is a top-level const in the generated entry (see "const buildId = ..." above). function isrCacheKey(router, pathname) { - const normalized = pathname === "/" ? "/" : pathname.replace(/\\/$/, ""); - const prefix = buildId ? router + ":" + buildId : router; - const key = prefix + ":" + normalized; - if (key.length <= 200) return key; - return prefix + ":__hash:" + fnv1a64(normalized); -} - -function getMediaType(contentType) { - var type = (contentType || "text/plain").split(";")[0]; - type = type && type.trim().toLowerCase(); - return type || "text/plain"; -} - -function isJsonMediaType(mediaType) { - return mediaType === "application/json" || mediaType === "application/ld+json"; + return __sharedIsrCacheKey(router, pathname, buildId || undefined); } async function renderToStringAsync(element) { @@ -592,126 +557,6 @@ function parseCookieLocaleFromHeader(cookieHeader) { return null; } -// Lightweight req/res facade for getServerSideProps and API routes. -// Next.js pages expect ctx.req/ctx.res with Node-like shapes. -function createReqRes(request, url, query, body) { - const headersObj = {}; - for (const [k, v] of request.headers) headersObj[k.toLowerCase()] = v; - - const req = { - method: request.method, - url: url, - headers: headersObj, - query: query, - body: body, - cookies: parseCookies(request.headers.get("cookie")), - }; - - let resStatusCode = 200; - const resHeaders = {}; - // set-cookie needs array support (multiple Set-Cookie headers are common) - const setCookieHeaders = []; - let resBody = null; - let ended = false; - let resolveResponse; - const responsePromise = new Promise(function(r) { resolveResponse = r; }); - - const res = { - get statusCode() { return resStatusCode; }, - set statusCode(code) { resStatusCode = code; }, - writeHead: function(code, headers) { - resStatusCode = code; - if (headers) { - for (const [k, v] of Object.entries(headers)) { - if (k.toLowerCase() === "set-cookie") { - if (Array.isArray(v)) { for (const c of v) setCookieHeaders.push(c); } - else { setCookieHeaders.push(v); } - } else { - resHeaders[k] = v; - } - } - } - return res; - }, - setHeader: function(name, value) { - if (name.toLowerCase() === "set-cookie") { - if (Array.isArray(value)) { for (const c of value) setCookieHeaders.push(c); } - else { setCookieHeaders.push(value); } - } else { - resHeaders[name.toLowerCase()] = value; - } - return res; - }, - getHeader: function(name) { - if (name.toLowerCase() === "set-cookie") return setCookieHeaders.length > 0 ? setCookieHeaders : undefined; - return resHeaders[name.toLowerCase()]; - }, - end: function(data) { - if (ended) return; - ended = true; - if (data !== undefined && data !== null) resBody = data; - const h = new Headers(resHeaders); - for (const c of setCookieHeaders) h.append("set-cookie", c); - resolveResponse(new Response(resBody, { status: resStatusCode, headers: h })); - }, - status: function(code) { resStatusCode = code; return res; }, - json: function(data) { - resHeaders["content-type"] = "application/json"; - res.end(JSON.stringify(data)); - }, - send: function(data) { - if (Buffer.isBuffer(data)) { - if (!resHeaders["content-type"]) resHeaders["content-type"] = "application/octet-stream"; - resHeaders["content-length"] = String(data.length); - res.end(data); - } else if (typeof data === "object" && data !== null) { - res.json(data); - } else { - if (!resHeaders["content-type"]) resHeaders["content-type"] = "text/plain"; - res.end(String(data)); - } - }, - redirect: function(statusOrUrl, url2) { - if (typeof statusOrUrl === "string") { res.writeHead(307, { Location: statusOrUrl }); } - else { res.writeHead(statusOrUrl, { Location: url2 }); } - res.end(); - }, - getHeaders: function() { - var h = Object.assign({}, resHeaders); - if (setCookieHeaders.length > 0) h["set-cookie"] = setCookieHeaders; - return h; - }, - get headersSent() { return ended; }, - }; - - return { req, res, responsePromise }; -} - -/** - * Read request body as text with a size limit. - * Throws if the body exceeds maxBytes. This prevents DoS via chunked - * transfer encoding where Content-Length is absent or spoofed. - */ -async function readBodyWithLimit(request, maxBytes) { - if (!request.body) return ""; - var reader = request.body.getReader(); - var decoder = new TextDecoder(); - var chunks = []; - var totalSize = 0; - for (;;) { - var result = await reader.read(); - if (result.done) break; - totalSize += result.value.byteLength; - if (totalSize > maxBytes) { - reader.cancel(); - throw new Error("Request body too large"); - } - chunks.push(decoder.decode(result.value, { stream: true })); - } - chunks.push(decoder.decode()); - return chunks.join(""); -} - export async function renderPage(request, url, manifest, ctx) { if (ctx) return _runWithExecutionContext(ctx, () => _renderPage(request, url, manifest)); return _renderPage(request, url, manifest); @@ -818,7 +663,7 @@ async function _renderPage(request, url, manifest) { }, buildId, createGsspReqRes() { - return createReqRes(request, routeUrl, query, undefined); + return __createPagesReqRes({ body: undefined, query, request, url: routeUrl }); }, createPageElement(currentPageProps) { var currentElement = AppComponent @@ -943,76 +788,19 @@ async function _renderPage(request, url, manifest) { export async function handleApiRoute(request, url) { const match = matchRoute(url, apiRoutes); - if (!match) { - return new Response("404 - API route not found", { status: 404 }); - } - - const { route, params } = match; - const handler = route.module.default; - if (typeof handler !== "function") { - return new Response("API route does not export a default function", { status: 500 }); - } - - const query = { ...params }; - const qs = url.split("?")[1]; - if (qs) { - for (const [k, v] of new URLSearchParams(qs)) { - if (k in query) { - // Multi-value: promote to array (Next.js returns string[] for duplicate keys) - query[k] = Array.isArray(query[k]) ? query[k].concat(v) : [query[k], v]; - } else { - query[k] = v; - } - } - } - - // Parse request body (enforce 1MB limit to prevent memory exhaustion, - // matching Next.js default bodyParser sizeLimit). - // Check Content-Length first as a fast path, then enforce on the actual - // stream to prevent bypasses via chunked transfer encoding. - const contentLength = parseInt(request.headers.get("content-length") || "0", 10); - if (contentLength > 1 * 1024 * 1024) { - return new Response("Request body too large", { status: 413 }); - } - try { - let body; - const mediaType = getMediaType(request.headers.get("content-type")); - let rawBody; - try { rawBody = await readBodyWithLimit(request, 1 * 1024 * 1024); } - catch { return new Response("Request body too large", { status: 413 }); } - if (!rawBody) { - body = isJsonMediaType(mediaType) - ? {} - : mediaType === "application/x-www-form-urlencoded" - ? decodeQueryString(rawBody) - : undefined; - } else if (isJsonMediaType(mediaType)) { - try { body = JSON.parse(rawBody); } - catch { throw new ApiBodyParseError("Invalid JSON", 400); } - } else if (mediaType === "application/x-www-form-urlencoded") { - body = decodeQueryString(rawBody); - } else { - body = rawBody; - } - - const { req, res, responsePromise } = createReqRes(request, url, query, body); - await handler(req, res); - // If handler didn't call res.end(), end it now. - // The end() method is idempotent — safe to call twice. - res.end(); - return await responsePromise; - } catch (e) { - if (e instanceof ApiBodyParseError) { - return new Response(e.message, { status: e.statusCode, statusText: e.message }); - } - console.error("[vinext] API error:", e); - _reportRequestError( - e instanceof Error ? e : new Error(String(e)), - { path: url, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, - { routerKind: "Pages Router", routePath: route.pattern, routeType: "route" }, - ); - return new Response("Internal Server Error", { status: 500 }); - } + return __handlePagesApiRoute({ + match, + request, + url, + reportRequestError(error, routePattern) { + console.error("[vinext] API error:", error); + void _reportRequestError( + error, + { path: url, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, + { routerKind: "Pages Router", routePath: routePattern, routeType: "route" }, + ); + }, + }); } ${middlewareExportCode} diff --git a/packages/vinext/src/server/api-handler.ts b/packages/vinext/src/server/api-handler.ts index 64d11207..5ebf5e0f 100644 --- a/packages/vinext/src/server/api-handler.ts +++ b/packages/vinext/src/server/api-handler.ts @@ -12,6 +12,7 @@ import { decode as decodeQueryString } from "node:querystring"; import { type Route, matchRoute } from "../routing/pages-router.js"; import { reportRequestError, importModule, type ModuleImporter } from "./instrumentation.js"; import { addQueryParam } from "../utils/query.js"; +import { PagesBodyParseError, getMediaType, isJsonMediaType } from "./pages-media-type.js"; /** * Extend the Node.js request with Next.js-style helpers. @@ -39,24 +40,6 @@ interface NextApiResponse extends ServerResponse { */ const MAX_BODY_SIZE = 1 * 1024 * 1024; -class ApiBodyParseError extends Error { - constructor( - message: string, - readonly statusCode: number, - ) { - super(message); - this.name = "ApiBodyParseError"; - } -} - -function getMediaType(contentType: string | undefined): string { - const [type] = (contentType ?? "text/plain").split(";"); - return type?.trim().toLowerCase() || "text/plain"; -} - -function isJsonMediaType(mediaType: string): boolean { - return mediaType === "application/json" || mediaType === "application/ld+json"; -} /** * Parse the request body based on content-type. * Enforces a size limit to prevent memory exhaustion attacks. @@ -71,7 +54,7 @@ async function parseBody(req: IncomingMessage): Promise { if (totalSize > MAX_BODY_SIZE) { settled = true; req.destroy(); - reject(new Error("Request body too large")); + reject(new PagesBodyParseError("Request body too large", 413)); return; } chunks.push(chunk); @@ -101,7 +84,7 @@ async function parseBody(req: IncomingMessage): Promise { try { resolve(JSON.parse(raw)); } catch { - reject(new ApiBodyParseError("Invalid JSON", 400)); + reject(new PagesBodyParseError("Invalid JSON", 400)); } } else if (mediaType === "application/x-www-form-urlencoded") { resolve(decodeQueryString(raw)); @@ -234,7 +217,7 @@ export async function handleApiRoute( await handler(apiReq, apiRes); return true; } catch (e) { - if (e instanceof ApiBodyParseError) { + if (e instanceof PagesBodyParseError) { res.statusCode = e.statusCode; res.statusMessage = e.message; res.end(e.message); @@ -259,13 +242,8 @@ export async function handleApiRoute( { routerKind: "Pages Router", routePath: match.route.pattern, routeType: "route" }, ); if (!res.headersSent) { - if ((e as Error).message === "Request body too large") { - res.statusCode = 413; - res.end("Request body too large"); - } else { - res.statusCode = 500; - res.end("Internal Server Error"); - } + res.statusCode = 500; + res.end("Internal Server Error"); } else if (!res.writableEnded) { res.end(); } diff --git a/packages/vinext/src/server/pages-api-route.ts b/packages/vinext/src/server/pages-api-route.ts new file mode 100644 index 00000000..8579c2e5 --- /dev/null +++ b/packages/vinext/src/server/pages-api-route.ts @@ -0,0 +1,82 @@ +import type { Route } from "../routing/pages-router.js"; +import { addQueryParam } from "../utils/query.js"; +import { + createPagesReqRes, + parsePagesApiBody, + type PagesRequestQuery, + type PagesReqResRequest, + type PagesReqResResponse, + PagesApiBodyParseError, +} from "./pages-node-compat.js"; + +interface PagesApiRouteModule { + default?: (req: PagesReqResRequest, res: PagesReqResResponse) => void | Promise; +} + +export interface PagesApiRouteMatch { + params: PagesRequestQuery; + route: Pick & { + module: PagesApiRouteModule; + }; +} + +export interface HandlePagesApiRouteOptions { + match: PagesApiRouteMatch | null; + reportRequestError?: (error: Error, routePattern: string) => void | Promise; + request: Request; + url: string; +} + +function buildPagesApiQuery(url: string, params: PagesRequestQuery): PagesRequestQuery { + 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; +} + +export async function handlePagesApiRoute(options: HandlePagesApiRouteOptions): Promise { + if (!options.match) { + return new Response("404 - API route not found", { status: 404 }); + } + + const { route, params } = options.match; + const handler = route.module.default; + if (typeof handler !== "function") { + return new Response("API route does not export a default function", { status: 500 }); + } + + try { + const query = buildPagesApiQuery(options.url, params); + const body = await parsePagesApiBody(options.request); + const { req, res, responsePromise } = createPagesReqRes({ + body, + query, + request: options.request, + url: options.url, + }); + + await handler(req, res); + res.end(); + return await responsePromise; + } catch (error) { + if (error instanceof PagesApiBodyParseError) { + return new Response(error.message, { + status: error.statusCode, + statusText: error.message, + }); + } + + void options.reportRequestError?.( + error instanceof Error ? error : new Error(String(error)), + route.pattern, + ); + return new Response("Internal Server Error", { status: 500 }); + } +} diff --git a/packages/vinext/src/server/pages-media-type.ts b/packages/vinext/src/server/pages-media-type.ts new file mode 100644 index 00000000..9d468b29 --- /dev/null +++ b/packages/vinext/src/server/pages-media-type.ts @@ -0,0 +1,25 @@ +/** + * Shared media-type helpers and body-parse error for Pages API routes. + * + * Used by both api-handler.ts (Pages Router dev/prod with Node.js req/res) and + * pages-node-compat.ts (Pages Router fetch-based facade for Cloudflare Workers). + */ + +export class PagesBodyParseError extends Error { + constructor( + message: string, + readonly statusCode: number, + ) { + super(message); + this.name = "PagesBodyParseError"; + } +} + +export function getMediaType(contentType: string | null | undefined): string { + const [type] = (contentType ?? "text/plain").split(";"); + return type?.trim().toLowerCase() || "text/plain"; +} + +export function isJsonMediaType(mediaType: string): boolean { + return mediaType === "application/json" || mediaType === "application/ld+json"; +} diff --git a/packages/vinext/src/server/pages-node-compat.ts b/packages/vinext/src/server/pages-node-compat.ts new file mode 100644 index 00000000..f92bcf48 --- /dev/null +++ b/packages/vinext/src/server/pages-node-compat.ts @@ -0,0 +1,263 @@ +import { decode as decodeQueryString } from "node:querystring"; +import { parseCookies } from "../config/config-matchers.js"; +import { PagesBodyParseError, getMediaType, isJsonMediaType } from "./pages-media-type.js"; + +export const MAX_PAGES_API_BODY_SIZE = 1 * 1024 * 1024; + +/** + * @deprecated Use PagesBodyParseError from pages-media-type.ts instead. + * Kept for backwards compatibility. + */ +export { PagesBodyParseError as PagesApiBodyParseError }; + +export type PagesRequestQuery = Record; + +export interface PagesReqResRequest { + method: string; + url: string; + headers: Record; + query: PagesRequestQuery; + body: unknown; + cookies: Record; +} + +export interface PagesReqResHeaders { + [key: string]: string | number | boolean | string[]; +} + +export interface PagesReqResResponse { + statusCode: number; + readonly headersSent: boolean; + writeHead: (code: number, headers?: PagesReqResHeaders) => PagesReqResResponse; + setHeader: (name: string, value: string | number | boolean | string[]) => PagesReqResResponse; + getHeader: (name: string) => string | number | boolean | string[] | undefined; + end: (data?: BodyInit | null) => void; + status: (code: number) => PagesReqResResponse; + json: (data: unknown) => void; + send: (data: unknown) => void; + redirect: (statusOrUrl: number | string, url?: string) => void; + getHeaders: () => PagesReqResHeaders; +} + +export interface CreatePagesReqResOptions { + body: unknown; + query: PagesRequestQuery; + request: Request; + url: string; +} + +export interface CreatePagesReqResResult { + req: PagesReqResRequest; + res: PagesReqResResponse; + responsePromise: Promise; +} + +async function readPagesRequestBodyWithLimit(request: Request, maxBytes: number): Promise { + if (!request.body) { + return ""; + } + + 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 async function parsePagesApiBody( + request: Request, + maxBytes = MAX_PAGES_API_BODY_SIZE, +): Promise { + const contentLength = Number.parseInt(request.headers.get("content-length") || "0", 10); + if (contentLength > maxBytes) { + throw new PagesBodyParseError("Request body too large", 413); + } + + let rawBody = ""; + try { + rawBody = await readPagesRequestBodyWithLimit(request, maxBytes); + } catch (err) { + if (err instanceof PagesBodyParseError) { + throw err; + } + throw new PagesBodyParseError("Request body too large", 413); + } + + const mediaType = getMediaType(request.headers.get("content-type")); + if (!rawBody) { + return isJsonMediaType(mediaType) + ? {} + : mediaType === "application/x-www-form-urlencoded" + ? decodeQueryString(rawBody) + : undefined; + } + + if (isJsonMediaType(mediaType)) { + try { + return JSON.parse(rawBody); + } catch { + throw new PagesBodyParseError("Invalid JSON", 400); + } + } + + if (mediaType === "application/x-www-form-urlencoded") { + return decodeQueryString(rawBody); + } + + return rawBody; +} + +export function createPagesReqRes(options: CreatePagesReqResOptions): CreatePagesReqResResult { + const headersObj: Record = {}; + for (const [key, value] of options.request.headers) { + 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")), + }; + + let resStatusCode = 200; + const resHeaders: Record = {}; + const setCookieHeaders: string[] = []; + let resBody: BodyInit | null = null; + let ended = false; + let resolveResponse!: (value: Response) => void; + const responsePromise = new Promise((resolve) => { + resolveResponse = resolve; + }); + + const res: PagesReqResResponse = { + get statusCode() { + return resStatusCode; + }, + set statusCode(code) { + resStatusCode = code; + }, + get headersSent() { + return ended; + }, + writeHead(code, headers) { + resStatusCode = code; + if (headers) { + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === "set-cookie") { + if (Array.isArray(value)) { + setCookieHeaders.push(...value.map(String)); + } else { + setCookieHeaders.push(String(value)); + } + } else { + resHeaders[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value; + } + } + } + return res; + }, + setHeader(name, value) { + if (name.toLowerCase() === "set-cookie") { + // Node.js res.setHeader() replaces the existing value entirely. + setCookieHeaders.length = 0; + if (Array.isArray(value)) { + setCookieHeaders.push(...value.map(String)); + } else { + setCookieHeaders.push(String(value)); + } + } else { + resHeaders[name.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value; + } + return res; + }, + getHeader(name) { + if (name.toLowerCase() === "set-cookie") { + return setCookieHeaders.length > 0 ? setCookieHeaders : undefined; + } + return resHeaders[name.toLowerCase()]; + }, + end(data) { + if (ended) { + return; + } + ended = true; + if (data !== undefined && data !== null) { + resBody = 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 })); + }, + status(code) { + resStatusCode = code; + return res; + }, + json(data) { + resHeaders["content-type"] = "application/json"; + res.end(JSON.stringify(data)); + }, + send(data) { + if (Buffer.isBuffer(data)) { + if (!resHeaders["content-type"]) { + resHeaders["content-type"] = "application/octet-stream"; + } + resHeaders["content-length"] = String(data.length); + res.end(new Uint8Array(data)); + return; + } + + if (typeof data === "object" && data !== null) { + resHeaders["content-type"] = "application/json"; + res.end(JSON.stringify(data)); + return; + } + + if (!resHeaders["content-type"]) { + resHeaders["content-type"] = "text/plain"; + } + res.end(String(data)); + }, + redirect(statusOrUrl, url) { + if (typeof statusOrUrl === "string") { + res.writeHead(307, { Location: statusOrUrl }); + } else { + res.writeHead(statusOrUrl, { Location: url ?? "" }); + } + res.end(); + }, + getHeaders() { + const headers: PagesReqResHeaders = { ...resHeaders }; + if (setCookieHeaders.length > 0) { + headers["set-cookie"] = setCookieHeaders; + } + return headers; + }, + }; + + return { req, res, responsePromise }; +} diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 5a343857..b830f13e 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -13687,7 +13687,7 @@ import { renderToReadableStream } from "react-dom/server.edge"; import { resetSSRHead, getSSRHeadHTML } from "next/head"; import { flushPreloads } from "next/dynamic"; import { setSSRContext, wrapWithRouterContext } from "next/router"; -import { getCacheHandler, _runWithCacheState } from "next/cache"; +import { _runWithCacheState } from "next/cache"; import { runWithPrivateCache } from "vinext/cache-runtime"; import { ensureFetchPatch, runWithFetchCache } from "vinext/fetch-cache"; import { runWithRequestContext as _runWithUnifiedCtx, createRequestContext as _createUnifiedCtx } from "vinext/unified-request-context"; @@ -13697,14 +13697,21 @@ import { runWithHeadState } from "vinext/head-state"; import "vinext/i18n-state"; import { setI18nContext } from "vinext/i18n-context"; import { safeJsonStringify } from "vinext/html"; -import { decode as decodeQueryString } from "node:querystring"; import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google"; import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local"; -import { parseCookies, sanitizeDestination as sanitizeDestinationLocal } from "/packages/vinext/src/config/config-matchers.js"; +import { sanitizeDestination as sanitizeDestinationLocal } from "/packages/vinext/src/config/config-matchers.js"; import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; import { reportRequestError as _reportRequestError } from "vinext/instrumentation"; import { resolvePagesI18nRequest } from "/packages/vinext/src/server/pages-i18n.js"; +import { createPagesReqRes as __createPagesReqRes } from "/packages/vinext/src/server/pages-node-compat.js"; +import { handlePagesApiRoute as __handlePagesApiRoute } from "/packages/vinext/src/server/pages-api-route.js"; +import { + isrGet as __sharedIsrGet, + isrSet as __sharedIsrSet, + isrCacheKey as __sharedIsrCacheKey, + triggerBackgroundRegeneration as __sharedTriggerBackgroundRegeneration, +} from "/packages/vinext/src/server/isr-cache.js"; import { resolvePagesPageData as __resolvePagesPageData } from "/packages/vinext/src/server/pages-page-data.js"; import { renderPagesPageResponse as __renderPagesPageResponse } from "/packages/vinext/src/server/pages-page-response.js"; import * as _instrumentation from "/tests/fixtures/pages-basic/instrumentation.ts"; @@ -13732,69 +13739,17 @@ const buildId = "test-build-id"; // Full resolved config for production server (embedded at build time) export const vinextConfig = {"basePath":"","trailingSlash":false,"redirects":[{"source":"/old-about","destination":"/about","permanent":true},{"source":"/repeat-redirect/:id","destination":"/docs/:id/:id","permanent":false},{"source":"/redirect-before-middleware-rewrite","destination":"/about","permanent":false},{"source":"/redirect-before-middleware-response","destination":"/about","permanent":false}],"rewrites":{"beforeFiles":[{"source":"/before-rewrite","destination":"/about"},{"source":"/repeat-rewrite/:id","destination":"/docs/:id/:id"},{"source":"/mw-gated-before","has":[{"type":"cookie","key":"mw-before-user"}],"destination":"/about"}],"afterFiles":[{"source":"/after-rewrite","destination":"/about"},{"source":"/mw-gated-rewrite","has":[{"type":"cookie","key":"mw-user"}],"destination":"/about"}],"fallback":[{"source":"/fallback-rewrite","destination":"/about"}]},"headers":[{"source":"/api/(.*)","headers":[{"key":"X-Custom-Header","value":"vinext"}]},{"source":"/about","has":[{"type":"cookie","key":"logged-in"}],"headers":[{"key":"X-Auth-Only-Header","value":"1"}]},{"source":"/about","missing":[{"type":"cookie","key":"logged-in"}],"headers":[{"key":"X-Guest-Only-Header","value":"1"}]},{"source":"/ssr","headers":[{"key":"Vary","value":"Accept-Language"}]},{"source":"/headers-before-middleware-rewrite","headers":[{"key":"X-Rewrite-Source-Header","value":"1"}]}],"i18n":null,"images":{}}; -class ApiBodyParseError extends Error { - constructor(message, statusCode) { - super(message); - this.statusCode = statusCode; - this.name = "ApiBodyParseError"; - } -} - -// ISR cache helpers (inlined for the server entry) -async function isrGet(key) { - const handler = getCacheHandler(); - const result = await handler.get(key); - if (!result || !result.value) return null; - return { value: result, isStale: result.cacheState === "stale" }; +function isrGet(key) { + return __sharedIsrGet(key); } -async function isrSet(key, data, revalidateSeconds, tags) { - const handler = getCacheHandler(); - await handler.set(key, data, { revalidate: revalidateSeconds, tags: tags || [] }); +function isrSet(key, data, revalidateSeconds, tags) { + return __sharedIsrSet(key, data, revalidateSeconds, tags); } -const pendingRegenerations = new Map(); function triggerBackgroundRegeneration(key, renderFn) { - if (pendingRegenerations.has(key)) return; - const promise = renderFn() - .catch((err) => console.error("[vinext] ISR regen failed for " + key + ":", err)) - .finally(() => pendingRegenerations.delete(key)); - pendingRegenerations.set(key, promise); - // Register with the Workers ExecutionContext so the isolate is kept alive - // until the regeneration finishes, even after the Response has been sent. - const ctx = _getRequestExecutionContext(); - if (ctx && typeof ctx.waitUntil === "function") ctx.waitUntil(promise); -} - -function fnv1a64(input) { - let h1 = 0x811c9dc5; - for (let i = 0; i < input.length; i++) { - h1 ^= input.charCodeAt(i); - h1 = (h1 * 0x01000193) >>> 0; - } - let h2 = 0x050c5d1f; - for (let i = 0; i < input.length; i++) { - h2 ^= input.charCodeAt(i); - h2 = (h2 * 0x01000193) >>> 0; - } - return h1.toString(36) + h2.toString(36); + return __sharedTriggerBackgroundRegeneration(key, renderFn); } -// Keep prefix construction and hashing logic in sync with isrCacheKey() in server/isr-cache.ts. -// buildId is a top-level const in the generated entry (see "const buildId = ..." above). function isrCacheKey(router, pathname) { - const normalized = pathname === "/" ? "/" : pathname.replace(/\\/$/, ""); - const prefix = buildId ? router + ":" + buildId : router; - const key = prefix + ":" + normalized; - if (key.length <= 200) return key; - return prefix + ":__hash:" + fnv1a64(normalized); -} - -function getMediaType(contentType) { - var type = (contentType || "text/plain").split(";")[0]; - type = type && type.trim().toLowerCase(); - return type || "text/plain"; -} - -function isJsonMediaType(mediaType) { - return mediaType === "application/json" || mediaType === "application/ld+json"; + return __sharedIsrCacheKey(router, pathname, buildId || undefined); } async function renderToStringAsync(element) { @@ -14113,126 +14068,6 @@ function parseCookieLocaleFromHeader(cookieHeader) { return null; } -// Lightweight req/res facade for getServerSideProps and API routes. -// Next.js pages expect ctx.req/ctx.res with Node-like shapes. -function createReqRes(request, url, query, body) { - const headersObj = {}; - for (const [k, v] of request.headers) headersObj[k.toLowerCase()] = v; - - const req = { - method: request.method, - url: url, - headers: headersObj, - query: query, - body: body, - cookies: parseCookies(request.headers.get("cookie")), - }; - - let resStatusCode = 200; - const resHeaders = {}; - // set-cookie needs array support (multiple Set-Cookie headers are common) - const setCookieHeaders = []; - let resBody = null; - let ended = false; - let resolveResponse; - const responsePromise = new Promise(function(r) { resolveResponse = r; }); - - const res = { - get statusCode() { return resStatusCode; }, - set statusCode(code) { resStatusCode = code; }, - writeHead: function(code, headers) { - resStatusCode = code; - if (headers) { - for (const [k, v] of Object.entries(headers)) { - if (k.toLowerCase() === "set-cookie") { - if (Array.isArray(v)) { for (const c of v) setCookieHeaders.push(c); } - else { setCookieHeaders.push(v); } - } else { - resHeaders[k] = v; - } - } - } - return res; - }, - setHeader: function(name, value) { - if (name.toLowerCase() === "set-cookie") { - if (Array.isArray(value)) { for (const c of value) setCookieHeaders.push(c); } - else { setCookieHeaders.push(value); } - } else { - resHeaders[name.toLowerCase()] = value; - } - return res; - }, - getHeader: function(name) { - if (name.toLowerCase() === "set-cookie") return setCookieHeaders.length > 0 ? setCookieHeaders : undefined; - return resHeaders[name.toLowerCase()]; - }, - end: function(data) { - if (ended) return; - ended = true; - if (data !== undefined && data !== null) resBody = data; - const h = new Headers(resHeaders); - for (const c of setCookieHeaders) h.append("set-cookie", c); - resolveResponse(new Response(resBody, { status: resStatusCode, headers: h })); - }, - status: function(code) { resStatusCode = code; return res; }, - json: function(data) { - resHeaders["content-type"] = "application/json"; - res.end(JSON.stringify(data)); - }, - send: function(data) { - if (Buffer.isBuffer(data)) { - if (!resHeaders["content-type"]) resHeaders["content-type"] = "application/octet-stream"; - resHeaders["content-length"] = String(data.length); - res.end(data); - } else if (typeof data === "object" && data !== null) { - res.json(data); - } else { - if (!resHeaders["content-type"]) resHeaders["content-type"] = "text/plain"; - res.end(String(data)); - } - }, - redirect: function(statusOrUrl, url2) { - if (typeof statusOrUrl === "string") { res.writeHead(307, { Location: statusOrUrl }); } - else { res.writeHead(statusOrUrl, { Location: url2 }); } - res.end(); - }, - getHeaders: function() { - var h = Object.assign({}, resHeaders); - if (setCookieHeaders.length > 0) h["set-cookie"] = setCookieHeaders; - return h; - }, - get headersSent() { return ended; }, - }; - - return { req, res, responsePromise }; -} - -/** - * Read request body as text with a size limit. - * Throws if the body exceeds maxBytes. This prevents DoS via chunked - * transfer encoding where Content-Length is absent or spoofed. - */ -async function readBodyWithLimit(request, maxBytes) { - if (!request.body) return ""; - var reader = request.body.getReader(); - var decoder = new TextDecoder(); - var chunks = []; - var totalSize = 0; - for (;;) { - var result = await reader.read(); - if (result.done) break; - totalSize += result.value.byteLength; - if (totalSize > maxBytes) { - reader.cancel(); - throw new Error("Request body too large"); - } - chunks.push(decoder.decode(result.value, { stream: true })); - } - chunks.push(decoder.decode()); - return chunks.join(""); -} - export async function renderPage(request, url, manifest, ctx) { if (ctx) return _runWithExecutionContext(ctx, () => _renderPage(request, url, manifest)); return _renderPage(request, url, manifest); @@ -14339,7 +14174,7 @@ async function _renderPage(request, url, manifest) { }, buildId, createGsspReqRes() { - return createReqRes(request, routeUrl, query, undefined); + return __createPagesReqRes({ body: undefined, query, request, url: routeUrl }); }, createPageElement(currentPageProps) { var currentElement = AppComponent @@ -14464,76 +14299,19 @@ async function _renderPage(request, url, manifest) { export async function handleApiRoute(request, url) { const match = matchRoute(url, apiRoutes); - if (!match) { - return new Response("404 - API route not found", { status: 404 }); - } - - const { route, params } = match; - const handler = route.module.default; - if (typeof handler !== "function") { - return new Response("API route does not export a default function", { status: 500 }); - } - - const query = { ...params }; - const qs = url.split("?")[1]; - if (qs) { - for (const [k, v] of new URLSearchParams(qs)) { - if (k in query) { - // Multi-value: promote to array (Next.js returns string[] for duplicate keys) - query[k] = Array.isArray(query[k]) ? query[k].concat(v) : [query[k], v]; - } else { - query[k] = v; - } - } - } - - // Parse request body (enforce 1MB limit to prevent memory exhaustion, - // matching Next.js default bodyParser sizeLimit). - // Check Content-Length first as a fast path, then enforce on the actual - // stream to prevent bypasses via chunked transfer encoding. - const contentLength = parseInt(request.headers.get("content-length") || "0", 10); - if (contentLength > 1 * 1024 * 1024) { - return new Response("Request body too large", { status: 413 }); - } - try { - let body; - const mediaType = getMediaType(request.headers.get("content-type")); - let rawBody; - try { rawBody = await readBodyWithLimit(request, 1 * 1024 * 1024); } - catch { return new Response("Request body too large", { status: 413 }); } - if (!rawBody) { - body = isJsonMediaType(mediaType) - ? {} - : mediaType === "application/x-www-form-urlencoded" - ? decodeQueryString(rawBody) - : undefined; - } else if (isJsonMediaType(mediaType)) { - try { body = JSON.parse(rawBody); } - catch { throw new ApiBodyParseError("Invalid JSON", 400); } - } else if (mediaType === "application/x-www-form-urlencoded") { - body = decodeQueryString(rawBody); - } else { - body = rawBody; - } - - const { req, res, responsePromise } = createReqRes(request, url, query, body); - await handler(req, res); - // If handler didn't call res.end(), end it now. - // The end() method is idempotent — safe to call twice. - res.end(); - return await responsePromise; - } catch (e) { - if (e instanceof ApiBodyParseError) { - return new Response(e.message, { status: e.statusCode, statusText: e.message }); - } - console.error("[vinext] API error:", e); - _reportRequestError( - e instanceof Error ? e : new Error(String(e)), - { path: url, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, - { routerKind: "Pages Router", routePath: route.pattern, routeType: "route" }, - ); - return new Response("Internal Server Error", { status: 500 }); - } + return __handlePagesApiRoute({ + match, + request, + url, + reportRequestError(error, routePattern) { + console.error("[vinext] API error:", error); + void _reportRequestError( + error, + { path: url, method: request.method, headers: Object.fromEntries(request.headers.entries()) }, + { routerKind: "Pages Router", routePath: routePattern, routeType: "route" }, + ); + }, + }); } diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index 10b77a4a..3e1fd6e3 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -283,13 +283,16 @@ describe("Pages Router entry templates", () => { expect(stabilize(code)).toContain("trieMatch"); }); - it("server entry eagerly starts ISR regeneration before waitUntil registration", async () => { + it("server entry delegates Pages ISR cache plumbing to shared helpers", async () => { const code = await getVirtualModuleCode("virtual:vinext-server-entry"); - const renderFnCall = code.indexOf("const promise = renderFn()"); - const waitUntilCall = code.indexOf("ctx.waitUntil(promise)"); - - expect(renderFnCall).toBeGreaterThan(-1); - expect(waitUntilCall).toBeGreaterThan(renderFnCall); + const stableCode = stabilize(code); + + expect(stableCode).toContain('from "/packages/vinext/src/server/isr-cache.js";'); + expect(code).toContain("function isrGet(key) {"); + expect(code).toContain("return __sharedIsrGet(key);"); + expect(code).toContain("return __sharedTriggerBackgroundRegeneration(key, renderFn);"); + expect(code).not.toContain("const promise = renderFn()"); + expect(code).not.toContain("ctx.waitUntil(promise)"); }); it("server entry seeds the main Pages Router unified context with executionContext", async () => { @@ -331,12 +334,35 @@ describe("Pages Router entry templates", () => { const code = await getVirtualModuleCode("virtual:vinext-server-entry"); expect(code).toContain("resolvePagesPageData as __resolvePagesPageData"); + expect(code).toContain("isrGet as __sharedIsrGet"); + expect(code).toContain("isrSet as __sharedIsrSet"); + expect(code).toContain("isrCacheKey as __sharedIsrCacheKey"); + expect(code).toContain( + "triggerBackgroundRegeneration as __sharedTriggerBackgroundRegeneration", + ); expect(code).toContain("const pageDataResult = await __resolvePagesPageData({"); - expect(code).not.toContain("triggerBackgroundRegeneration(cacheKey, async function()"); + expect(code).toContain("return __sharedTriggerBackgroundRegeneration(key, renderFn);"); + expect(code).not.toContain("async function isrGet(key)"); + expect(code).not.toContain("async function isrSet(key, data, revalidateSeconds, tags)"); + expect(code).not.toContain("const pendingRegenerations = new Map();"); + expect(code).not.toContain("function fnv1a64(input)"); expect(code).not.toContain("const result = await pageModule.getServerSideProps(ctx);"); expect(code).not.toContain("const result = await pageModule.getStaticProps(ctx);"); }); + it("server entry delegates Pages API route handling and req/res shims to typed helpers", async () => { + const code = await getVirtualModuleCode("virtual:vinext-server-entry"); + + expect(code).toContain("createPagesReqRes as __createPagesReqRes"); + expect(code).toContain("handlePagesApiRoute as __handlePagesApiRoute"); + expect(code).toContain("return __handlePagesApiRoute({"); + expect(code).not.toContain("function createReqRes(request, url, query, body)"); + expect(code).not.toContain("async function readBodyWithLimit(request, maxBytes)"); + expect(code).not.toContain( + "const { req, res, responsePromise } = createReqRes(request, url, query, body);", + ); + }); + it("server entry isolates the ISR cache-fill rerender in fresh render sub-scopes", async () => { const code = await getVirtualModuleCode("virtual:vinext-server-entry"); diff --git a/tests/pages-api-route.test.ts b/tests/pages-api-route.test.ts new file mode 100644 index 00000000..2c0b67d4 --- /dev/null +++ b/tests/pages-api-route.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { + handlePagesApiRoute, + type PagesApiRouteMatch, +} from "../packages/vinext/src/server/pages-api-route.js"; + +function createMatch( + handler: PagesApiRouteMatch["route"]["module"]["default"], + params: Record = {}, +): PagesApiRouteMatch { + return { + params, + route: { + pattern: "/api/test", + module: { + 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("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"]); + }); +});