From 914e4830b7cb431fe13a41a9bd692e42aa626ba9 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Thu, 12 Mar 2026 23:19:43 -0500 Subject: [PATCH 1/7] fix pages router streaming --- packages/vinext/src/server/prod-server.ts | 50 ++++-- tests/features.test.ts | 25 +++ .../pages-basic/pages/streaming-ssr.tsx | 27 +++ tests/pages-router.test.ts | 160 ++++++++++++++++++ 4 files changed, 248 insertions(+), 14 deletions(-) create mode 100644 tests/fixtures/pages-basic/pages/streaming-ssr.tsx diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 951ec231b..69e627579 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -156,6 +156,39 @@ function mergeResponseHeaders( return merged; } +function toWebHeaders(headersRecord: Record): Headers { + const headers = new Headers(); + for (const [key, value] of Object.entries(headersRecord)) { + if (Array.isArray(value)) { + for (const item of value) headers.append(key, item); + } else { + headers.set(key, value); + } + } + return headers; +} + +/** + * Merge middleware/config headers and an optional status override into a new + * Web Response while preserving the original body stream. + */ +function mergeWebResponse( + middlewareHeaders: Record, + response: Response, + statusOverride?: number, +): Response { + if (!Object.keys(middlewareHeaders).length && statusOverride === undefined) { + return response; + } + + const status = statusOverride ?? response.status; + return new Response(response.body, { + status, + statusText: status === response.status ? response.statusText : undefined, + headers: toWebHeaders(mergeResponseHeaders(middlewareHeaders, response)), + }); +} + /** * Send a compressed response if the content type is compressible and the * client supports compression. Otherwise send uncompressed. @@ -1140,23 +1173,11 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { return; } - // Merge middleware + config headers into the response - const responseBody = Buffer.from(await response.arrayBuffer()); - const ct = response.headers.get("content-type") ?? "text/html"; - const responseHeaders = mergeResponseHeaders(middlewareHeaders, response); - const finalStatus = middlewareRewriteStatus ?? response.status; - const finalStatusText = - finalStatus === response.status ? response.statusText || undefined : undefined; - - sendCompressed( + await sendWebResponse( + mergeWebResponse(middlewareHeaders, response, middlewareRewriteStatus), req, res, - responseBody, - ct, - finalStatus, - responseHeaders, compress, - finalStatusText, ); } catch (e) { console.error("[vinext] Server error:", e); @@ -1190,4 +1211,5 @@ export { trustProxy, nodeToWebRequest, mergeResponseHeaders, + mergeWebResponse, }; diff --git a/tests/features.test.ts b/tests/features.test.ts index f530b34ed..d2c97eb4b 100644 --- a/tests/features.test.ts +++ b/tests/features.test.ts @@ -3170,6 +3170,31 @@ describe("Set-Cookie header preservation in prod-server", () => { expect(merged["x-custom"]).toBe("from-response"); }); + it("mergeWebResponse preserves the original body stream while applying header overrides", async () => { + const { mergeWebResponse } = await import("../packages/vinext/src/server/prod-server.js"); + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("hello")); + controller.close(); + }, + }); + const response = new Response(stream, { + status: 200, + headers: { + "content-type": "text/plain", + "x-custom": "from-response", + }, + }); + + const merged = mergeWebResponse({ "x-custom": "from-middleware" }, response, 201); + + expect(merged.status).toBe(201); + expect(merged.headers.get("x-custom")).toBe("from-response"); + expect(merged.headers.get("content-type")).toBe("text/plain"); + expect(await merged.text()).toBe("hello"); + }); + it("sendCompressed passes array-valued Set-Cookie to writeHead", async () => { const { sendCompressed } = await import("../packages/vinext/src/server/prod-server.js"); diff --git a/tests/fixtures/pages-basic/pages/streaming-ssr.tsx b/tests/fixtures/pages-basic/pages/streaming-ssr.tsx new file mode 100644 index 000000000..66be2d4ae --- /dev/null +++ b/tests/fixtures/pages-basic/pages/streaming-ssr.tsx @@ -0,0 +1,27 @@ +import React, { Suspense, lazy } from "react"; + +const DelayedChunk = lazy( + () => + new Promise<{ default: React.ComponentType }>((resolve) => { + // Keep the boundary pending long enough for production streaming tests + // to observe the fallback before the final content arrives. + setTimeout(() => { + resolve({ + default: function DelayedChunkImpl() { + return
Delayed stream content loaded
; + }, + }); + }, 600); + }), +); + +export default function StreamingSsrPage() { + return ( +
+

Streaming SSR Test

+ Loading delayed chunk...}> + + +
+ ); +} diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 9833d0dcc..13e6cac48 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; import { createServer, build, type ViteDevServer } from "vite"; +import { request as httpRequest, type IncomingHttpHeaders } from "node:http"; import path from "node:path"; import fs from "node:fs"; import fsp from "node:fs/promises"; @@ -36,6 +37,91 @@ export default function middleware() { ); } +async function buildPagesFixture(rootDir: string, outDir: string): Promise { + await build({ + root: rootDir, + configFile: false, + plugins: [vinext()], + logLevel: "silent", + build: { + outDir: path.join(outDir, "server"), + ssr: "virtual:vinext-server-entry", + rollupOptions: { output: { entryFileNames: "entry.js" } }, + }, + }); + + await build({ + root: rootDir, + configFile: false, + plugins: [vinext()], + logLevel: "silent", + build: { + outDir: path.join(outDir, "client"), + manifest: true, + ssrManifest: true, + rollupOptions: { input: "virtual:vinext-client-entry" }, + }, + }); +} + +interface CapturedStreamResponse { + body: Buffer; + headers: IncomingHttpHeaders; + statusCode: number; + firstChunkMs: number; + endMs: number; + snapshot: Buffer; +} + +async function captureStreamedResponse( + url: string, + options: { headers?: Record; snapshotDelayMs?: number } = {}, +): Promise { + const { headers = {}, snapshotDelayMs = 120 } = options; + + return await new Promise((resolve, reject) => { + const startedAt = Date.now(); + const req = httpRequest(url, { headers }, (res) => { + const chunks: Buffer[] = []; + let firstChunkMs = -1; + let snapshot = Buffer.alloc(0); + let snapshotCaptured = false; + let snapshotTimer: ReturnType | undefined; + + const captureSnapshot = () => { + if (snapshotCaptured) return; + snapshotCaptured = true; + snapshot = Buffer.concat(chunks); + }; + + res.on("data", (chunk: Buffer) => { + chunks.push(chunk); + if (firstChunkMs !== -1) return; + firstChunkMs = Date.now() - startedAt; + snapshotTimer = setTimeout(captureSnapshot, snapshotDelayMs); + }); + + res.on("end", () => { + if (snapshotTimer) clearTimeout(snapshotTimer); + captureSnapshot(); + resolve({ + body: Buffer.concat(chunks), + headers: res.headers, + statusCode: res.statusCode ?? 0, + firstChunkMs, + endMs: Date.now() - startedAt, + snapshot, + }); + }); + + res.on("error", reject); + }); + + req.on("error", reject); + req.end(); + }); +} + describe("Pages Router integration", () => { let server: ViteDevServer; let baseUrl: string; @@ -2243,6 +2329,80 @@ describe("Production server middleware (Pages Router)", () => { }); }); +describe("Production Pages Router SSR streaming", () => { + let outDir: string; + let prodServer: import("node:http").Server; + let prodUrl: string; + + beforeAll(async () => { + outDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-streaming-prod-")); + await fsp.symlink( + path.resolve(import.meta.dirname, "../node_modules"), + path.join(outDir, "node_modules"), + "junction", + ); + await buildPagesFixture(FIXTURE_DIR, outDir); + + const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); + prodServer = await startProdServer({ + port: 0, + host: "127.0.0.1", + outDir, + noCompression: true, + }); + const addr = prodServer.address() as { port: number }; + prodUrl = `http://127.0.0.1:${addr.port}`; + }, 60000); + + afterAll(async () => { + if (prodServer) { + await new Promise((resolve) => prodServer.close(() => resolve())); + } + if (outDir) { + fs.rmSync(outDir, { recursive: true, force: true }); + } + }); + + it("streams Pages SSR responses incrementally in production", async () => { + // Parity target: Next.js streams Node responses via sendResponse() -> + // pipeToNodeResponse() instead of buffering the full HTML first. + // https://raw.githubusercontent.com/vercel/next.js/canary/packages/next/src/server/send-response.ts + // https://raw.githubusercontent.com/vercel/next.js/canary/packages/next/src/server/pipe-readable.ts + const response = await captureStreamedResponse(`${prodUrl}/streaming-ssr`); + const partialHtml = response.snapshot.toString("utf8"); + const finalHtml = response.body.toString("utf8"); + const contentType = response.headers["content-type"]; + const middlewareHeader = response.headers["x-custom-middleware"]; + const transferEncoding = response.headers["transfer-encoding"]; + + expect(response.statusCode).toBe(200); + expect(String(contentType)).toContain("text/html"); + expect(String(middlewareHeader)).toBe("active"); + expect(response.headers["content-length"]).toBeUndefined(); + expect(String(transferEncoding)).toBe("chunked"); + expect(response.endMs).toBeGreaterThanOrEqual(400); + expect(response.firstChunkMs).toBeGreaterThanOrEqual(0); + expect(response.firstChunkMs).toBeLessThan(response.endMs - 250); + + expect(partialHtml).toContain("Streaming SSR Test"); + expect(partialHtml).toContain("Loading delayed chunk..."); + expect(partialHtml).not.toContain("Delayed stream content loaded"); + + expect(finalHtml).toContain("Streaming SSR Test"); + expect(finalHtml).toContain("Delayed stream content loaded"); + expect(finalHtml).toContain("__NEXT_DATA__"); + }); + + it("preserves streamed SSR bodies when middleware rewrites are merged into the response", async () => { + const res = await fetch(`${prodUrl}/streaming-ssr`); + expect(res.status).toBe(200); + expect(res.headers.get("x-custom-middleware")).toBe("active"); + + const html = await res.text(); + expect(html).toContain("Delayed stream content loaded"); + }); +}); + describe("Production server next.config.js features (Pages Router)", () => { const outDir = path.resolve(FIXTURE_DIR, "dist"); let prodServer: import("node:http").Server; From 4565b5863cec39276f5ff2021fbac8f121de7ff2 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Fri, 13 Mar 2026 00:32:28 -0500 Subject: [PATCH 2/7] handle regressions --- packages/vinext/src/server/prod-server.ts | 56 ++++++++- .../entry-templates.test.ts.snap | 34 +++--- tests/fixtures/pages-basic/middleware.ts | 7 ++ .../pages/streaming-gssp-content-length.tsx | 39 +++++++ tests/pages-router.test.ts | 109 +++++++++++++++++- 5 files changed, 226 insertions(+), 19 deletions(-) create mode 100644 tests/fixtures/pages-basic/pages/streaming-gssp-content-length.tsx diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 69e627579..d4ed7998e 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -168,24 +168,72 @@ function toWebHeaders(headersRecord: Record): Headers return headers; } +const NO_BODY_RESPONSE_STATUSES = new Set([204, 205, 304]); + +function hasHeader(headersRecord: Record, name: string): boolean { + const target = name.toLowerCase(); + return Object.keys(headersRecord).some((key) => key.toLowerCase() === target); +} + +function stripHeaders( + headersRecord: Record, + names: readonly string[], +): void { + const targets = new Set(names.map((name) => name.toLowerCase())); + for (const key of Object.keys(headersRecord)) { + if (targets.has(key.toLowerCase())) delete headersRecord[key]; + } +} + +function isNoBodyResponseStatus(status: number): boolean { + return NO_BODY_RESPONSE_STATUSES.has(status); +} + /** * Merge middleware/config headers and an optional status override into a new - * Web Response while preserving the original body stream. + * Web Response while preserving the original body stream when allowed. */ function mergeWebResponse( middlewareHeaders: Record, response: Response, statusOverride?: number, ): Response { - if (!Object.keys(middlewareHeaders).length && statusOverride === undefined) { + const status = statusOverride ?? response.status; + const mergedHeaders = mergeResponseHeaders(middlewareHeaders, response); + const shouldDropBody = isNoBodyResponseStatus(status); + const shouldStripStreamLength = !!response.body && hasHeader(mergedHeaders, "content-length"); + + if ( + !Object.keys(middlewareHeaders).length && + statusOverride === undefined && + !shouldDropBody && + !shouldStripStreamLength + ) { return response; } - const status = statusOverride ?? response.status; + if (shouldDropBody) { + stripHeaders(mergedHeaders, [ + "content-encoding", + "content-length", + "content-type", + "transfer-encoding", + ]); + return new Response(null, { + status, + statusText: status === response.status ? response.statusText : undefined, + headers: toWebHeaders(mergedHeaders), + }); + } + + if (shouldStripStreamLength) { + stripHeaders(mergedHeaders, ["content-length"]); + } + return new Response(response.body, { status, statusText: status === response.status ? response.statusText : undefined, - headers: toWebHeaders(mergeResponseHeaders(middlewareHeaders, response)), + headers: toWebHeaders(mergedHeaders), }); } diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index e78ea28f3..8184c0e89 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -17850,6 +17850,8 @@ const pageLoaders = { "/ssr": () => import("/tests/fixtures/pages-basic/pages/ssr.tsx"), "/ssr-headers": () => import("/tests/fixtures/pages-basic/pages/ssr-headers.tsx"), "/ssr-res-end": () => import("/tests/fixtures/pages-basic/pages/ssr-res-end.tsx"), + "/streaming-gssp-content-length": () => import("/tests/fixtures/pages-basic/pages/streaming-gssp-content-length.tsx"), + "/streaming-ssr": () => import("/tests/fixtures/pages-basic/pages/streaming-ssr.tsx"), "/suspense-test": () => import("/tests/fixtures/pages-basic/pages/suspense-test.tsx"), "/articles/[id]": () => import("/tests/fixtures/pages-basic/pages/articles/[id].tsx"), "/blog/[slug]": () => import("/tests/fixtures/pages-basic/pages/blog/[slug].tsx"), @@ -18055,13 +18057,15 @@ import * as page_21 from "/tests/fixtures/pages-basic/pages/shallow-test.t import * as page_22 from "/tests/fixtures/pages-basic/pages/ssr.tsx"; import * as page_23 from "/tests/fixtures/pages-basic/pages/ssr-headers.tsx"; import * as page_24 from "/tests/fixtures/pages-basic/pages/ssr-res-end.tsx"; -import * as page_25 from "/tests/fixtures/pages-basic/pages/suspense-test.tsx"; -import * as page_26 from "/tests/fixtures/pages-basic/pages/articles/[id].tsx"; -import * as page_27 from "/tests/fixtures/pages-basic/pages/blog/[slug].tsx"; -import * as page_28 from "/tests/fixtures/pages-basic/pages/posts/[id].tsx"; -import * as page_29 from "/tests/fixtures/pages-basic/pages/products/[pid].tsx"; -import * as page_30 from "/tests/fixtures/pages-basic/pages/docs/[...slug].tsx"; -import * as page_31 from "/tests/fixtures/pages-basic/pages/sign-up/[[...sign-up]]/index.tsx"; +import * as page_25 from "/tests/fixtures/pages-basic/pages/streaming-gssp-content-length.tsx"; +import * as page_26 from "/tests/fixtures/pages-basic/pages/streaming-ssr.tsx"; +import * as page_27 from "/tests/fixtures/pages-basic/pages/suspense-test.tsx"; +import * as page_28 from "/tests/fixtures/pages-basic/pages/articles/[id].tsx"; +import * as page_29 from "/tests/fixtures/pages-basic/pages/blog/[slug].tsx"; +import * as page_30 from "/tests/fixtures/pages-basic/pages/posts/[id].tsx"; +import * as page_31 from "/tests/fixtures/pages-basic/pages/products/[pid].tsx"; +import * as page_32 from "/tests/fixtures/pages-basic/pages/docs/[...slug].tsx"; +import * as page_33 from "/tests/fixtures/pages-basic/pages/sign-up/[[...sign-up]]/index.tsx"; import * as api_0 from "/tests/fixtures/pages-basic/pages/api/binary.ts"; import * as api_1 from "/tests/fixtures/pages-basic/pages/api/echo-body.ts"; import * as api_2 from "/tests/fixtures/pages-basic/pages/api/error-route.ts"; @@ -18102,13 +18106,15 @@ const pageRoutes = [ { pattern: "/ssr", patternParts: ["ssr"], isDynamic: false, params: [], module: page_22, filePath: "/tests/fixtures/pages-basic/pages/ssr.tsx" }, { pattern: "/ssr-headers", patternParts: ["ssr-headers"], isDynamic: false, params: [], module: page_23, filePath: "/tests/fixtures/pages-basic/pages/ssr-headers.tsx" }, { pattern: "/ssr-res-end", patternParts: ["ssr-res-end"], isDynamic: false, params: [], module: page_24, filePath: "/tests/fixtures/pages-basic/pages/ssr-res-end.tsx" }, - { pattern: "/suspense-test", patternParts: ["suspense-test"], isDynamic: false, params: [], module: page_25, filePath: "/tests/fixtures/pages-basic/pages/suspense-test.tsx" }, - { pattern: "/articles/:id", patternParts: ["articles",":id"], isDynamic: true, params: ["id"], module: page_26, filePath: "/tests/fixtures/pages-basic/pages/articles/[id].tsx" }, - { pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, params: ["slug"], module: page_27, filePath: "/tests/fixtures/pages-basic/pages/blog/[slug].tsx" }, - { pattern: "/posts/:id", patternParts: ["posts",":id"], isDynamic: true, params: ["id"], module: page_28, filePath: "/tests/fixtures/pages-basic/pages/posts/[id].tsx" }, - { pattern: "/products/:pid", patternParts: ["products",":pid"], isDynamic: true, params: ["pid"], module: page_29, filePath: "/tests/fixtures/pages-basic/pages/products/[pid].tsx" }, - { pattern: "/docs/:slug+", patternParts: ["docs",":slug+"], isDynamic: true, params: ["slug"], module: page_30, filePath: "/tests/fixtures/pages-basic/pages/docs/[...slug].tsx" }, - { pattern: "/sign-up/:sign-up*", patternParts: ["sign-up",":sign-up*"], isDynamic: true, params: ["sign-up"], module: page_31, filePath: "/tests/fixtures/pages-basic/pages/sign-up/[[...sign-up]]/index.tsx" } + { pattern: "/streaming-gssp-content-length", patternParts: ["streaming-gssp-content-length"], isDynamic: false, params: [], module: page_25, filePath: "/tests/fixtures/pages-basic/pages/streaming-gssp-content-length.tsx" }, + { pattern: "/streaming-ssr", patternParts: ["streaming-ssr"], isDynamic: false, params: [], module: page_26, filePath: "/tests/fixtures/pages-basic/pages/streaming-ssr.tsx" }, + { pattern: "/suspense-test", patternParts: ["suspense-test"], isDynamic: false, params: [], module: page_27, filePath: "/tests/fixtures/pages-basic/pages/suspense-test.tsx" }, + { pattern: "/articles/:id", patternParts: ["articles",":id"], isDynamic: true, params: ["id"], module: page_28, filePath: "/tests/fixtures/pages-basic/pages/articles/[id].tsx" }, + { pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, params: ["slug"], module: page_29, filePath: "/tests/fixtures/pages-basic/pages/blog/[slug].tsx" }, + { pattern: "/posts/:id", patternParts: ["posts",":id"], isDynamic: true, params: ["id"], module: page_30, filePath: "/tests/fixtures/pages-basic/pages/posts/[id].tsx" }, + { pattern: "/products/:pid", patternParts: ["products",":pid"], isDynamic: true, params: ["pid"], module: page_31, filePath: "/tests/fixtures/pages-basic/pages/products/[pid].tsx" }, + { pattern: "/docs/:slug+", patternParts: ["docs",":slug+"], isDynamic: true, params: ["slug"], module: page_32, filePath: "/tests/fixtures/pages-basic/pages/docs/[...slug].tsx" }, + { pattern: "/sign-up/:sign-up*", patternParts: ["sign-up",":sign-up*"], isDynamic: true, params: ["sign-up"], module: page_33, filePath: "/tests/fixtures/pages-basic/pages/sign-up/[[...sign-up]]/index.tsx" } ]; const _pageRouteTrie = _buildRouteTrie(pageRoutes); diff --git a/tests/fixtures/pages-basic/middleware.ts b/tests/fixtures/pages-basic/middleware.ts index 14f62ac45..4fcca4998 100644 --- a/tests/fixtures/pages-basic/middleware.ts +++ b/tests/fixtures/pages-basic/middleware.ts @@ -26,6 +26,13 @@ export function middleware(request: NextRequest) { return NextResponse.rewrite(new URL("/ssr", request.url)); } + if (url.pathname === "/middleware-bad-content-length") { + const res = NextResponse.rewrite(new URL("/streaming-ssr", request.url)); + res.headers.set("content-length", "1"); + res.headers.set("x-custom-middleware", "active"); + return res; + } + if (url.pathname === "/headers-before-middleware-rewrite") { return NextResponse.rewrite(new URL("/ssr", request.url)); } diff --git a/tests/fixtures/pages-basic/pages/streaming-gssp-content-length.tsx b/tests/fixtures/pages-basic/pages/streaming-gssp-content-length.tsx new file mode 100644 index 000000000..5f22d4df5 --- /dev/null +++ b/tests/fixtures/pages-basic/pages/streaming-gssp-content-length.tsx @@ -0,0 +1,39 @@ +import React, { Suspense, lazy } from "react"; + +const DelayedChunk = lazy( + () => + new Promise<{ default: React.ComponentType }>((resolve) => { + setTimeout(() => { + resolve({ + default: function DelayedChunkImpl() { + return ( +
Delayed gSSP stream content loaded
+ ); + }, + }); + }, 600); + }), +); + +export async function getServerSideProps({ + res, +}: { + res: { setHeader: (key: string, value: string) => void }; +}) { + // Simulate a userland length that would be stale once the streamed HTML starts flowing. + res.setHeader("Content-Length", "1"); + return { props: {} }; +} + +export default function StreamingGsspContentLengthPage() { + return ( +
+

Streaming gSSP Content-Length Test

+ Loading delayed gSSP chunk...} + > + + +
+ ); +} diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 13e6cac48..a91fc6655 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -2382,7 +2382,6 @@ describe("Production Pages Router SSR streaming", () => { expect(String(transferEncoding)).toBe("chunked"); expect(response.endMs).toBeGreaterThanOrEqual(400); expect(response.firstChunkMs).toBeGreaterThanOrEqual(0); - expect(response.firstChunkMs).toBeLessThan(response.endMs - 250); expect(partialHtml).toContain("Streaming SSR Test"); expect(partialHtml).toContain("Loading delayed chunk..."); @@ -2401,6 +2400,37 @@ describe("Production Pages Router SSR streaming", () => { const html = await res.text(); expect(html).toContain("Delayed stream content loaded"); }); + + it("strips stale content-length from streamed Pages SSR responses when gSSP sets one", async () => { + // Parity target: Next.js only sets Content-Length for unchunked render + // payloads; streamed HTML is sent without one. + // https://raw.githubusercontent.com/vercel/next.js/canary/packages/next/src/server/send-payload.ts + const response = await captureStreamedResponse(`${prodUrl}/streaming-gssp-content-length`); + const partialHtml = response.snapshot.toString("utf8"); + const finalHtml = response.body.toString("utf8"); + + expect(response.statusCode).toBe(200); + expect(response.headers["content-length"]).toBeUndefined(); + expect(String(response.headers["transfer-encoding"])).toBe("chunked"); + expect(partialHtml).toContain("Loading delayed gSSP chunk..."); + expect(partialHtml).not.toContain("Delayed gSSP stream content loaded"); + expect(finalHtml).toContain("Streaming gSSP Content-Length Test"); + expect(finalHtml).toContain("Delayed gSSP stream content loaded"); + }); + + it("strips middleware-provided content-length when rewriting to a streamed Pages SSR response", async () => { + // Parity target: Next.js route resolution explicitly skips forwarding + // middleware content-length headers. + // https://raw.githubusercontent.com/vercel/next.js/canary/packages/next/src/server/lib/router-utils/resolve-routes.ts + const response = await captureStreamedResponse(`${prodUrl}/middleware-bad-content-length`); + const html = response.body.toString("utf8"); + + expect(response.statusCode).toBe(200); + expect(response.headers["content-length"]).toBeUndefined(); + expect(String(response.headers["transfer-encoding"])).toBe("chunked"); + expect(html).toContain("Streaming SSR Test"); + expect(html).toContain("Delayed stream content loaded"); + }); }); describe("Production server next.config.js features (Pages Router)", () => { @@ -2848,6 +2878,83 @@ export function middleware(request) { }); }); +describe("Pages Router production no-body rewrite statuses", () => { + let tmpRoot: string; + let outDir: string; + let prodServer: import("node:http").Server; + let prodUrl: string; + + beforeAll(async () => { + tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-no-body-rewrite-")); + outDir = path.join(tmpRoot, "dist"); + + await fsp.symlink( + path.resolve(import.meta.dirname, "../node_modules"), + path.join(tmpRoot, "node_modules"), + "junction", + ); + await fsp.mkdir(path.join(tmpRoot, "pages"), { recursive: true }); + + await fsp.writeFile(path.join(tmpRoot, "package.json"), JSON.stringify({ type: "module" })); + await fsp.writeFile(path.join(tmpRoot, "next.config.mjs"), `export default {};\n`); + await fsp.writeFile( + path.join(tmpRoot, "middleware.ts"), + `import { NextResponse } from "next/server"; +export function middleware(request) { + const url = new URL(request.url); + const match = url.pathname.match(/^\\/status-(204|205|304)$/); + if (!match) return NextResponse.next(); + return NextResponse.rewrite(new URL("/target", request.url), { status: Number(match[1]) }); +} +`, + ); + await fsp.writeFile( + path.join(tmpRoot, "pages", "index.tsx"), + `export default function Home() { + return
home
; +} +`, + ); + await fsp.writeFile( + path.join(tmpRoot, "pages", "target.tsx"), + `export default function TargetPage() { + return
TARGET PAGE
; +} +`, + ); + + await buildPagesFixture(tmpRoot, outDir); + + const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); + prodServer = await startProdServer({ + port: 0, + host: "127.0.0.1", + outDir, + noCompression: true, + }); + const addr = prodServer.address() as { port: number }; + prodUrl = `http://127.0.0.1:${addr.port}`; + }, 60000); + + afterAll(async () => { + if (prodServer) { + await new Promise((resolve) => prodServer.close(() => resolve())); + } + if (tmpRoot) { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + }); + + for (const statusCode of [204, 205, 304]) { + it(`preserves middleware rewrite status ${statusCode} for Pages SSR responses in production`, async () => { + const res = await fetch(`${prodUrl}/status-${statusCode}`); + + expect(res.status).toBe(statusCode); + expect(await res.text()).toBe(""); + }); + } +}); + describe("router __NEXT_DATA__ correctness (Pages Router)", () => { let routerServer: ViteDevServer; let routerBaseUrl: string; From 25dc13ab7c0e42313284e4643ed81587abfde09e Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Mon, 16 Mar 2026 21:29:36 -0500 Subject: [PATCH 3/7] fix: align pages response merge behavior --- packages/vinext/src/deploy.ts | 41 +++++++++- .../vinext/src/entries/pages-server-entry.ts | 9 ++- packages/vinext/src/server/prod-server.ts | 46 +++++++++-- packages/vinext/src/server/worker-utils.ts | 46 ++++++++++- .../entry-templates.test.ts.snap | 9 ++- tests/deploy.test.ts | 68 ++++++++++++++++ tests/features.test.ts | 81 +++++++++++++++++++ .../pages-basic/pages/ssr-res-end.tsx | 4 +- tests/pages-router.test.ts | 9 +++ 9 files changed, 296 insertions(+), 17 deletions(-) diff --git a/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts index 3101716d4..df72197ce 100644 --- a/packages/vinext/src/deploy.ts +++ b/packages/vinext/src/deploy.ts @@ -806,7 +806,12 @@ function mergeHeaders( extraHeaders: Record, statusOverride?: number, ): Response { - if (!Object.keys(extraHeaders).length && !statusOverride) return response; + const NO_BODY_RESPONSE_STATUSES = new Set([204, 205, 304]); + function isVinextStreamedHtmlResponse(response: Response): boolean { + return response.__vinextStreamedHtmlResponse === true; + } + + const status = statusOverride ?? response.status; const merged = new Headers(); // Middleware/config headers go in first (lower precedence) for (const [k, v] of Object.entries(extraHeaders)) { @@ -824,9 +829,39 @@ function mergeHeaders( }); const responseCookies = response.headers.getSetCookie?.() ?? []; for (const cookie of responseCookies) merged.append("set-cookie", cookie); + + const shouldDropBody = NO_BODY_RESPONSE_STATUSES.has(status); + const shouldStripStreamLength = + isVinextStreamedHtmlResponse(response) && merged.has("content-length"); + + if ( + !Object.keys(extraHeaders).length && + statusOverride === undefined && + !shouldDropBody && + !shouldStripStreamLength + ) { + return response; + } + + if (shouldDropBody) { + merged.delete("content-encoding"); + merged.delete("content-length"); + merged.delete("content-type"); + merged.delete("transfer-encoding"); + return new Response(null, { + status, + statusText: status === response.status ? response.statusText : undefined, + headers: merged, + }); + } + + if (shouldStripStreamLength) { + merged.delete("content-length"); + } + return new Response(response.body, { - status: statusOverride ?? response.status, - statusText: response.statusText, + status, + statusText: status === response.status ? response.statusText : undefined, headers: merged, }); } diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index 7344e1d3e..c10e05a10 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -1065,7 +1065,14 @@ async function _renderPage(request, url, manifest) { if (_fontLinkHeader) { responseHeaders.set("Link", _fontLinkHeader); } - return new Response(compositeStream, { status: finalStatus, headers: responseHeaders }); + const streamedPageResponse = new Response(compositeStream, { + status: finalStatus, + headers: responseHeaders, + }); + // Mark the normal streamed HTML render so the Node prod server can strip + // stale Content-Length only for this path, not for custom gSSP responses. + streamedPageResponse.__vinextStreamedHtmlResponse = true; + return streamedPageResponse; } catch (e) { console.error("[vinext] SSR error:", e); _reportRequestError( diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index d4ed7998e..6401497b1 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -189,6 +189,14 @@ function isNoBodyResponseStatus(status: number): boolean { return NO_BODY_RESPONSE_STATUSES.has(status); } +type ResponseWithVinextStreamingMetadata = Response & { + __vinextStreamedHtmlResponse?: boolean; +}; + +function isVinextStreamedHtmlResponse(response: Response): boolean { + return (response as ResponseWithVinextStreamingMetadata).__vinextStreamedHtmlResponse === true; +} + /** * Merge middleware/config headers and an optional status override into a new * Web Response while preserving the original body stream when allowed. @@ -201,7 +209,8 @@ function mergeWebResponse( const status = statusOverride ?? response.status; const mergedHeaders = mergeResponseHeaders(middlewareHeaders, response); const shouldDropBody = isNoBodyResponseStatus(status); - const shouldStripStreamLength = !!response.body && hasHeader(mergedHeaders, "content-length"); + const shouldStripStreamLength = + isVinextStreamedHtmlResponse(response) && hasHeader(mergedHeaders, "content-length"); if ( !Object.keys(middlewareHeaders).length && @@ -254,6 +263,13 @@ function sendCompressed( const buf = typeof body === "string" ? Buffer.from(body) : body; const baseType = contentType.split(";")[0].trim(); const encoding = compress ? negotiateEncoding(req) : null; + const { + "content-length": _cl, + "Content-Length": _CL, + "content-type": _ct, + "Content-Type": _CT, + ...headersWithoutBodyHeaders + } = extraHeaders; const writeHead = (headers: Record) => { if (statusText) { @@ -280,7 +296,7 @@ function sendCompressed( varyValue = "Accept-Encoding"; } writeHead({ - ...extraHeaders, + ...headersWithoutBodyHeaders, "Content-Type": contentType, "Content-Encoding": encoding, Vary: varyValue, @@ -290,11 +306,8 @@ function sendCompressed( /* ignore pipeline errors on closed connections */ }); } else { - // Strip any pre-existing content-length (from the Web Response constructor) - // before setting our own — avoids duplicate Content-Length headers. - const { "content-length": _cl, "Content-Length": _CL, ...headersWithoutLength } = extraHeaders; writeHead({ - ...headersWithoutLength, + ...headersWithoutBodyHeaders, "Content-Type": contentType, "Content-Length": String(buf.length), }); @@ -1221,11 +1234,28 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { return; } - await sendWebResponse( - mergeWebResponse(middlewareHeaders, response, middlewareRewriteStatus), + const shouldStreamPagesResponse = isVinextStreamedHtmlResponse(response); + const mergedResponse = mergeWebResponse(middlewareHeaders, response, middlewareRewriteStatus); + + if (shouldStreamPagesResponse || !mergedResponse.body) { + await sendWebResponse(mergedResponse, req, res, compress); + return; + } + + const responseBody = Buffer.from(await mergedResponse.arrayBuffer()); + const ct = mergedResponse.headers.get("content-type") ?? "text/html"; + const responseHeaders = mergeResponseHeaders({}, mergedResponse); + const finalStatusText = mergedResponse.statusText || undefined; + + sendCompressed( req, res, + responseBody, + ct, + mergedResponse.status, + responseHeaders, compress, + finalStatusText, ); } catch (e) { console.error("[vinext] Server error:", e); diff --git a/packages/vinext/src/server/worker-utils.ts b/packages/vinext/src/server/worker-utils.ts index a0d69e14a..f382b149d 100644 --- a/packages/vinext/src/server/worker-utils.ts +++ b/packages/vinext/src/server/worker-utils.ts @@ -12,12 +12,22 @@ * except Set-Cookie, which is additive (both middleware and response cookies * are preserved). Uses getSetCookie() to preserve multiple Set-Cookie values. */ +const NO_BODY_RESPONSE_STATUSES = new Set([204, 205, 304]); + +type ResponseWithVinextStreamingMetadata = Response & { + __vinextStreamedHtmlResponse?: boolean; +}; + +function isVinextStreamedHtmlResponse(response: Response): boolean { + return (response as ResponseWithVinextStreamingMetadata).__vinextStreamedHtmlResponse === true; +} + export function mergeHeaders( response: Response, extraHeaders: Record, statusOverride?: number, ): Response { - if (!Object.keys(extraHeaders).length && !statusOverride) return response; + const status = statusOverride ?? response.status; const merged = new Headers(); for (const [k, v] of Object.entries(extraHeaders)) { if (Array.isArray(v)) { @@ -32,9 +42,39 @@ export function mergeHeaders( }); const responseCookies = response.headers.getSetCookie?.() ?? []; for (const cookie of responseCookies) merged.append("set-cookie", cookie); + + const shouldDropBody = NO_BODY_RESPONSE_STATUSES.has(status); + const shouldStripStreamLength = + isVinextStreamedHtmlResponse(response) && merged.has("content-length"); + + if ( + !Object.keys(extraHeaders).length && + statusOverride === undefined && + !shouldDropBody && + !shouldStripStreamLength + ) { + return response; + } + + if (shouldDropBody) { + merged.delete("content-encoding"); + merged.delete("content-length"); + merged.delete("content-type"); + merged.delete("transfer-encoding"); + return new Response(null, { + status, + statusText: status === response.status ? response.statusText : undefined, + headers: merged, + }); + } + + if (shouldStripStreamLength) { + merged.delete("content-length"); + } + return new Response(response.body, { - status: statusOverride ?? response.status, - statusText: response.statusText, + status, + statusText: status === response.status ? response.statusText : undefined, headers: merged, }); } diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 8184c0e89..4e6d3c78f 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -18817,7 +18817,14 @@ async function _renderPage(request, url, manifest) { if (_fontLinkHeader) { responseHeaders.set("Link", _fontLinkHeader); } - return new Response(compositeStream, { status: finalStatus, headers: responseHeaders }); + const streamedPageResponse = new Response(compositeStream, { + status: finalStatus, + headers: responseHeaders, + }); + // Mark the normal streamed HTML render so the Node prod server can strip + // stale Content-Length only for this path, not for custom gSSP responses. + streamedPageResponse.__vinextStreamedHtmlResponse = true; + return streamedPageResponse; } catch (e) { console.error("[vinext] SSR error:", e); _reportRequestError( diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 60151be19..57fa590d9 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -641,6 +641,74 @@ describe("generatePagesRouterWorkerEntry", () => { expect(merged.headers.get("x-custom")).toBe("from-middleware"); }); + it("mergeHeaders drops the body for no-body middleware rewrite statuses", async () => { + for (const status of [204, 205, 304]) { + const response = new Response("body", { + status: 200, + headers: { "content-type": "text/plain", "content-length": "4" }, + }); + + const merged = mergeHeaders(response, { "x-custom": "from-middleware" }, status); + + expect(merged.status).toBe(status); + expect(merged.headers.get("x-custom")).toBe("from-middleware"); + expect(merged.headers.get("content-type")).toBeNull(); + expect(merged.headers.get("content-length")).toBeNull(); + expect(await merged.text()).toBe(""); + } + }); + + it("mergeHeaders strips stale content-length only for tagged streamed Pages HTML", async () => { + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("hello")); + controller.close(); + }, + }), + { + status: 200, + headers: { + "content-type": "text/html; charset=utf-8", + "content-length": "1", + }, + }, + ) as Response & { __vinextStreamedHtmlResponse?: boolean }; + response.__vinextStreamedHtmlResponse = true; + + const merged = mergeHeaders(response, { "x-custom": "from-middleware" }); + + expect(merged.headers.get("content-length")).toBeNull(); + expect(merged.headers.get("content-type")).toBe("text/html; charset=utf-8"); + expect(merged.headers.get("x-custom")).toBe("from-middleware"); + expect(await merged.text()).toBe("hello"); + }); + + it("mergeHeaders preserves valid content-length for untagged custom responses", async () => { + const response = new Response(Buffer.from([1, 2, 3]), { + status: 200, + headers: { + "content-type": "application/octet-stream", + "content-length": "3", + }, + }); + + const merged = mergeHeaders(response, { "x-custom": "from-middleware" }); + + expect(merged.headers.get("content-length")).toBe("3"); + expect(merged.headers.get("content-type")).toBe("application/octet-stream"); + expect(merged.headers.get("x-custom")).toBe("from-middleware"); + const body = Buffer.from(await merged.arrayBuffer()); + expect(body.equals(Buffer.from([1, 2, 3]))).toBe(true); + }); + + it("generated worker entry includes the no-body and streamed content-length merge guards", () => { + const content = generatePagesRouterWorkerEntry(); + expect(content).toContain("NO_BODY_RESPONSE_STATUSES"); + expect(content).toContain("__vinextStreamedHtmlResponse"); + expect(content).toContain('merged.delete("content-length")'); + }); + it("preserves x-middleware-request-* headers for prod request override handling", () => { const content = generatePagesRouterWorkerEntry(); // Worker entry must import applyMiddlewareRequestHeaders from config-matchers diff --git a/tests/features.test.ts b/tests/features.test.ts index d2c97eb4b..5e431731b 100644 --- a/tests/features.test.ts +++ b/tests/features.test.ts @@ -3195,6 +3195,54 @@ describe("Set-Cookie header preservation in prod-server", () => { expect(await merged.text()).toBe("hello"); }); + it("mergeWebResponse strips stale content-length only for tagged streamed Pages HTML", async () => { + const { mergeWebResponse } = await import("../packages/vinext/src/server/prod-server.js"); + + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("hello")); + controller.close(); + }, + }), + { + status: 200, + headers: { + "content-type": "text/html; charset=utf-8", + "content-length": "1", + }, + }, + ) as Response & { __vinextStreamedHtmlResponse?: boolean }; + response.__vinextStreamedHtmlResponse = true; + + const merged = mergeWebResponse({}, response); + + expect(merged.headers.get("content-length")).toBeNull(); + expect(merged.headers.get("content-type")).toBe("text/html; charset=utf-8"); + expect(await merged.text()).toBe("hello"); + }); + + it("mergeWebResponse preserves content-length for untagged custom responses", async () => { + const { mergeWebResponse } = await import("../packages/vinext/src/server/prod-server.js"); + + const response = new Response(Buffer.from([1, 2, 3]), { + status: 200, + headers: { + "content-type": "application/octet-stream", + "content-length": "3", + }, + }); + + const merged = mergeWebResponse({ "x-custom-middleware": "active" }, response); + + expect(merged.headers.get("content-length")).toBe("3"); + expect(merged.headers.get("content-type")).toBe("application/octet-stream"); + expect(merged.headers.get("x-custom-middleware")).toBe("active"); + + const body = Buffer.from(await merged.arrayBuffer()); + expect(body.equals(Buffer.from([1, 2, 3]))).toBe(true); + }); + it("sendCompressed passes array-valued Set-Cookie to writeHead", async () => { const { sendCompressed } = await import("../packages/vinext/src/server/prod-server.js"); @@ -3214,6 +3262,39 @@ describe("Set-Cookie header preservation in prod-server", () => { sendCompressed(req as any, res as any, "small body", "text/html", 200, extraHeaders, false); expect(writtenHeaders["set-cookie"]).toEqual(["a=1; Path=/", "b=2; Path=/"]); }); + + it("sendCompressed replaces any existing content-type and content-length headers", async () => { + const { sendCompressed } = await import("../packages/vinext/src/server/prod-server.js"); + + let writtenHeaders: Record = {}; + const req = { headers: {} }; + const res = { + writeHead: (_status: number, headers: Record) => { + writtenHeaders = headers; + }, + end: () => {}, + }; + + sendCompressed( + req as any, + res as any, + "hello", + "application/json", + 200, + { + "content-type": "text/plain", + "content-length": "999", + "x-custom": "active", + }, + false, + ); + + expect(writtenHeaders["Content-Type"]).toBe("application/json"); + expect(writtenHeaders["Content-Length"]).toBe("5"); + expect(writtenHeaders["content-type"]).toBeUndefined(); + expect(writtenHeaders["content-length"]).toBeUndefined(); + expect(writtenHeaders["x-custom"]).toBe("active"); + }); }); describe("host header poisoning prevention", () => { diff --git a/tests/fixtures/pages-basic/pages/ssr-res-end.tsx b/tests/fixtures/pages-basic/pages/ssr-res-end.tsx index 151656407..a3b5dfd04 100644 --- a/tests/fixtures/pages-basic/pages/ssr-res-end.tsx +++ b/tests/fixtures/pages-basic/pages/ssr-res-end.tsx @@ -9,8 +9,10 @@ export default function SSRResEndPage() { export async function getServerSideProps({ res }: { res: any }) { // Short-circuit: gSSP calls res.end() directly instead of returning props. // This is a valid Next.js pattern for custom responses. + const body = JSON.stringify({ ok: true, source: "gssp-res-end" }); res.setHeader("content-type", "application/json"); + res.setHeader("content-length", String(Buffer.byteLength(body))); res.statusCode = 202; - res.end(JSON.stringify({ ok: true, source: "gssp-res-end" })); + res.end(body); return { props: {} }; } diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index a91fc6655..7d72cda22 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -193,6 +193,7 @@ describe("Pages Router integration", () => { // gSSP calls res.end() with a JSON body and status 202 expect(res.status).toBe(202); expect(res.headers.get("content-type")).toBe("application/json"); + expect(res.headers.get("content-length")).toBe("35"); const body = await res.json(); expect(body).toEqual({ ok: true, source: "gssp-res-end" }); }); @@ -2301,6 +2302,14 @@ describe("Production server middleware (Pages Router)", () => { expect(html).toContain("Hello, vinext!"); }); + it("preserves content-length for getServerSideProps res.end() short-circuit responses in production", async () => { + const res = await fetch(`${prodUrl}/ssr-res-end`); + expect(res.status).toBe(202); + expect(res.headers.get("content-type")).toBe("application/json"); + expect(res.headers.get("content-length")).toBe("35"); + expect(await res.json()).toEqual({ ok: true, source: "gssp-res-end" }); + }); + it("returns 400 for malformed percent-encoded path (not crash)", async () => { const res = await fetch(`${prodUrl}/%E0%A4%A`); expect(res.status).toBe(400); From d2b46553f0ff15cadaab842fe2a59dd2872fa40d Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Mon, 16 Mar 2026 21:52:22 -0500 Subject: [PATCH 4/7] fix: drop stale worker content-length headers --- packages/vinext/src/deploy.ts | 6 ++++- packages/vinext/src/server/worker-utils.ts | 7 +++++- tests/deploy.test.ts | 27 ++++++++++++++++++++-- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts index df72197ce..cdec0a842 100644 --- a/packages/vinext/src/deploy.ts +++ b/packages/vinext/src/deploy.ts @@ -810,11 +810,15 @@ function mergeHeaders( function isVinextStreamedHtmlResponse(response: Response): boolean { return response.__vinextStreamedHtmlResponse === true; } + function isContentLengthHeader(name: string): boolean { + return name.toLowerCase() === "content-length"; + } const status = statusOverride ?? response.status; const merged = new Headers(); // Middleware/config headers go in first (lower precedence) for (const [k, v] of Object.entries(extraHeaders)) { + if (isContentLengthHeader(k)) continue; if (Array.isArray(v)) { for (const item of v) merged.append(k, item); } else { @@ -835,7 +839,7 @@ function mergeHeaders( isVinextStreamedHtmlResponse(response) && merged.has("content-length"); if ( - !Object.keys(extraHeaders).length && + !Object.keys(extraHeaders).some((key) => !isContentLengthHeader(key)) && statusOverride === undefined && !shouldDropBody && !shouldStripStreamLength diff --git a/packages/vinext/src/server/worker-utils.ts b/packages/vinext/src/server/worker-utils.ts index f382b149d..2ea85e19f 100644 --- a/packages/vinext/src/server/worker-utils.ts +++ b/packages/vinext/src/server/worker-utils.ts @@ -22,6 +22,10 @@ function isVinextStreamedHtmlResponse(response: Response): boolean { return (response as ResponseWithVinextStreamingMetadata).__vinextStreamedHtmlResponse === true; } +function isContentLengthHeader(name: string): boolean { + return name.toLowerCase() === "content-length"; +} + export function mergeHeaders( response: Response, extraHeaders: Record, @@ -30,6 +34,7 @@ export function mergeHeaders( const status = statusOverride ?? response.status; const merged = new Headers(); for (const [k, v] of Object.entries(extraHeaders)) { + if (isContentLengthHeader(k)) continue; if (Array.isArray(v)) { for (const item of v) merged.append(k, item); } else { @@ -48,7 +53,7 @@ export function mergeHeaders( isVinextStreamedHtmlResponse(response) && merged.has("content-length"); if ( - !Object.keys(extraHeaders).length && + !Object.keys(extraHeaders).some((key) => !isContentLengthHeader(key)) && statusOverride === undefined && !shouldDropBody && !shouldStripStreamLength diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 57fa590d9..36dd937f0 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -684,7 +684,26 @@ describe("generatePagesRouterWorkerEntry", () => { expect(await merged.text()).toBe("hello"); }); - it("mergeHeaders preserves valid content-length for untagged custom responses", async () => { + it("mergeHeaders strips middleware-provided content-length for untagged responses", async () => { + const response = new Response("body", { + status: 200, + headers: { + "content-type": "text/plain", + }, + }); + + const merged = mergeHeaders(response, { + "content-length": "1", + "x-custom": "from-middleware", + }); + + expect(merged.headers.get("content-length")).toBeNull(); + expect(merged.headers.get("content-type")).toBe("text/plain"); + expect(merged.headers.get("x-custom")).toBe("from-middleware"); + expect(await merged.text()).toBe("body"); + }); + + it("mergeHeaders preserves response content-length over middleware content-length for untagged custom responses", async () => { const response = new Response(Buffer.from([1, 2, 3]), { status: 200, headers: { @@ -693,7 +712,10 @@ describe("generatePagesRouterWorkerEntry", () => { }, }); - const merged = mergeHeaders(response, { "x-custom": "from-middleware" }); + const merged = mergeHeaders(response, { + "content-length": "1", + "x-custom": "from-middleware", + }); expect(merged.headers.get("content-length")).toBe("3"); expect(merged.headers.get("content-type")).toBe("application/octet-stream"); @@ -707,6 +729,7 @@ describe("generatePagesRouterWorkerEntry", () => { expect(content).toContain("NO_BODY_RESPONSE_STATUSES"); expect(content).toContain("__vinextStreamedHtmlResponse"); expect(content).toContain('merged.delete("content-length")'); + expect(content).toContain("if (isContentLengthHeader(k)) continue;"); }); it("preserves x-middleware-request-* headers for prod request override handling", () => { From 48771731c8b50ebf8bd9b5ef9e0c6019a8556aca Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Mon, 16 Mar 2026 23:42:33 -0500 Subject: [PATCH 5/7] fix: preserve no-body rewrite parity --- packages/vinext/src/deploy.ts | 8 +++++ packages/vinext/src/server/prod-server.ts | 33 ++++++++++++----- packages/vinext/src/server/worker-utils.ts | 9 +++++ tests/deploy.test.ts | 37 +++++++++++++++++++ tests/features.test.ts | 38 ++++++++++++++++++++ tests/pages-router.test.ts | 42 ++++++++++++++++++++-- 6 files changed, 157 insertions(+), 10 deletions(-) diff --git a/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts index cdec0a842..2cc24bd5a 100644 --- a/packages/vinext/src/deploy.ts +++ b/packages/vinext/src/deploy.ts @@ -813,6 +813,13 @@ function mergeHeaders( function isContentLengthHeader(name: string): boolean { return name.toLowerCase() === "content-length"; } + function cancelResponseBody(response: Response): void { + const body = response.body; + if (!body || body.locked) return; + void body.cancel().catch(() => { + /* ignore cancellation failures on discarded bodies */ + }); + } const status = statusOverride ?? response.status; const merged = new Headers(); @@ -848,6 +855,7 @@ function mergeHeaders( } if (shouldDropBody) { + cancelResponseBody(response); merged.delete("content-encoding"); merged.delete("content-length"); merged.delete("content-type"); diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 6401497b1..6a6493fde 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -189,6 +189,14 @@ function isNoBodyResponseStatus(status: number): boolean { return NO_BODY_RESPONSE_STATUSES.has(status); } +function cancelResponseBody(response: Response): void { + const body = response.body; + if (!body || body.locked) return; + void body.cancel().catch(() => { + /* ignore cancellation failures on discarded bodies */ + }); +} + type ResponseWithVinextStreamingMetadata = Response & { __vinextStreamedHtmlResponse?: boolean; }; @@ -222,6 +230,7 @@ function mergeWebResponse( } if (shouldDropBody) { + cancelResponseBody(response); stripHeaders(mergedHeaders, [ "content-encoding", "content-length", @@ -1167,23 +1176,31 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { response = new Response("404 - API route not found", { status: 404 }); } - // Merge middleware + config headers into the response - const responseBody = Buffer.from(await response.arrayBuffer()); + const mergedResponse = mergeWebResponse( + middlewareHeaders, + response, + middlewareRewriteStatus, + ); + + if (!mergedResponse.body) { + await sendWebResponse(mergedResponse, req, res, compress); + return; + } + + const responseBody = Buffer.from(await mergedResponse.arrayBuffer()); // API routes may return arbitrary data (JSON, binary, etc.), so // default to application/octet-stream rather than text/html when // the handler doesn't set an explicit Content-Type. - const ct = response.headers.get("content-type") ?? "application/octet-stream"; - const responseHeaders = mergeResponseHeaders(middlewareHeaders, response); - const finalStatus = middlewareRewriteStatus ?? response.status; - const finalStatusText = - finalStatus === response.status ? response.statusText || undefined : undefined; + const ct = mergedResponse.headers.get("content-type") ?? "application/octet-stream"; + const responseHeaders = mergeResponseHeaders({}, mergedResponse); + const finalStatusText = mergedResponse.statusText || undefined; sendCompressed( req, res, responseBody, ct, - finalStatus, + mergedResponse.status, responseHeaders, compress, finalStatusText, diff --git a/packages/vinext/src/server/worker-utils.ts b/packages/vinext/src/server/worker-utils.ts index 2ea85e19f..baef16137 100644 --- a/packages/vinext/src/server/worker-utils.ts +++ b/packages/vinext/src/server/worker-utils.ts @@ -26,6 +26,14 @@ function isContentLengthHeader(name: string): boolean { return name.toLowerCase() === "content-length"; } +function cancelResponseBody(response: Response): void { + const body = response.body; + if (!body || body.locked) return; + void body.cancel().catch(() => { + /* ignore cancellation failures on discarded bodies */ + }); +} + export function mergeHeaders( response: Response, extraHeaders: Record, @@ -62,6 +70,7 @@ export function mergeHeaders( } if (shouldDropBody) { + cancelResponseBody(response); merged.delete("content-encoding"); merged.delete("content-length"); merged.delete("content-type"); diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 36dd937f0..8845c3ac6 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -658,6 +658,42 @@ describe("generatePagesRouterWorkerEntry", () => { } }); + it("mergeHeaders cancels discarded body streams for no-body statuses", async () => { + let started = false; + let canceled = false; + const response = new Response( + new ReadableStream({ + async start(controller) { + started = true; + await new Promise((resolve) => setTimeout(resolve, 25)); + if (canceled) return; + controller.enqueue(new TextEncoder().encode("hello")); + controller.close(); + }, + cancel() { + canceled = true; + }, + }), + { + headers: { + "content-type": "text/plain", + "content-length": "5", + }, + }, + ); + + const merged = mergeHeaders(response, { "x-custom": "from-middleware" }, 204); + + expect(merged.status).toBe(204); + expect(merged.headers.get("content-type")).toBeNull(); + expect(merged.headers.get("content-length")).toBeNull(); + expect(await merged.text()).toBe(""); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(started).toBe(true); + expect(canceled).toBe(true); + }); + it("mergeHeaders strips stale content-length only for tagged streamed Pages HTML", async () => { const response = new Response( new ReadableStream({ @@ -730,6 +766,7 @@ describe("generatePagesRouterWorkerEntry", () => { expect(content).toContain("__vinextStreamedHtmlResponse"); expect(content).toContain('merged.delete("content-length")'); expect(content).toContain("if (isContentLengthHeader(k)) continue;"); + expect(content).toContain("cancelResponseBody(response)"); }); it("preserves x-middleware-request-* headers for prod request override handling", () => { diff --git a/tests/features.test.ts b/tests/features.test.ts index 5e431731b..2025e9b7b 100644 --- a/tests/features.test.ts +++ b/tests/features.test.ts @@ -3195,6 +3195,44 @@ describe("Set-Cookie header preservation in prod-server", () => { expect(await merged.text()).toBe("hello"); }); + it("mergeWebResponse cancels discarded body streams for no-body statuses", async () => { + const { mergeWebResponse } = await import("../packages/vinext/src/server/prod-server.js"); + + let started = false; + let canceled = false; + const response = new Response( + new ReadableStream({ + async start(controller) { + started = true; + await new Promise((resolve) => setTimeout(resolve, 25)); + if (canceled) return; + controller.enqueue(new TextEncoder().encode("hello")); + controller.close(); + }, + cancel() { + canceled = true; + }, + }), + { + headers: { + "content-type": "text/plain", + "content-length": "5", + }, + }, + ); + + const merged = mergeWebResponse({}, response, 204); + + expect(merged.status).toBe(204); + expect(merged.headers.get("content-type")).toBeNull(); + expect(merged.headers.get("content-length")).toBeNull(); + expect(await merged.text()).toBe(""); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(started).toBe(true); + expect(canceled).toBe(true); + }); + it("mergeWebResponse strips stale content-length only for tagged streamed Pages HTML", async () => { const { mergeWebResponse } = await import("../packages/vinext/src/server/prod-server.js"); diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 7d72cda22..5d271ca1d 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -2912,8 +2912,20 @@ describe("Pages Router production no-body rewrite statuses", () => { export function middleware(request) { const url = new URL(request.url); const match = url.pathname.match(/^\\/status-(204|205|304)$/); - if (!match) return NextResponse.next(); - return NextResponse.rewrite(new URL("/target", request.url), { status: Number(match[1]) }); + if (match) { + const response = NextResponse.rewrite(new URL("/target", request.url), { + status: Number(match[1]), + }); + response.headers.set("x-custom-middleware", "active"); + return response; + } + const apiMatch = url.pathname.match(/^\\/api-status-(204|205|304)$/); + if (!apiMatch) return NextResponse.next(); + const response = NextResponse.rewrite(new URL("/api/target", request.url), { + status: Number(apiMatch[1]), + }); + response.headers.set("x-custom-middleware", "active"); + return response; } `, ); @@ -2929,6 +2941,14 @@ export function middleware(request) { `export default function TargetPage() { return
TARGET PAGE
; } +`, + ); + await fsp.mkdir(path.join(tmpRoot, "pages", "api"), { recursive: true }); + await fsp.writeFile( + path.join(tmpRoot, "pages", "api", "target.ts"), + `export default function handler(req, res) { + res.status(200).json({ ok: true }); +} `, ); @@ -2959,6 +2979,24 @@ export function middleware(request) { const res = await fetch(`${prodUrl}/status-${statusCode}`); expect(res.status).toBe(statusCode); + expect(res.headers.get("x-custom-middleware")).toBe("active"); + expect(await res.text()).toBe(""); + }); + } + + for (const statusCode of [204, 205, 304]) { + it(`drops body headers for middleware rewrite status ${statusCode} on Pages API responses in production`, async () => { + // Parity targets: + // - Next.js skips forwarding middleware content-length in route resolution. + // https://raw.githubusercontent.com/vercel/next.js/canary/packages/next/src/server/lib/router-utils/resolve-routes.ts + // - Next.js sends bodyless responses by ending the Node response without piping the body. + // https://raw.githubusercontent.com/vercel/next.js/canary/packages/next/src/server/send-response.ts + const res = await fetch(`${prodUrl}/api-status-${statusCode}`); + + expect(res.status).toBe(statusCode); + expect(res.headers.get("x-custom-middleware")).toBe("active"); + expect(res.headers.get("content-type")).toBeNull(); + expect(res.headers.get("content-length")).toBeNull(); expect(await res.text()).toBe(""); }); } From 7def74c80c9a3cad7a521a1aaebe0da76e3ba66c Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Tue, 17 Mar 2026 10:01:10 -0500 Subject: [PATCH 6/7] fix: cancel streamed HEAD responses --- packages/vinext/src/deploy.ts | 3 +- packages/vinext/src/server/prod-server.ts | 4 ++ packages/vinext/src/server/worker-utils.ts | 1 + tests/features.test.ts | 52 ++++++++++++++++++++++ tests/pages-router.test.ts | 11 +++++ 5 files changed, 70 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts index 144542fb8..1d2efaca9 100644 --- a/packages/vinext/src/deploy.ts +++ b/packages/vinext/src/deploy.ts @@ -813,7 +813,8 @@ export default { * Response headers take precedence over middleware headers for all headers * except Set-Cookie, which is additive (both middleware and response cookies * are preserved). Matches the behavior in prod-server.ts. Uses getSetCookie() - * to preserve multiple Set-Cookie values. + * to preserve multiple Set-Cookie values. Keep this in sync with + * prod-server.ts and server/worker-utils.ts. */ function mergeHeaders( response: Response, diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index c0abdc73f..d20acf380 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -208,6 +208,8 @@ function isVinextStreamedHtmlResponse(response: Response): boolean { /** * Merge middleware/config headers and an optional status override into a new * Web Response while preserving the original body stream when allowed. + * Keep this in sync with server/worker-utils.ts and the generated copy in + * deploy.ts. */ function mergeWebResponse( middlewareHeaders: Record, @@ -596,6 +598,7 @@ async function sendWebResponse( // HEAD requests: send headers only, skip the body if (req.method === "HEAD") { + cancelResponseBody(webResponse); res.end(); return; } @@ -1329,6 +1332,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { // Export helpers for testing export { sendCompressed, + sendWebResponse, negotiateEncoding, COMPRESSIBLE_TYPES, COMPRESS_THRESHOLD, diff --git a/packages/vinext/src/server/worker-utils.ts b/packages/vinext/src/server/worker-utils.ts index baef16137..05aa32e10 100644 --- a/packages/vinext/src/server/worker-utils.ts +++ b/packages/vinext/src/server/worker-utils.ts @@ -11,6 +11,7 @@ * Response headers take precedence over middleware headers for all headers * except Set-Cookie, which is additive (both middleware and response cookies * are preserved). Uses getSetCookie() to preserve multiple Set-Cookie values. + * Keep this in sync with prod-server.ts and the generated copy in deploy.ts. */ const NO_BODY_RESPONSE_STATUSES = new Set([204, 205, 304]); diff --git a/tests/features.test.ts b/tests/features.test.ts index 1458a89ff..872fe4d38 100644 --- a/tests/features.test.ts +++ b/tests/features.test.ts @@ -3290,6 +3290,58 @@ describe("Set-Cookie header preservation in prod-server", () => { expect(body.equals(Buffer.from([1, 2, 3]))).toBe(true); }); + it("sendWebResponse cancels streamed bodies for HEAD requests", async () => { + const { sendWebResponse } = await import("../packages/vinext/src/server/prod-server.js"); + + let canceled = false; + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("hello")); + }, + cancel() { + canceled = true; + }, + }), + { + status: 200, + headers: { + "content-type": "text/html; charset=utf-8", + }, + }, + ); + + let status = 0; + let writtenHeaders: Record = {}; + let ended = false; + const req = { + method: "HEAD", + headers: {}, + }; + const res = { + writeHead: ( + writtenStatus: number, + headersOrStatusText: string | Record, + maybeHeaders?: Record, + ) => { + status = writtenStatus; + writtenHeaders = + typeof headersOrStatusText === "string" ? (maybeHeaders ?? {}) : headersOrStatusText; + }, + end: () => { + ended = true; + }, + }; + + await sendWebResponse(response, req as any, res as any, false); + await new Promise((resolve) => setTimeout(resolve, 25)); + + expect(status).toBe(200); + expect(writtenHeaders["content-type"]).toBe("text/html; charset=utf-8"); + expect(ended).toBe(true); + expect(canceled).toBe(true); + }); + it("sendCompressed passes array-valued Set-Cookie to writeHead", async () => { const { sendCompressed } = await import("../packages/vinext/src/server/prod-server.js"); diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 693a433f2..2e0540323 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -2519,6 +2519,17 @@ describe("Production Pages Router SSR streaming", () => { expect(html).toContain("Delayed stream content loaded"); }); + it("serves streamed Pages SSR HEAD requests as headers-only responses in production", async () => { + const res = await fetch(`${prodUrl}/streaming-ssr`, { + method: "HEAD", + }); + + expect(res.status).toBe(200); + expect(res.headers.get("x-custom-middleware")).toBe("active"); + expect(res.headers.get("content-length")).toBeNull(); + expect(await res.text()).toBe(""); + }); + it("strips stale content-length from streamed Pages SSR responses when gSSP sets one", async () => { // Parity target: Next.js only sets Content-Length for unchunked render // payloads; streamed HTML is sent without one. From 552e07cd15a856a72a55914ca8b6d172d2a2b749 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Tue, 17 Mar 2026 12:37:49 -0500 Subject: [PATCH 7/7] test: support merged prod server shape --- tests/pages-router.test.ts | 106 ++++++++++++++++++++++--------------- 1 file changed, 64 insertions(+), 42 deletions(-) diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 2e0540323..fa421beb3 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -70,6 +70,12 @@ async function buildPagesFixtureToOutDir(rootDir: string, outDir: string): Promi }); } +function unwrapStartedProdServer( + result: import("node:http").Server | { server: import("node:http").Server }, +): import("node:http").Server { + return "server" in result ? result.server : result; +} + interface CapturedStreamResponse { body: Buffer; headers: IncomingHttpHeaders; @@ -1645,11 +1651,13 @@ export default function CounterPage() { ); const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); - const prodServer = await startProdServer({ - port: 0, - host: "127.0.0.1", - outDir: fixtureOutDir, - }); + const prodServer = unwrapStartedProdServer( + await startProdServer({ + port: 0, + host: "127.0.0.1", + outDir: fixtureOutDir, + }), + ); try { const addr = prodServer.address() as { port: number }; @@ -1766,11 +1774,13 @@ export default function CounterPage() { expect(cssContent).toMatch(/data:image\/svg\+xml|\.svg/); const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); - const prodServer = await startProdServer({ - port: 0, - host: "127.0.0.1", - outDir: fixtureOutDir, - }); + const prodServer = unwrapStartedProdServer( + await startProdServer({ + port: 0, + host: "127.0.0.1", + outDir: fixtureOutDir, + }), + ); try { const addr = prodServer.address() as { port: number }; @@ -2094,11 +2104,13 @@ describe("Production server middleware (Pages Router)", () => { } const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); - prodServer = await startProdServer({ - port: 0, - host: "127.0.0.1", - outDir, - }); + prodServer = unwrapStartedProdServer( + await startProdServer({ + port: 0, + host: "127.0.0.1", + outDir, + }), + ); const addr = prodServer.address() as { port: number }; prodUrl = `http://127.0.0.1:${addr.port}`; }); @@ -2146,11 +2158,13 @@ describe("Production server middleware (Pages Router)", () => { }); const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); - prodServer = await startProdServer({ - port: 0, - host: "127.0.0.1", - outDir: path.join(tmpDir, "dist"), - }); + prodServer = unwrapStartedProdServer( + await startProdServer({ + port: 0, + host: "127.0.0.1", + outDir: path.join(tmpDir, "dist"), + }), + ); const addr = prodServer.address() as { port: number }; const tempProdUrl = `http://127.0.0.1:${addr.port}`; @@ -2462,12 +2476,14 @@ describe("Production Pages Router SSR streaming", () => { await buildPagesFixtureToOutDir(FIXTURE_DIR, outDir); const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); - prodServer = await startProdServer({ - port: 0, - host: "127.0.0.1", - outDir, - noCompression: true, - }); + prodServer = unwrapStartedProdServer( + await startProdServer({ + port: 0, + host: "127.0.0.1", + outDir, + noCompression: true, + }), + ); const addr = prodServer.address() as { port: number }; prodUrl = `http://127.0.0.1:${addr.port}`; }, 60000); @@ -2599,11 +2615,13 @@ describe("Production server next.config.js features (Pages Router)", () => { } const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); - prodServer = await startProdServer({ - port: 0, - host: "127.0.0.1", - outDir, - }); + prodServer = unwrapStartedProdServer( + await startProdServer({ + port: 0, + host: "127.0.0.1", + outDir, + }), + ); const addr = prodServer.address() as { port: number }; prodUrl = `http://127.0.0.1:${addr.port}`; }); @@ -2973,11 +2991,13 @@ export function middleware(request) { }); const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); - const prodServer = await startProdServer({ - port: 0, - host: "127.0.0.1", - outDir, - }); + const prodServer = unwrapStartedProdServer( + await startProdServer({ + port: 0, + host: "127.0.0.1", + outDir, + }), + ); try { const addr = prodServer.address() as { port: number }; @@ -3067,12 +3087,14 @@ export function middleware(request) { await buildPagesFixtureToOutDir(tmpRoot, outDir); const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); - prodServer = await startProdServer({ - port: 0, - host: "127.0.0.1", - outDir, - noCompression: true, - }); + prodServer = unwrapStartedProdServer( + await startProdServer({ + port: 0, + host: "127.0.0.1", + outDir, + noCompression: true, + }), + ); const addr = prodServer.address() as { port: number }; prodUrl = `http://127.0.0.1:${addr.port}`; }, 60000);