Skip to content

fix: apply headers, cookies, and status set by getServerSideProps to the response#281

Merged
southpolesteve merged 2 commits intomainfrom
fix/render-page-response-headers
Mar 6, 2026
Merged

fix: apply headers, cookies, and status set by getServerSideProps to the response#281
southpolesteve merged 2 commits intomainfrom
fix/render-page-response-headers

Conversation

@southpolesteve
Copy link
Copy Markdown
Collaborator

Summary

  • getServerSideProps receives a res object that supports setHeader(), statusCode, and end(). Previously, headers/cookies/status set on this object were silently discarded in the final HTML response.
  • Fixed the production path (renderPage in index.ts): the mock res object's accumulated state is now merged into the final Response, including custom headers, Set-Cookie values, and non-200 status codes
  • Fixed the dev server path (dev-server.ts): res.statusCode set by gSSP is now preserved through streamPageToResponse, and res.end() short-circuit is detected to avoid writing to an already-ended stream
  • Added getHeaders() and headersSent to the mock res object in createReqRes so callers can read accumulated response metadata
  • Added fixture pages and integration tests covering: custom headers, cookies, status codes, and res.end() short-circuit behavior

Test plan

Two new fixture pages:

  • pages/ssr-headers.tsx — gSSP sets x-custom-header, a Set-Cookie, and statusCode = 201
  • pages/ssr-res-end.tsx — gSSP calls res.end() with a JSON body and status 202

Two new integration tests in pages-router.test.ts verify headers, cookies, status, and short-circuit behavior.

…the response

getServerSideProps receives a res object that supports setHeader(),
statusCode, and end(). Previously, any headers, cookies, or status
codes set on this object were silently discarded in the final HTML
response.

This fixes two code paths:

1. Production (index.ts renderPage): Capture the res object from
   createReqRes and merge its accumulated headers, cookies, and
   status into the final Response. Also handle the short-circuit
   case where gSSP calls res.end() directly.

2. Dev server (dev-server.ts): Preserve res.statusCode set by gSSP
   so it flows through to streamPageToResponse. Also detect when
   gSSP calls res.end() to avoid writing to an already-ended stream.

Added getHeaders() and headersSent to the mock res object in
createReqRes so callers can read accumulated response metadata.

Test: Added fixture pages (ssr-headers.tsx, ssr-res-end.tsx) and
integration tests verifying custom headers, cookies, status codes,
and res.end() short-circuit behavior.
@southpolesteve
Copy link
Copy Markdown
Collaborator Author

/bigbonk review this

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 6, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@281

commit: 04f01b1

@github-actions
Copy link
Copy Markdown

github-actions bot commented Mar 6, 2026

Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Good fix for a real bug — gSSP's res.setHeader(), res.statusCode, and res.end() should absolutely flow through to the actual HTTP response, and this PR addresses that correctly for both dev and prod paths.

What looks good

  • Production path (renderPage in index.ts): The approach of saving gsspRes, calling getHeaders() after gSSP returns, and merging into the final Response via Headers API is clean. Using Headers.append() for Set-Cookie is correct.
  • res.end() short-circuit: Both paths detect it properly — res.headersSent (mock getter) in prod, res.writableEnded (Node.js native) in dev. The responsePromise pattern in prod is a good design for bridging the mock res to a Web Response.
  • getHeaders() and headersSent additions to createReqRes: These are useful additions that make the mock res more compatible with the Node.js API surface.
  • Test coverage: Good fixture pages that exercise the key scenarios (custom headers, cookies, status codes, and res.end() short-circuit). Tests assert the right things.
  • Content-Type guard: Re-setting Content-Type: text/html after merging gSSP headers prevents gSSP from accidentally breaking the page render response type.

Issue: Dev server doesn't pass gSSP custom headers

The dev server (dev-server.ts) preserves res.statusCode (lines 463-467), which is great. But it does not pass custom headers set via res.setHeader() (like x-custom-header or set-cookie) through to the streamPageToResponse call.

