diff --git a/packages/vinext/src/server/pages-api-route.ts b/packages/vinext/src/server/pages-api-route.ts index 6779b400c..4d6daddc6 100644 --- a/packages/vinext/src/server/pages-api-route.ts +++ b/packages/vinext/src/server/pages-api-route.ts @@ -8,6 +8,7 @@ import { } from "../utils/query.js"; import { createPagesReqRes, + markVinextStreamedApiResponse, parsePagesApiBody, type PagesRequestQuery, type PagesReqResRequest, @@ -22,7 +23,11 @@ import { isEdgeApiRuntime, type EdgeApiExecutionRuntime, } from "./edge-api-runtime.js"; -import { runWithExecutionContext, type ExecutionContextLike } from "vinext/shims/request-context"; +import { + getRequestExecutionContext, + runWithExecutionContext, + type ExecutionContextLike, +} from "vinext/shims/request-context"; import { NextRequest } from "vinext/shims/server"; type PagesApiRouteConfig = { @@ -179,6 +184,7 @@ async function _handlePagesApiRoute(options: HandlePagesApiRouteOptions): Promis if (!isNodeApiRouteModule(route.module)) { return new Response("API route does not export a default function", { status: 500 }); } + const handler = route.module.default; const query = buildPagesApiQuery(options.url, params); @@ -220,15 +226,69 @@ async function _handlePagesApiRoute(options: HandlePagesApiRouteOptions): Promis // (e.g. proxy middleware that attaches its pipe asynchronously) sends it. const externalResolver = route.module.config?.api?.externalResolver || false; - await route.module.default(req, res); - // Auto-end if no stream is in progress. Without this guard a handler - // that forgets to call res.end() would leave the request hanging. - // Skipped for `externalResolver: true` routes, which legitimately - // respond after the handler settles. - if (!externalResolver && !resWasPiped && !res.headersSent) { - res.end(); + const completeHandler = () => { + // Auto-end if no stream is in progress. Without this guard a handler + // that forgets to call res.end() would leave the request hanging. + // Skipped for `externalResolver: true` routes, which legitimately + // respond after the handler settles. + if (!externalResolver && !resWasPiped && !res.headersSent) { + res.end(); + } + }; + const finalizeResponse = (response: Response) => { + // The response resolves on the first write; if `end()` still has not + // been called by the time it settles, the body is a live stream (e.g. a + // piped proxy upstream). Mark it so buffering adapters forward it as a + // stream instead of holding the whole body in memory until the source + // closes. + if (!res.writableEnded) { + markVinextStreamedApiResponse(response); + } + return response; + }; + const destroyAfterHandlerError = (error: unknown): never => { + const normalizedError = error instanceof Error ? error : new Error(String(error)); + // Once a handler has started writing, the Fetch Response may already be + // on its way to the client. Error the bridge so a parked write callback + // and the response consumer both settle instead of abandoning the + // stream while the error is reported below. + res.destroy(normalizedError); + throw normalizedError; + }; + + const responseReady = responsePromise.then((response) => ({ + type: "response" as const, + response, + })); + // Schedule invocation after both sides of the race have rejection + // handlers attached. A synchronous throw may destroy the response bridge, + // which rejects responsePromise as well as the handler completion. + const handlerCompletion = Promise.resolve() + .then(() => handler(req, res)) + .then(() => ({ type: "handler" as const }), destroyAfterHandlerError); + + // A real Node ServerResponse is consumed by the socket while the API + // handler is still running. Mirror that concurrency at the Fetch boundary: + // a handler is allowed to await pipeline() or `drain`, so expose the body + // as soon as its first write commits the Response instead of waiting for + // the handler to finish first. + const firstSettled = await Promise.race([responseReady, handlerCompletion]); + if (firstSettled.type === "response") { + const handlerLifecycle = handlerCompletion.then(completeHandler, (error) => { + void options.reportRequestError?.(error, route.pattern); + }); + // The body may already be complete (for example, res.end() followed by + // awaited cleanup), so keeping only a floating promise is not enough on + // Workers. Register the remaining handler lifecycle before returning; + // this also covers hybrid App/Pages requests, where the context is + // inherited from the surrounding App Router handler rather than passed + // through options.ctx. + getRequestExecutionContext()?.waitUntil(handlerLifecycle); + return finalizeResponse(firstSettled.response); } - return await responsePromise; + + completeHandler(); + return finalizeResponse(await responsePromise); } catch (error) { if (error instanceof PagesApiBodyParseError) { return new Response(error.message, { diff --git a/packages/vinext/src/server/pages-node-compat.ts b/packages/vinext/src/server/pages-node-compat.ts index a57fa0ad8..47beb6877 100644 --- a/packages/vinext/src/server/pages-node-compat.ts +++ b/packages/vinext/src/server/pages-node-compat.ts @@ -254,6 +254,7 @@ class PagesResponseStream extends Writable { private controller: ReadableStreamDefaultController | null = null; private readonly bufferedChunks: Buffer[] = []; private streamEnded = false; + private pendingWrite: ((error?: Error | null) => void) | null = null; constructor( private readonly resolveResponse: (value: Response) => void, @@ -405,7 +406,23 @@ class PagesResponseStream extends Writable { this.bufferedChunks.push(buffer); } this.resolveOnce(); - callback(); + // Propagate consumer backpressure to the Node source: while the response + // body's queue is full, hold the write callback until the consumer pulls. + // A held callback makes `write()` return false, which pauses any piped + // source (e.g. a proxied upstream) instead of queueing chunks without + // bound. `end(data)` writes can park here too because Node sets + // writableEnded after _write returns; the adapter's first body pull + // releases them, and the completed response remains on the buffered path. + if ( + this.controller && + !this.streamEnded && + !this.writableEnded && + (this.controller.desiredSize ?? 1) <= 0 + ) { + this.pendingWrite = callback; + } else { + callback(); + } } override _final(callback: (error?: Error | null) => void): void { @@ -423,6 +440,9 @@ class PagesResponseStream extends Writable { override _destroy(error: Error | null, callback: (error?: Error | null) => void): void { this.streamEnded = true; + // A write parked for backpressure would otherwise never complete; release + // it so piped sources unwind instead of waiting on a dead stream. + this.releasePendingWrite(error); if (!this.resolved) { if (error) { this.resolved = true; @@ -464,6 +484,12 @@ class PagesResponseStream extends Writable { this.resHeaders[name.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value; } + private releasePendingWrite(error?: Error | null): void { + const callback = this.pendingWrite; + this.pendingWrite = null; + callback?.(error); + } + private resolveOnce(): void { if (this.resolved) { return; @@ -497,6 +523,9 @@ class PagesResponseStream extends Writable { } } }, + pull: () => { + this.releasePendingWrite(); + }, cancel: (reason) => { this.bufferedChunks.length = 0; this.destroy(reason instanceof Error ? reason : new Error("Response body cancelled")); @@ -507,6 +536,26 @@ class PagesResponseStream extends Writable { } } +type ResponseWithVinextStreamedApiBody = Response & { + __vinextStreamedApiResponse?: boolean; +}; + +/** + * Marks a Pages API Response whose body was still being written when the + * handler settled (streaming/piping), so Node adapters forward it as a stream. + * Buffering a live stream would defer delivery until the source closes and + * hold the whole body in memory. Complete bodies (`res.json`, `res.send`, + * `res.end(data)`) are not marked and keep the buffered path, which preserves + * Content-Length. + */ +export function markVinextStreamedApiResponse(response: Response): void { + (response as ResponseWithVinextStreamedApiBody).__vinextStreamedApiResponse = true; +} + +export function isVinextStreamedApiResponse(response: Response): boolean { + return (response as ResponseWithVinextStreamedApiBody).__vinextStreamedApiResponse === true; +} + export function createPagesReqRes(options: CreatePagesReqResOptions): CreatePagesReqResResult { const headersObj: Record = {}; for (const [key, value] of options.request.headers) { diff --git a/packages/vinext/src/server/pages-request-pipeline.ts b/packages/vinext/src/server/pages-request-pipeline.ts index 8a462f290..0536a71fa 100644 --- a/packages/vinext/src/server/pages-request-pipeline.ts +++ b/packages/vinext/src/server/pages-request-pipeline.ts @@ -611,12 +611,21 @@ export async function runPagesRequest( apiRequest = cloneRequestWithUrl(request, apiRequestUrl.toString()); } const response = await deps.handleApi(apiRequest, apiLookupUrl, deps.ctx ?? null); + const merged = mergeHeaders(response, middlewareHeaders, middlewareStatus); + // Preserve the streaming marker so the adapter can decide stream-vs-buffer. + // mergeHeaders may create a new Response object (losing non-standard + // properties), so copy the marker from the original API response. + if (merged !== response) { + (merged as { __vinextStreamedApiResponse?: boolean }).__vinextStreamedApiResponse = ( + response as { __vinextStreamedApiResponse?: boolean } + ).__vinextStreamedApiResponse; + } return { type: "response", // API routes return arbitrary data; default a missing content-type to // application/octet-stream (not text/html) to avoid content sniffing. defaultContentType: "application/octet-stream", - response: mergeHeaders(response, middlewareHeaders, middlewareStatus), + response: merged, }; } return { diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index cdb7e0577..1878b50bf 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -449,12 +449,21 @@ function cancelResponseBody(response: Response): void { type ResponseWithVinextStreamingMetadata = Response & { __vinextStreamedHtmlResponse?: boolean; + __vinextStreamedApiResponse?: boolean; }; function isVinextStreamedHtmlResponse(response: Response): boolean { return (response as ResponseWithVinextStreamingMetadata).__vinextStreamedHtmlResponse === true; } +// Set by the Pages API bridge (pages-node-compat.ts) when the Response was +// resolved while the handler was still writing (streaming/piping). Buffering +// such a body would defer delivery until the source closes and hold the whole +// stream in memory. +function isVinextStreamedApiResponse(response: Response): boolean { + return (response as ResponseWithVinextStreamingMetadata).__vinextStreamedApiResponse === true; +} + function logProdServerStarted(host: string, port: number, purpose: ProdServerOptions["purpose"]) { const url = `http://${host}:${port}`; if (purpose === "prerender") { @@ -2234,12 +2243,23 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { res.end("Not Found"); return; } - const shouldStream = isVinextStreamedHtmlResponse(response); + const streamedApi = isVinextStreamedApiResponse(response); + const shouldStream = isVinextStreamedHtmlResponse(response) || streamedApi; // Passthrough responses (middleware short-circuits, external proxies, redirects) // carry no defaultContentType — send them verbatim without injecting a - // Content-Type, matching the pre-refactor behavior. Only buffered render/api - // responses below apply a Content-Type fallback. + // Content-Type, matching the pre-refactor behavior. Buffered render/api + // responses below apply a Content-Type fallback; streamed API responses + // apply the same fallback here since they skip the buffered path. Live + // API bodies also cannot use the buffered path's size threshold without + // defeating streaming, so sendWebResponse chooses compression up front. if (shouldStream || !response.body || result.defaultContentType === undefined) { + if ( + streamedApi && + result.defaultContentType !== undefined && + !response.headers.has("content-type") + ) { + response.headers.set("content-type", result.defaultContentType); + } await sendWebResponse(response, req, res, compress); return; } diff --git a/packages/vinext/src/shims/unified-request-context.ts b/packages/vinext/src/shims/unified-request-context.ts index 223175ff9..46dca3f22 100644 --- a/packages/vinext/src/shims/unified-request-context.ts +++ b/packages/vinext/src/shims/unified-request-context.ts @@ -324,6 +324,9 @@ export function closeAfterResponseWithBody( (wrapped as { __vinextStreamedHtmlResponse?: boolean }).__vinextStreamedHtmlResponse = ( response as { __vinextStreamedHtmlResponse?: boolean } ).__vinextStreamedHtmlResponse; + (wrapped as { __vinextStreamedApiResponse?: boolean }).__vinextStreamedApiResponse = ( + response as { __vinextStreamedApiResponse?: boolean } + ).__vinextStreamedApiResponse; return wrapped; } diff --git a/tests/after-deploy.test.ts b/tests/after-deploy.test.ts index a1c9f7b1a..8939f2318 100644 --- a/tests/after-deploy.test.ts +++ b/tests/after-deploy.test.ts @@ -198,6 +198,55 @@ describe("after() in deploy mode — Pages Router API handler", () => { expect(observedCtx).toBe(ctx); }); + it("keeps streaming handler completion alive through the active execution context", async () => { + const { handlePagesApiRoute } = + await import("../packages/vinext/src/server/pages-api-route.js"); + const { runWithExecutionContext } = + await import("../packages/vinext/src/shims/request-context.js"); + + const ctx = createMockCtx(); + let releaseHandler!: () => void; + const handlerGate = new Promise((resolve) => { + releaseHandler = resolve; + }); + let handlerSettled = false; + + // The ambient form matches the hybrid App/Pages bridge, where the App + // Router owns the request scope and handlePagesApiRoute receives no ctx + // argument of its own. + const response = await runWithExecutionContext(ctx, () => + handlePagesApiRoute({ + match: { + params: {}, + route: { + pattern: "/api/test", + module: { + async default( + _req: unknown, + res: { end: (body?: string) => void; statusCode: number }, + ) { + res.statusCode = 200; + res.end("ok"); + await handlerGate; + handlerSettled = true; + }, + }, + }, + }, + request: new Request("https://example.test/api/test"), + url: "/api/test", + }), + ); + + await expect(response.text()).resolves.toBe("ok"); + expect(handlerSettled).toBe(false); + expect(ctx.promises).toHaveLength(1); + + releaseHandler(); + await ctx.promises[0]; + expect(handlerSettled).toBe(true); + }); + it("handlePagesApiRoute works without ctx (Node dev parity)", async () => { const { handlePagesApiRoute } = await import("../packages/vinext/src/server/pages-api-route.js"); diff --git a/tests/pages-api-route.test.ts b/tests/pages-api-route.test.ts index f7f767af4..7feac3252 100644 --- a/tests/pages-api-route.test.ts +++ b/tests/pages-api-route.test.ts @@ -1,13 +1,16 @@ import { AsyncLocalStorage } from "node:async_hooks"; +import { once } from "node:events"; import http from "node:http"; import type { AddressInfo } from "node:net"; -import { PassThrough } from "node:stream"; +import { PassThrough, Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; import { gzipSync } from "node:zlib"; import { describe, expect, it, vi } from "vite-plus/test"; import { handlePagesApiRoute, type PagesApiRouteMatch, } from "../packages/vinext/src/server/pages-api-route.js"; +import { isVinextStreamedApiResponse } from "../packages/vinext/src/server/pages-node-compat.js"; type PagesApiRouteModule = PagesApiRouteMatch["route"]["module"]; @@ -940,6 +943,177 @@ describe("pages api route", () => { await expect(response.text()).resolves.toBe(body); }); + it("pauses a piped source until the response body is consumed", async () => { + // Availability: a fast source (e.g. a proxied upstream) piped into `res` + // must pause when the client is not reading, instead of queueing the + // entire stream in memory. The write callback is held while the body's + // queue is full, so `write()` returns false and `pipe()` pauses the source. + const totalChunks = 20; + const chunk = Buffer.alloc(64 * 1024, "x"); + let produced = 0; + const source = Readable.from( + (function* () { + for (let i = 0; i < totalChunks; i++) { + produced++; + yield chunk; + } + })(), + ); + + const response = await handlePagesApiRoute({ + match: createMatch( + (_req, res) => { + source.pipe(res); + }, + {}, + { api: { bodyParser: false } }, + ), + request: new Request("https://example.com/api/backpressure"), + url: "/api/backpressure", + }); + + // Let the pipe run as far as it can while nothing reads the body. + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => setImmediate(resolve)); + } + // Without backpressure the source drains completely here even though no + // consumer has read a single byte. + expect(produced).toBeLessThan(totalChunks); + + const body = await response.arrayBuffer(); + expect(body.byteLength).toBe(totalChunks * chunk.length); + expect(produced).toBe(totalChunks); + }); + + it("streams while an API handler awaits a backpressured pipeline", async () => { + // Ported from Next.js's awaited Pages API pipeline pattern: + // https://github.com/vercel/next.js/blob/canary/test/e2e/cancel-request/pages/api/node-api.ts + // The Node socket consumes `res` concurrently with the handler. The Fetch + // bridge must do the same or pipeline() waits for a pull that cannot begin + // until handlePagesApiRoute() returns. + const totalChunks = 20; + const chunk = Buffer.alloc(64 * 1024, "x"); + let handlerSettled = false; + + const response = await handlePagesApiRoute({ + match: createMatch(async (_req, res) => { + await pipeline( + Readable.from( + (function* () { + for (let i = 0; i < totalChunks; i++) yield chunk; + })(), + ), + res, + ); + handlerSettled = true; + }), + request: new Request("https://example.com/api/awaited-pipeline"), + url: "/api/awaited-pipeline", + }); + + expect(handlerSettled).toBe(false); + const body = await response.arrayBuffer(); + expect(body.byteLength).toBe(totalChunks * chunk.length); + await vi.waitFor(() => expect(handlerSettled).toBe(true)); + }); + + it("streams while an API handler awaits drain", async () => { + const totalChunks = 20; + const chunk = Buffer.alloc(64 * 1024, "x"); + let handlerSettled = false; + + const response = await handlePagesApiRoute({ + match: createMatch(async (_req, res) => { + for (let i = 0; i < totalChunks; i++) { + if (!res.write(chunk)) await once(res, "drain"); + } + res.end(); + handlerSettled = true; + }), + request: new Request("https://example.com/api/awaited-drain"), + url: "/api/awaited-drain", + }); + + expect(handlerSettled).toBe(false); + const body = await response.arrayBuffer(); + expect(body.byteLength).toBe(totalChunks * chunk.length); + await vi.waitFor(() => expect(handlerSettled).toBe(true)); + }); + + it("unwinds a parked write when an active streaming handler rejects", async () => { + const reportRequestError = vi.fn(); + const failure = new Error("handler failed after writing"); + let writeError: Error | null | undefined; + + const response = await handlePagesApiRoute({ + match: createMatch(async (_req, res) => { + res.write(Buffer.alloc(64 * 1024), (error: Error | null | undefined) => { + writeError = error; + }); + throw failure; + }), + reportRequestError, + request: new Request("https://example.com/api/reject-after-write"), + url: "/api/reject-after-write", + }); + + expect(response.status).toBe(200); + await expect(response.text()).rejects.toThrow(failure.message); + await vi.waitFor(() => expect(writeError).toBe(failure)); + expect(reportRequestError).toHaveBeenCalledWith(failure, "/api/test"); + }); + + it("passes cancellation errors to a parked write callback", async () => { + const cancellation = new Error("client cancelled"); + let writeError: Error | null | undefined; + + const response = await handlePagesApiRoute({ + match: createMatch((_req, res) => { + res.write(Buffer.alloc(64 * 1024), (error: Error | null | undefined) => { + writeError = error; + }); + }), + request: new Request("https://example.com/api/cancel-parked-write"), + url: "/api/cancel-parked-write", + }); + + await response.body!.cancel(cancellation); + await vi.waitFor(() => expect(writeError).toBe(cancellation)); + }); + + it("marks a response as streamed only while its handler is still writing", async () => { + // Contract with Node adapters (prod-server): live streams must be + // forwarded as streams, while complete bodies stay on the buffered path + // that preserves Content-Length. + const upstream = new PassThrough(); + upstream.write("data"); + const streamed = await handlePagesApiRoute({ + match: createMatch( + (_req, res) => { + // Proxy pattern: the upstream stays open past the handler's return. + upstream.pipe(res); + }, + {}, + { api: { bodyParser: false } }, + ), + request: new Request("https://example.com/api/marker-streamed"), + url: "/api/marker-streamed", + }); + expect(isVinextStreamedApiResponse(streamed)).toBe(true); + upstream.end("-more"); + await expect(streamed.text()).resolves.toBe("data-more"); + + const buffered = await handlePagesApiRoute({ + match: createMatch((_req, res) => { + res.json({ ok: true }); + }), + request: new Request("https://example.com/api/marker-buffered"), + url: "/api/marker-buffered", + }); + expect(isVinextStreamedApiResponse(buffered)).toBe(false); + await expect(buffered.json()).resolves.toEqual({ ok: true }); + }); + it("streams a piped request body through to the response", async () => { const body = "piped-body-content"; const response = await handlePagesApiRoute({