From 839ea7ed25bfbf9fe85d4183d691582de86f0d73 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:32:01 +1000 Subject: [PATCH 1/4] fix(pages-router): stream piped API responses with backpressure A handler that pipes into `res` (proxying an upstream, echoing a request body) could queue the entire stream in memory. The response bridge acknowledged every write immediately, so `write()` never returned false and a piped source was never paused, regardless of how slowly the client consumed the body. The Node production server also read API responses with `arrayBuffer()`, which held the full body in memory and deferred delivery until the source closed, so long-lived streams (e.g. proxied SSE) never flushed. Hold the write callback while the response body's queue is full and release it from the stream's pull hook. A held callback makes `write()` return false, which pauses any piped source until the consumer reads. Mark responses that are still being written when the handler settles and send them through the streaming path in the Node production server. Complete bodies (`res.json`, `res.send`, `res.end(data)`) keep the buffered path and its Content-Length behaviour. --- packages/vinext/src/server/pages-api-route.ts | 11 ++- .../vinext/src/server/pages-node-compat.ts | 50 +++++++++++- .../src/server/pages-request-pipeline.ts | 11 ++- packages/vinext/src/server/prod-server.ts | 24 +++++- .../src/shims/unified-request-context.ts | 3 + tests/pages-api-route.test.ts | 78 ++++++++++++++++++- 6 files changed, 170 insertions(+), 7 deletions(-) diff --git a/packages/vinext/src/server/pages-api-route.ts b/packages/vinext/src/server/pages-api-route.ts index 6779b400c1..78fbf98646 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, @@ -228,7 +229,15 @@ async function _handlePagesApiRoute(options: HandlePagesApiRouteOptions): Promis if (!externalResolver && !resWasPiped && !res.headersSent) { res.end(); } - return await responsePromise; + const response = await responsePromise; + // 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; } 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 a57fa0ad84..ee93cf9c2e 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: (() => void) | null = null; constructor( private readonly resolveResponse: (value: Response) => void, @@ -405,7 +406,22 @@ 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. Writes issued by `end(data)` belong to a complete body — finish + // them immediately so `finish` is not gated on the first read. + 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 +439,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(); if (!this.resolved) { if (error) { this.resolved = true; @@ -464,6 +483,12 @@ class PagesResponseStream extends Writable { this.resHeaders[name.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value; } + private releasePendingWrite(): void { + const callback = this.pendingWrite; + this.pendingWrite = null; + callback?.(); + } + private resolveOnce(): void { if (this.resolved) { return; @@ -497,6 +522,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 +535,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 b8eaecb897..dcab4898d2 100644 --- a/packages/vinext/src/server/pages-request-pipeline.ts +++ b/packages/vinext/src/server/pages-request-pipeline.ts @@ -580,12 +580,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 1bf28ff163..e46e049897 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -437,12 +437,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") { @@ -2009,12 +2018,21 @@ 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. 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 223175ff90..46dca3f22b 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/pages-api-route.test.ts b/tests/pages-api-route.test.ts index f7f767af48..39060cee96 100644 --- a/tests/pages-api-route.test.ts +++ b/tests/pages-api-route.test.ts @@ -1,13 +1,14 @@ import { AsyncLocalStorage } from "node:async_hooks"; import http from "node:http"; import type { AddressInfo } from "node:net"; -import { PassThrough } from "node:stream"; +import { PassThrough, Readable } from "node:stream"; 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 +941,81 @@ 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("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({ From 3735665b7fad1fe6729da90b5691eda94e3310ea Mon Sep 17 00:00:00 2001 From: James Date: Wed, 29 Jul 2026 01:29:05 +0100 Subject: [PATCH 2/4] fix(pages-router): avoid API stream backpressure deadlocks --- packages/vinext/src/server/pages-api-route.ts | 72 +++++++++++++---- tests/pages-api-route.test.ts | 80 +++++++++++++++++++ 2 files changed, 136 insertions(+), 16 deletions(-) diff --git a/packages/vinext/src/server/pages-api-route.ts b/packages/vinext/src/server/pages-api-route.ts index 78fbf98646..b18f51ebd3 100644 --- a/packages/vinext/src/server/pages-api-route.ts +++ b/packages/vinext/src/server/pages-api-route.ts @@ -180,6 +180,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); @@ -221,23 +222,62 @@ 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 response = await responsePromise; - // 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); + 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") { + void handlerCompletion.then(completeHandler, (error) => { + void options.reportRequestError?.(error, route.pattern); + }); + return finalizeResponse(firstSettled.response); } - return response; + + completeHandler(); + return finalizeResponse(await responsePromise); } catch (error) { if (error instanceof PagesApiBodyParseError) { return new Response(error.message, { diff --git a/tests/pages-api-route.test.ts b/tests/pages-api-route.test.ts index 39060cee96..4a477756a5 100644 --- a/tests/pages-api-route.test.ts +++ b/tests/pages-api-route.test.ts @@ -1,7 +1,9 @@ import { AsyncLocalStorage } from "node:async_hooks"; +import { once } from "node:events"; import http from "node:http"; import type { AddressInfo } from "node:net"; 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 { @@ -983,6 +985,84 @@ describe("pages api route", () => { 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 writeSettled = false; + + const response = await handlePagesApiRoute({ + match: createMatch(async (_req, res) => { + res.write(Buffer.alloc(64 * 1024), () => { + writeSettled = true; + }); + 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(writeSettled).toBe(true)); + expect(reportRequestError).toHaveBeenCalledWith(failure, "/api/test"); + }); + 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 From 0a2bbbfb27dcab89a021fc1320a01056d3ec2a5d Mon Sep 17 00:00:00 2001 From: James Date: Wed, 29 Jul 2026 01:36:14 +0100 Subject: [PATCH 3/4] fix(pages-router): preserve streamed API handler lifecycle --- packages/vinext/src/server/pages-api-route.ts | 15 +++++- .../vinext/src/server/pages-node-compat.ts | 8 +-- tests/after-deploy.test.ts | 49 +++++++++++++++++++ tests/pages-api-route.test.ts | 26 ++++++++-- 4 files changed, 88 insertions(+), 10 deletions(-) diff --git a/packages/vinext/src/server/pages-api-route.ts b/packages/vinext/src/server/pages-api-route.ts index b18f51ebd3..4d6daddc61 100644 --- a/packages/vinext/src/server/pages-api-route.ts +++ b/packages/vinext/src/server/pages-api-route.ts @@ -23,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 = { @@ -270,9 +274,16 @@ async function _handlePagesApiRoute(options: HandlePagesApiRouteOptions): Promis // the handler to finish first. const firstSettled = await Promise.race([responseReady, handlerCompletion]); if (firstSettled.type === "response") { - void handlerCompletion.then(completeHandler, (error) => { + 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); } diff --git a/packages/vinext/src/server/pages-node-compat.ts b/packages/vinext/src/server/pages-node-compat.ts index ee93cf9c2e..f3e04eaa29 100644 --- a/packages/vinext/src/server/pages-node-compat.ts +++ b/packages/vinext/src/server/pages-node-compat.ts @@ -254,7 +254,7 @@ class PagesResponseStream extends Writable { private controller: ReadableStreamDefaultController | null = null; private readonly bufferedChunks: Buffer[] = []; private streamEnded = false; - private pendingWrite: (() => void) | null = null; + private pendingWrite: ((error?: Error | null) => void) | null = null; constructor( private readonly resolveResponse: (value: Response) => void, @@ -441,7 +441,7 @@ class PagesResponseStream extends Writable { 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(); + this.releasePendingWrite(error); if (!this.resolved) { if (error) { this.resolved = true; @@ -483,10 +483,10 @@ class PagesResponseStream extends Writable { this.resHeaders[name.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value; } - private releasePendingWrite(): void { + private releasePendingWrite(error?: Error | null): void { const callback = this.pendingWrite; this.pendingWrite = null; - callback?.(); + callback?.(error); } private resolveOnce(): void { diff --git a/tests/after-deploy.test.ts b/tests/after-deploy.test.ts index a1c9f7b1af..8939f23187 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 4a477756a5..7feac3252e 100644 --- a/tests/pages-api-route.test.ts +++ b/tests/pages-api-route.test.ts @@ -1043,12 +1043,12 @@ describe("pages api route", () => { 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 writeSettled = false; + let writeError: Error | null | undefined; const response = await handlePagesApiRoute({ match: createMatch(async (_req, res) => { - res.write(Buffer.alloc(64 * 1024), () => { - writeSettled = true; + res.write(Buffer.alloc(64 * 1024), (error: Error | null | undefined) => { + writeError = error; }); throw failure; }), @@ -1059,10 +1059,28 @@ describe("pages api route", () => { expect(response.status).toBe(200); await expect(response.text()).rejects.toThrow(failure.message); - await vi.waitFor(() => expect(writeSettled).toBe(true)); + 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 From 598c3703c4e4de74b286a001fa6190e5f207ce06 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 29 Jul 2026 01:54:42 +0100 Subject: [PATCH 4/4] docs(pages-router): clarify API stream backpressure --- packages/vinext/src/server/pages-node-compat.ts | 5 +++-- packages/vinext/src/server/prod-server.ts | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/server/pages-node-compat.ts b/packages/vinext/src/server/pages-node-compat.ts index f3e04eaa29..47beb68775 100644 --- a/packages/vinext/src/server/pages-node-compat.ts +++ b/packages/vinext/src/server/pages-node-compat.ts @@ -410,8 +410,9 @@ class PagesResponseStream extends Writable { // 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. Writes issued by `end(data)` belong to a complete body — finish - // them immediately so `finish` is not gated on the first read. + // 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 && diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 7e28eea49f..1878b50bfc 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -2249,7 +2249,9 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { // carry no defaultContentType — send them verbatim without injecting a // 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. + // 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 &&