diff --git a/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts index cdf4462f7..1d2efaca9 100644 --- a/packages/vinext/src/deploy.ts +++ b/packages/vinext/src/deploy.ts @@ -813,17 +813,34 @@ 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, 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; + } + 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(); // 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 { @@ -838,9 +855,40 @@ 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).some((key) => !isContentLengthHeader(key)) && + statusOverride === undefined && + !shouldDropBody && + !shouldStripStreamLength + ) { + return response; + } + + if (shouldDropBody) { + cancelResponseBody(response); + 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 44be81a98..ec9f602e8 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -1108,7 +1108,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 6620c5bf2..ad0700f71 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -157,6 +157,107 @@ 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; +} + +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); +} + +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; +}; + +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. + * Keep this in sync with server/worker-utils.ts and the generated copy in + * deploy.ts. + */ +function mergeWebResponse( + middlewareHeaders: Record, + response: Response, + statusOverride?: number, +): Response { + const status = statusOverride ?? response.status; + const mergedHeaders = mergeResponseHeaders(middlewareHeaders, response); + const shouldDropBody = isNoBodyResponseStatus(status); + const shouldStripStreamLength = + isVinextStreamedHtmlResponse(response) && hasHeader(mergedHeaders, "content-length"); + + if ( + !Object.keys(middlewareHeaders).length && + statusOverride === undefined && + !shouldDropBody && + !shouldStripStreamLength + ) { + return response; + } + + if (shouldDropBody) { + cancelResponseBody(response); + 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(mergedHeaders), + }); +} + /** * Send a compressed response if the content type is compressible and the * client supports compression. Otherwise send uncompressed. @@ -174,6 +275,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) { @@ -200,7 +308,7 @@ function sendCompressed( varyValue = "Accept-Encoding"; } writeHead({ - ...extraHeaders, + ...headersWithoutBodyHeaders, "Content-Type": contentType, "Content-Encoding": encoding, Vary: varyValue, @@ -210,11 +318,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), }); @@ -494,6 +599,7 @@ async function sendWebResponse( // HEAD requests: send headers only, skip the body if (req.method === "HEAD") { + cancelResponseBody(webResponse); res.end(); return; } @@ -1180,23 +1286,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, @@ -1247,20 +1361,25 @@ 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; + 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, - finalStatus, + mergedResponse.status, responseHeaders, compress, finalStatusText, @@ -1291,6 +1410,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { // Export helpers for testing export { sendCompressed, + sendWebResponse, negotiateEncoding, COMPRESSIBLE_TYPES, COMPRESS_THRESHOLD, @@ -1299,4 +1419,5 @@ export { trustProxy, nodeToWebRequest, mergeResponseHeaders, + mergeWebResponse, }; diff --git a/packages/vinext/src/server/worker-utils.ts b/packages/vinext/src/server/worker-utils.ts index a0d69e14a..05aa32e10 100644 --- a/packages/vinext/src/server/worker-utils.ts +++ b/packages/vinext/src/server/worker-utils.ts @@ -11,15 +11,39 @@ * 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]); + +type ResponseWithVinextStreamingMetadata = Response & { + __vinextStreamedHtmlResponse?: boolean; +}; + +function isVinextStreamedHtmlResponse(response: Response): boolean { + return (response as ResponseWithVinextStreamingMetadata).__vinextStreamedHtmlResponse === true; +} + +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, 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 (isContentLengthHeader(k)) continue; if (Array.isArray(v)) { for (const item of v) merged.append(k, item); } else { @@ -32,9 +56,40 @@ 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).some((key) => !isContentLengthHeader(key)) && + statusOverride === undefined && + !shouldDropBody && + !shouldStripStreamLength + ) { + return response; + } + + if (shouldDropBody) { + cancelResponseBody(response); + 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 813388650..db759aafe 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -19188,6 +19188,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"), @@ -19412,13 +19414,15 @@ import * as page_24 from "/tests/fixtures/pages-basic/pages/shallow-test.t import * as page_25 from "/tests/fixtures/pages-basic/pages/ssr.tsx"; import * as page_26 from "/tests/fixtures/pages-basic/pages/ssr-headers.tsx"; import * as page_27 from "/tests/fixtures/pages-basic/pages/ssr-res-end.tsx"; -import * as page_28 from "/tests/fixtures/pages-basic/pages/suspense-test.tsx"; -import * as page_29 from "/tests/fixtures/pages-basic/pages/articles/[id].tsx"; -import * as page_30 from "/tests/fixtures/pages-basic/pages/blog/[slug].tsx"; -import * as page_31 from "/tests/fixtures/pages-basic/pages/posts/[id].tsx"; -import * as page_32 from "/tests/fixtures/pages-basic/pages/products/[pid].tsx"; -import * as page_33 from "/tests/fixtures/pages-basic/pages/docs/[...slug].tsx"; -import * as page_34 from "/tests/fixtures/pages-basic/pages/sign-up/[[...sign-up]]/index.tsx"; +import * as page_28 from "/tests/fixtures/pages-basic/pages/streaming-gssp-content-length.tsx"; +import * as page_29 from "/tests/fixtures/pages-basic/pages/streaming-ssr.tsx"; +import * as page_30 from "/tests/fixtures/pages-basic/pages/suspense-test.tsx"; +import * as page_31 from "/tests/fixtures/pages-basic/pages/articles/[id].tsx"; +import * as page_32 from "/tests/fixtures/pages-basic/pages/blog/[slug].tsx"; +import * as page_33 from "/tests/fixtures/pages-basic/pages/posts/[id].tsx"; +import * as page_34 from "/tests/fixtures/pages-basic/pages/products/[pid].tsx"; +import * as page_35 from "/tests/fixtures/pages-basic/pages/docs/[...slug].tsx"; +import * as page_36 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"; @@ -19462,13 +19466,15 @@ export const pageRoutes = [ { pattern: "/ssr", patternParts: ["ssr"], isDynamic: false, params: [], module: page_25, filePath: "/tests/fixtures/pages-basic/pages/ssr.tsx" }, { pattern: "/ssr-headers", patternParts: ["ssr-headers"], isDynamic: false, params: [], module: page_26, filePath: "/tests/fixtures/pages-basic/pages/ssr-headers.tsx" }, { pattern: "/ssr-res-end", patternParts: ["ssr-res-end"], isDynamic: false, params: [], module: page_27, filePath: "/tests/fixtures/pages-basic/pages/ssr-res-end.tsx" }, - { pattern: "/suspense-test", patternParts: ["suspense-test"], isDynamic: false, params: [], module: page_28, filePath: "/tests/fixtures/pages-basic/pages/suspense-test.tsx" }, - { pattern: "/articles/:id", patternParts: ["articles",":id"], isDynamic: true, params: ["id"], module: page_29, filePath: "/tests/fixtures/pages-basic/pages/articles/[id].tsx" }, - { pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, params: ["slug"], module: page_30, filePath: "/tests/fixtures/pages-basic/pages/blog/[slug].tsx" }, - { pattern: "/posts/:id", patternParts: ["posts",":id"], isDynamic: true, params: ["id"], module: page_31, filePath: "/tests/fixtures/pages-basic/pages/posts/[id].tsx" }, - { pattern: "/products/:pid", patternParts: ["products",":pid"], isDynamic: true, params: ["pid"], module: page_32, filePath: "/tests/fixtures/pages-basic/pages/products/[pid].tsx" }, - { pattern: "/docs/:slug+", patternParts: ["docs",":slug+"], isDynamic: true, params: ["slug"], module: page_33, 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_34, 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_28, filePath: "/tests/fixtures/pages-basic/pages/streaming-gssp-content-length.tsx" }, + { pattern: "/streaming-ssr", patternParts: ["streaming-ssr"], isDynamic: false, params: [], module: page_29, filePath: "/tests/fixtures/pages-basic/pages/streaming-ssr.tsx" }, + { pattern: "/suspense-test", patternParts: ["suspense-test"], isDynamic: false, params: [], module: page_30, filePath: "/tests/fixtures/pages-basic/pages/suspense-test.tsx" }, + { pattern: "/articles/:id", patternParts: ["articles",":id"], isDynamic: true, params: ["id"], module: page_31, filePath: "/tests/fixtures/pages-basic/pages/articles/[id].tsx" }, + { pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, params: ["slug"], module: page_32, filePath: "/tests/fixtures/pages-basic/pages/blog/[slug].tsx" }, + { pattern: "/posts/:id", patternParts: ["posts",":id"], isDynamic: true, params: ["id"], module: page_33, filePath: "/tests/fixtures/pages-basic/pages/posts/[id].tsx" }, + { pattern: "/products/:pid", patternParts: ["products",":pid"], isDynamic: true, params: ["pid"], module: page_34, filePath: "/tests/fixtures/pages-basic/pages/products/[pid].tsx" }, + { pattern: "/docs/:slug+", patternParts: ["docs",":slug+"], isDynamic: true, params: ["slug"], module: page_35, 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_36, filePath: "/tests/fixtures/pages-basic/pages/sign-up/[[...sign-up]]/index.tsx" } ]; const _pageRouteTrie = _buildRouteTrie(pageRoutes); @@ -20198,7 +20204,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 045314f00..cd19afd21 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -656,6 +656,134 @@ 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 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({ + 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 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: { + "content-type": "application/octet-stream", + "content-length": "3", + }, + }); + + 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"); + 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")'); + expect(content).toContain("if (isContentLengthHeader(k)) continue;"); + expect(content).toContain("cancelResponseBody(response)"); + }); + 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 e00e14c2d..872fe4d38 100644 --- a/tests/features.test.ts +++ b/tests/features.test.ts @@ -3179,6 +3179,169 @@ 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("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"); + + 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("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"); @@ -3198,6 +3361,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/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/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/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/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 cf15464b2..ae34a211a 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 "vite-plus/test"; import { createServer, build, type ViteDevServer } from "vite-plus"; +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"; @@ -42,6 +43,97 @@ export default function middleware() { ); } +async function buildPagesFixtureToOutDir(rootDir: string, outDir: string): Promise { + await build({ + root: rootDir, + configFile: false, + plugins: [vinext({ disableAppRouter: true })], + 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({ disableAppRouter: true })], + logLevel: "silent", + build: { + outDir: path.join(outDir, "client"), + manifest: true, + ssrManifest: true, + rollupOptions: { input: "virtual:vinext-client-entry" }, + }, + }); +} + +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; + 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(); + }); +} + function findBuildManifestEntries( buildManifest: Record, moduleId: string, @@ -122,6 +214,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" }); }); @@ -1558,11 +1651,13 @@ export default function CounterPage() { ); const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); - const { server: 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 }; @@ -1679,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 { server: 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 }; @@ -2007,12 +2104,14 @@ describe("Production server middleware (Pages Router)", () => { } const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); - ({ server: prodServer } = await startProdServer({ - port: 0, - host: "127.0.0.1", - outDir, - })); - const addr = prodServer!.address() as { port: number }; + 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}`; }); @@ -2059,12 +2158,14 @@ describe("Production server middleware (Pages Router)", () => { }); const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); - ({ server: prodServer } = await startProdServer({ - port: 0, - host: "127.0.0.1", - outDir: path.join(tmpDir, "dist"), - })); - const addr = prodServer!.address() as { port: number }; + 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}`; const encodedRes = await fetch(`${tempProdUrl}/a%2Fb`); @@ -2324,6 +2425,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); @@ -2352,6 +2461,123 @@ 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 buildPagesFixtureToOutDir(FIXTURE_DIR, outDir); + + const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); + 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); + + 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(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"); + }); + + 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. + // 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)", () => { const outDir = path.resolve(FIXTURE_DIR, "dist"); let prodServer: import("node:http").Server | undefined; @@ -2389,12 +2615,14 @@ describe("Production server next.config.js features (Pages Router)", () => { } const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); - ({ server: prodServer } = await startProdServer({ - port: 0, - host: "127.0.0.1", - outDir, - })); - const addr = prodServer!.address() as { port: number }; + 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}`; }); @@ -2763,11 +2991,13 @@ export function middleware(request) { }); const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); - const { server: 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 }; @@ -2789,6 +3019,123 @@ 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) { + 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; +} +`, + ); + 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 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 }); +} +`, + ); + + await buildPagesFixtureToOutDir(tmpRoot, outDir); + + const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); + 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); + + 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(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(""); + }); + } +}); + describe("router __NEXT_DATA__ correctness (Pages Router)", () => { let routerServer: ViteDevServer; let routerBaseUrl: string;