In the dev path, res is the real Node.js ServerResponse — when gSSP calls res.setHeader("x-custom-header", "hello-from-gssp"), that sets headers on the Node.js response object. But streamPageToResponse then calls res.writeHead(statusCode, headers) (line 143 of dev-server.ts), which replaces any previously set headers rather than merging them.

The dev test passes because streamPageToResponse calls res.writeHead(statusCode, headers) on the same res object that gSSP already called res.setHeader() on, and Node.js writeHead() merges with previously set headers when called with an object argument. So the test works, but it's somewhat accidental — if streamPageToResponse were ever changed to construct a fresh headers object, gSSP headers would be lost.

It would be more robust to explicitly collect gSSP-set headers and pass them through extraHeaders, similar to how the prod path does it. But this is a minor concern since the current behavior works correctly.

Question: prod server parity for res.end() short-circuit

In the prod path (prod-server.ts, lines 916-947), renderPage returns a Response, and that response gets passed through sendCompressed. When gSSP calls res.end() and renderPage returns the responsePromise Response (which has content-type: application/json and status 202), the prod server wraps it through sendCompressed which adds Content-Type from the response. This should work fine.

However, the middleware headers from middlewareHeaders will still be merged into the response even for res.end() short-circuit responses. Is that intentional? In the dev path, middleware headers don't get merged because streamPageToResponse is never called. This is a minor dev/prod inconsistency.

Nit: var vs let/const in generated code

The new code in renderPage (generated server entry) mixes var and const/let:

var gsspRes = null;
// ...
var finalStatus = 200;
const responseHeaders = new Headers({ "Content-Type": "text/html" });

The existing generated code in this function uses var throughout (this is ES5-style generated code). The new code introduces const and let (const responseHeaders = new Headers(...), var finalStatus = 200). Not a functional issue since the output targets modern runtimes, but worth noting the inconsistency.

Summary

The fix is correct and well-tested. The one thing worth considering:

  1. The dev/prod behavior gap for middleware headers on res.end() short-circuit responses (minor, probably fine for now)

LGTM with those minor notes.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 6, 2026

I've reviewed and approved PR #281. Here's the summary:

Overall: Good fix. The core issue — gSSP headers/cookies/status being silently dropped — is correctly addressed in both the dev server and production server entry paths.

Key findings:

  1. Prod path is solid: gsspRes is saved after gSSP runs, its accumulated headers/status/cookies are merged into the final Response via the Headers API, with correct append() for Set-Cookie. The res.end() short-circuit uses a responsePromise pattern that cleanly bridges the mock res to a Web Response.

  2. Dev path works but is fragile: The dev server preserves res.statusCode explicitly, but custom headers from res.setHeader() survive only because Node.js writeHead() merges with previously-set headers on the same response object. If streamPageToResponse were refactored, this would silently break. Not blocking, but worth being aware of.

  3. Minor dev/prod inconsistency: When gSSP calls res.end() to short-circuit, the dev path skips middleware header merging entirely (returns early), while the prod path still merges middleware headers into the response via sendCompressed. Unlikely to cause real issues but worth noting.

  4. Tests are well-structured: Two fixture pages covering the important scenarios (custom headers + cookies + status code, and res.end() short-circuit), with assertions on all the right response properties.

github run

Address review feedback:
- Capture headers newly set by gSSP via snapshot/diff and forward them
  explicitly through extraHeaders, instead of relying on Node.js
  writeHead() implicitly merging setHeader() calls. This prevents
  silent header loss if streamPageToResponse is refactored.
- Support array-valued headers (e.g. multiple Set-Cookie) in
  streamPageToResponse by using res.setHeader() for arrays.
- Add comments explaining the middleware header behavior when gSSP
  calls res.end() (dev includes them via the shared res object,
  prod merges them via the worker entry).
@southpolesteve
Copy link
Copy Markdown
Collaborator Author

/bigbonk review this one more time

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 6, 2026

UnknownError: ProviderInitError: ProviderInitError

github run

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 6, 2026

@southpolesteve Bonk workflow failed. Check the logs for details.

View workflow run · To retry, trigger Bonk again.

@southpolesteve
Copy link
Copy Markdown
Collaborator Author

