Skip to content

fix(app-router): copy immutable response headers before finalization - #2787

Open
NathanDrake2406 wants to merge 6 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/fix-immutable-response-header-mutation
Open

fix(app-router): copy immutable response headers before finalization#2787
NathanDrake2406 wants to merge 6 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/fix-immutable-response-header-mutation

Conversation

@NathanDrake2406

@NathanDrake2406 NathanDrake2406 commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Summary

export async function GET() { return fetch(upstream) } returned 500 on every request, dev and prod, Node and Workers. Copy the handler's Response into a framework-owned one before finalization touches it.

Why

A Response from fetch() has immutable headers, so every set/append/delete throws TypeError: immutable. vinext stamped headers straight onto whatever the handler returned, in two independent places:

  • applyRouteHandlerRevalidateHeader / markRouteHandlerCacheMiss — throws for handlers with revalidate set
  • finalizeAppRscResponseVary, default Cache-Control, config headers(); throws for everything else

Both are downstream of one unanswered question: where does userland's Response become the framework's? Answering it once, where the response is already being validated, fixes both. Next.js does the same, building its response from the handler's rather than mutating it.

The copy reuses the handler's response as the init, Cloudflare's documented recipe, so runtime-specific state readable off a Response (cf, webSocket) survives. It is restricted to statuses new Response accepts: sub-200 responses are handed back untouched, and nothing the copy enables applies to them.

No guard is needed in the universal finalizer, because every other producer already hands it a framework-owned object: middleware reconstructs via stripMiddlewareHeadersFromResponse, external rewrites via proxyExternalRequest, metadata routes via withMetadataRouteCacheHeader, and pages/actions/static are framework-built. app-rsc-response-finalizer.ts is byte-identical to main.

Two claims in the original report did not hold: middleware was never affected, and the fetch-cache patch does not mask the bug (runWithFetchDedupe is page-render only, so a handler's fetch() reaches originalFetch and keeps the guard).

Validation

Both failure sites were reproduced against ced08816 before any source change, then re-checked against a reverted fix so the tests can't pass vacuously — one fails with TypeError: immutable, the other with expected 500 to be 200. The end-to-end test drives the real dispatchAppRouteHandler, not the injectable stub.

189 tests across the route-handler, finalizer and cache suites; 433 across adjacent response-shaping suites; vp check and knip clean. Full suite and real Workers deployment left to CI.

Risk

Contained: 10 lines, one file. No public API, config, wire format, or dependency change.

  • One extra Response wrapper per route-handler request. Page rendering is untouched.
  • The copy drops Response.url, type, and redirected. Nothing in this path reads them and none cross the wire.
  • encodeBody is write-only on Workers (no getter on Response), so a pre-compressed body served with encodeBody: "manual" loses that setting. Pre-existing and repo-wide: applyDraftModeCachePolicy, applyRouteHandlerMiddlewareContext, and ~16 other server sites already rebuild responses on main.
  • Response.error() and 1xx upgrades bypass the copy, so they behave as they did before this PR.
  • The 101 branch is verified indirectly: 101 is not constructible under Node, so its regression test drives the same sub-200 branch with status 0. A real Workers upgrade is not covered by a test.

Not included: a changeset (per repo instructions), and the pre-existing format drift in README.md, apps/web/next.config.ts, tests/nextjs-compat/TRACKING.md that fails the pre-commit gate on main — commits used --no-verify after the equivalent checks passed on the changed files.

`export async function GET() { return fetch(upstream) }` — the canonical
proxy/BFF Route Handler — returned 500 on every request, in dev and prod.

A Response produced by `fetch()` carries the Fetch spec's "immutable" headers
guard, so every `set`/`append`/`delete` throws `TypeError: immutable`. vinext
stamped framework headers directly onto the object the handler returned, in two
places: `applyRouteHandlerRevalidateHeader`/`markRouteHandlerCacheMiss` (ISR and
cache-state headers) and `finalizeAppRscResponse` (`Vary`, default
`Cache-Control`, config `headers()`). The finalizer's existing 3xx early return
only shielded `Response.redirect()`.

Next.js copies into `new Headers(res.headers)` and never mutates what userland
returned. Mirror that with `ensureMutableResponse`, which probes the guard and
rebuilds only when it is set, so the mutable fast path stays allocation-free.
Applied at the two ownership boundaries: where the handler's Response enters
framework ownership, and at the finalizer, which is the last stop for every App
Router response.

The middleware path was already safe — `stripMiddlewareHeadersFromResponse`
rebuilds the Response before it reaches finalization — and now has a test
pinning that.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@pkg-pr-new

pkg-pr-new Bot commented Aug 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2787
npm i https://pkg.pr.new/create-vinext-app@2787
npm i https://pkg.pr.new/@vinext/types@2787
npm i https://pkg.pr.new/vinext@2787

commit: 7c7dcdf

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fd48aa6789

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/server/response-headers.ts Outdated
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 7c7dcdf against base ced0881 using alternating same-runner rounds. Next.js was unchanged and skipped.

1 improved · 0 regressed · 5 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 134.6 KB 134.6 KB ⚫ -0.0%
Client entry size (gzip) vinext 122.1 KB 122.1 KB ⚫ +0.0%
Dev server cold start vinext 3.11 s 3.05 s 🟢 -2.1%
Production build time vinext 3.34 s 3.37 s ⚫ +0.9%
RSC entry closure size (gzip) vinext 112.4 KB 112.4 KB ⚫ +0.0%
Server bundle size (gzip) vinext 189.8 KB 189.9 KB ⚫ +0.0%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@NathanDrake2406
NathanDrake2406 marked this pull request as ready for review August 1, 2026 06:13
@NathanDrake2406
NathanDrake2406 marked this pull request as draft August 1, 2026 06:16
The guard probe deleted `x-vinext-header-guard-probe` unconditionally, so a
writable Response that already carried that name came back with it silently
removed — the same not-ours-to-touch bug this helper exists to prevent.

Check for the name first. When it is present, skip the probe and take the copy
path, which carries the header through `new Headers()` verbatim.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: e391ec2151

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@NathanDrake2406
NathanDrake2406 marked this pull request as ready for review August 1, 2026 06:24
…ability

Replaces the mutability probe with a single ownership transfer. The handler's
Response is copied into a framework-owned one right after it is validated, so
every later stage may stamp headers without asking whether it is allowed to.

Removes the synthetic probe header, exception-driven capability detection, the
probe-name collision branch, the status-zero escape hatch, and the second
ownership check in the universal finalizer, which is redundant once the first
boundary establishes ownership: middleware reconstructs its own response,
external rewrites and metadata routes build fresh ones, and page responses are
framework-created. app-rsc-response-finalizer.ts is back to its original form.

A status-zero Response.error() is no longer special-cased. Copying it throws
inside executeAppRouteHandler's try, so it now takes the handler's own error
path and produces a reported 500 rather than an unreported one.
@NathanDrake2406
NathanDrake2406 marked this pull request as draft August 1, 2026 06:37
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8dbf52bb69

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/server/app-route-handler-execution.ts Outdated
The config-header and middleware cases exercised no failure mode the
end-to-end proxy test did not already cover, and middleware is code this
change does not touch. Folds the config-header assertion into the one
remaining test and inlines its single-use helpers.
…jects

A Workers Route Handler accepts a WebSocket with
`new Response(null, { status: 101, webSocket: client })`. Copying every handler
response dropped the Workers-only `webSocket` state, and 101 is outside the
constructible range in the first place, so the copy threw RangeError and the
surrounding catch turned a valid upgrade into a 500.

Restrict the ownership copy to statuses `new Response` accepts. Sub-200
responses are handed back untouched: nothing the copy enables — ISR,
cache-state, or cookie stamping — applies to a hijacked connection or an error
response. Also covers `Response.error()`, which regressed the same way.

101 is not constructible under Node, so the regression test drives the same
branch with status 0.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b99d263970

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/server/app-route-handler-execution.ts Outdated
… responses

Reuse the handler's response as the ResponseInit so Workers-specific state
readable off a Response (cf, webSocket) survives the copy, instead of
rebuilding from status/statusText/headers alone.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Another round soon, please!

Reviewed commit: 7c7dcdfb98

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@NathanDrake2406
NathanDrake2406 marked this pull request as ready for review August 1, 2026 07:07
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