/bigbonk review this one more time

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 6, 2026

UnknownError: ProviderInitError: ProviderInitError

github run

@southpolesteve
Copy link
Copy Markdown
Collaborator Author

/bigbonk review this one more time

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 6, 2026

I've completed my review. Here's my analysis:


Review of PR #281

The fix is correct and well-structured. Both the production path (renderPage in index.ts) and the dev path (dev-server.ts) are properly updated. The previous review's findings still hold. I have one real bug to flag, plus a few observations.

Bug: Multiple Set-Cookie headers lost in prod-server.ts (pre-existing, but now exposed)

prod-server.ts:944-945 does this:

const responseHeaders: Record<string, string> = { ...middlewareHeaders };
response.headers.forEach((v, k) => { responseHeaders[k] = v; });

Headers.forEach() iterates Set-Cookie values as combined comma-separated strings with Headers.get() semantics, or as individual entries depending on the runtime. But since responseHeaders is Record<string, string>, the last Set-Cookie value wins if forEach is called per-entry. In the more common path, Headers.get("set-cookie") joins them with , , which corrupts multi-value Set-Cookie headers (cookies can't be comma-joined because Expires= dates contain commas).

This PR correctly uses responseHeaders.append("set-cookie", ...) in renderPage (index.ts:1468), which is the right approach. But when the response flows through prod-server.ts:945, that forEach + Record<string, string> pattern flattens it back. The resulting sendCompressed call passes the flattened object to writeHead, which doesn't understand multiple Set-Cookie values from a flat string.

This is a pre-existing bug, not introduced by this PR. But this PR makes it more visible since gSSP can now actually set cookies. Worth a follow-up fix — prod-server.ts should use Headers.getSetCookie() (available in modern runtimes) or response.headers.raw() to preserve array-valued Set-Cookie.

What looks good

  1. Production renderPage path — Clean separation: save gsspRes, check headersSent for short-circuit, merge headers via Headers API with correct append() for Set-Cookie, force Content-Type: text/html after merge. All correct.

  2. Dev server path — The header snapshot approach (headersBeforeGSSP / headersAfterGSSP diff) is robust. Removing captured headers from res before writeHead prevents duplication. Using res.setHeader(key, val) for array values before writeHead for scalar values in the headers object is the correct Node.js pattern.

  3. res.end() short-circuit — Both paths handle it correctly. The prod path uses the responsePromise pattern from createReqRes (the end() method resolves it). The dev path checks res.writableEnded on the real Node.js ServerResponse. The comment about middleware headers already being on res in the dev path is accurate and helpful.

  4. getHeaders() and headersSent additions — Correct implementations. getHeaders() returns a copy via Object.assign (good, no mutation risk). headersSent is backed by ended which matches Node.js semantics (headers are "sent" once end() is called).

  5. Tests — Good coverage of the three key scenarios: custom headers + cookies + status, and short-circuit via res.end().

Minor observations

  • var vs const/let mixing in generated code (index.ts:1255, 1460): The previous review noted this. Not a functional issue since the output targets modern runtimes, but var gsspRes = null alongside const responseHeaders = new Headers(...) is inconsistent with the surrounding style. Low priority.

  • Dev statusCode precedence (dev-server.ts:490): if (!statusCode && res.statusCode !== 200) — this means middleware-set status takes precedence over gSSP status, which matches Next.js behavior. Good.

  • Dev/prod parity for middleware headers on res.end() short-circuit: The previous review noted this. In dev, middleware headers are already on the ServerResponse object. In prod, prod-server.ts merges middlewareHeaders into the response from renderPage(). Both paths end up with middleware headers on the final response, just via different mechanisms. This is fine.

Verdict

The PR is correct for its stated scope. The Set-Cookie flattening issue in prod-server.ts is pre-existing and should be tracked separately. LGTM.

github run

@southpolesteve southpolesteve merged commit 1a468dd into main Mar 6, 2026
23 checks passed
@southpolesteve southpolesteve deleted the fix/render-page-response-headers branch March 6, 2026 04:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant