Skip to content

fix(middleware): preserve headers for empty override value - #2767

Merged
james-elicx merged 7 commits into
mainfrom
codex/fix-empty-middleware-override-parity
Jul 31, 2026
Merged

fix(middleware): preserve headers for empty override value#2767
james-elicx merged 7 commits into
mainfrom
codex/fix-empty-middleware-override-parity

Conversation

@james-elicx

@james-elicx james-elicx commented Jul 30, 2026

Copy link
Copy Markdown
Member

Summary

  • require a truthy x-middleware-override-headers value before translating middleware request-header overrides
  • preserve the original logical request headers when the override value is missing or empty
  • preserve truthy unconsumed x-middleware-request-* values under their literal names on downstream requests and responses; skip empty values
  • retain complete-set deletion semantics for every non-empty override list

Next.js parity

Next.js handles these headers in two stages. The override translation block runs only when x-middleware-override-headers is truthy. Its later generic middleware-header merge copies any truthy values that were not consumed by that list under their literal names.

This was confirmed both from source and with a black-box Next.js 16.2.7 App Route repro, which observed the original logical header alongside the literal unconsumed protocol header.

Validation

  • focused helper, config-matcher, redirect, App Route, Pages, and external-rewrite tests pass
  • vp check passes for every changed source and test file
  • independent sub-agent review: no findings
  • all 66 PR checks green

@github-actions

Copy link
Copy Markdown
Contributor
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
workers-cache preview production
web preview production

@pkg-pr-new

pkg-pr-new Bot commented Jul 30, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: e0ff780

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared e0ff780 against base 1607104 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.5 KB 134.5 KB ⚫ +0.0%
Client entry size (gzip) vinext 122.1 KB 122.1 KB ⚫ 0.0%
Dev server cold start vinext 3.00 s 2.90 s 🟢 -3.5%
Production build time vinext 3.16 s 3.15 s ⚫ -0.4%
RSC entry closure size (gzip) vinext 111.9 KB 112.2 KB ⚫ +0.3%
Server bundle size (gzip) vinext 189.6 KB 189.7 KB ⚫ +0.1%

View detailed results and traces

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

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: fix(middleware): preserve headers for empty override value

Verdict: LGTM. The fix is correct and matches Next.js behavior. Verified against Next.js source and confirmed all call sites handle the change safely.

Correctness confirmed

The core change — treating an empty x-middleware-override-headers value as "no override" (return null) — matches Next.js exactly. In resolve-routes.ts, the entire request-header mutation block is guarded by a truthiness check:

if (middlewareHeaders['x-middleware-override-headers']) {  // "" is falsy → skipped

So NextResponse.next({ request: { headers: new Headers() } }) (which serializes to x-middleware-override-headers: "") leaves the incoming request unchanged. Before this PR, getOverrideHeaderNames returned [] for "", which drove buildRequestHeadersFromMiddlewareResponse down the "complete override set" path and produced an empty Headers — dropping every request header (auth, cookies, etc.). This was a real bug; the fix is right.

Call-site safety verified

All five consumers of buildRequestHeadersFromMiddlewareResponse gate on a falsy/null return and leave the request untouched, so returning null (rather than empty Headers) is safe everywhere:

  • shims/headers.ts:651-653if (!nextHeaders && ...) return; then if (nextHeaders)
  • config/config-matchers.ts:460if (nextHeaders) { ... }
  • server/app-middleware.ts:125if (!nextHeaders) return request;
  • server/app-route-handler-runtime.ts:310 — already handled the pre-existing null case

Tests

Good coverage: a focused helper unit test (red before the change) plus a real external-rewrite proxy integration test asserting authorization/cookie/x-keep survive. Both pass locally, along with tests/app-router-external-rewrite.test.ts and tests/pages-request-pipeline.test.ts. vp check is clean.

Non-blocking note: one theoretical divergence

There is a narrow edge case where vinext and Next.js still differ: an empty override value ("") combined with x-middleware-request-* forwarded headers present. In that case vinext takes the overrideHeaderNames === null && forwardedHeaders.size === 0 guard as false (line 89), clones the base headers, and applies the forwarded headers on top — whereas Next.js skips the whole mutation block and applies nothing.

This is not reachable through vinext's own producer: encodeMiddlewareRequestHeaders derives both the override list and the x-middleware-request-* entries from the same requestHeaders, so an empty override implies zero forwarded headers. It's only constructible via manually/externally crafted headers, so it's low risk and I would not block on it. If you want strict parity for external inputs, getOverrideHeaderNames returning a distinct "empty = no override" sentinel could let buildRequestHeadersFromMiddlewareResponse short-circuit to null even when stray x-middleware-request-* headers exist. Optional — a comment noting the assumption would also suffice.

@ask-bonk

ask-bonk Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2767.

Summary

Verdict: LGTM — the fix is correct and matches Next.js behavior.

What the PR does

Treats an empty x-middleware-override-headers value ("") as "no override," returning null instead of building an empty Headers set that dropped all request headers.

What I verified

  1. Next.js parity — Fetched resolve-routes.ts from Next.js canary. The request-header mutation block is guarded by a truthiness check if (middlewareHeaders['x-middleware-override-headers']), so "" skips it and leaves the request unchanged. The fix matches exactly.
  2. The bug was real — Before the change, NextResponse.next({ request: { headers: new Headers() } }) serialized to "", drove the "complete override set" path, and wiped auth/cookie headers.
  3. All 5 call sites correctly gate on the falsy/null return (shims/headers.ts, config-matchers.ts, app-middleware.ts, app-route-handler-runtime.ts), so returning null is safe everywhere.
  4. Tests pass — targeted helper test, external-rewrite proxy integration test, plus app-router-external-rewrite and pages-request-pipeline suites. vp check clean.

One non-blocking note

I flagged a narrow theoretical divergence: empty override "" plus stray x-middleware-request-* headers. vinext would clone base + apply forwarded headers, while Next.js skips the whole block. However, this combination is not producible via vinext's own encodeMiddlewareRequestHeaders (empty override implies zero forwarded headers), so it's only reachable through externally crafted headers — low risk, optional to address.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: fix(middleware): preserve headers for empty override value

Verdict: LGTM. The fix is correct, matches Next.js exactly, and the latest two commits go further than the earlier /bigbonk pass saw — they close the one theoretical divergence that review flagged as non-blocking. Verified against Next.js source, all five call sites, and dev/prod/Pages/App parity.

Correctness confirmed against Next.js

The whole request-header mutation block in resolve-routes.ts is gated on a truthiness check:

if (middlewareHeaders['x-middleware-override-headers']) {  // "" and missing are both falsy → skipped

The PR's final form guards identically with if (!rawOverrideHeader) return null;, so:

  • Empty override ("") — emitted by NextResponse.next({ request: { headers: new Headers() } }) — returns null, leaving the request unchanged. This is the reported bug: previously getOverrideHeaderNames("") returned [], drove the "complete override set" path, and produced an empty Headers that dropped every request header (auth, cookies). Fixed.
  • Missing override — also returns null. Note this is a subtle behavior change from the previous code, which cloned base headers and applied forwarded x-middleware-request-* on top. Next.js skips the block entirely for a missing header, so the old vinext path actually diverged; the new code now matches Next.js. Safe in practice because vinext's own encodeMiddlewareRequestHeaders never emits x-middleware-request-* without a corresponding non-empty override entry.

The earlier non-blocking note is now resolved

The previous review flagged "empty override "" plus stray forwarded headers" as a remaining divergence (vinext would clone base + apply forwarded; Next.js skips). Commits 4330cc2 and 6e860c2 collapse the guard to a single !rawOverrideHeader check, so stray x-middleware-request-* values are now ignored for both empty and missing overrides — strict parity even for externally crafted headers. The new request-pipeline.test.ts cases assert exactly this.

Call-site safety verified (all five)

Every consumer treats null as "leave the request unchanged", so returning null instead of empty Headers is safe everywhere:

  • shims/headers.ts:651-665if (!nextHeaders && ...) return; then guarded if (nextHeaders)
  • config/config-matchers.ts:460if (nextHeaders) { rebuild request }, else keeps original
  • server/app-middleware.ts:125if (!nextHeaders) return request;
  • server/app-route-handler-runtime.ts:312-314requestHeaders ? rebuild : input

Parity across servers

Pages Router (pages-request-pipeline.ts:472) delegates to the same applyMiddlewareRequestHeadersbuildRequestHeadersFromMiddlewareResponse, and App Router / route handlers / the headers shim all call the shared helper. There is no divergent duplicate implementation in prod-server, dev-server, or the worker entry, so this single fix covers every runtime.

Tests & checks

  • tests/request-pipeline.test.ts — unit coverage for empty-override, empty+stray, and missing+stray, all returning null.
  • tests/shims.test.ts — real external-rewrite proxy integration test asserting authorization / cookie / x-keep survive an empty override; plus existing cookie-mutation tests correctly updated to include the now-required x-middleware-override-headers value (previously those relied on the missing-override clone path, so the update is a genuine correctness fix, not just churn).
  • Locally: helper unit tests, shims integration test, tests/app-router-external-rewrite.test.ts + tests/pages-request-pipeline.test.ts (94 passed), and vp check on the source all green.

Minor / optional (non-blocking)

  • The renamed _baseHeaders parameter is now entirely unused in the function body (the "clone base" path is gone). Keeping the signature stable for the five callers is fine, but a one-line JSDoc @param note that it's intentionally retained-for-signature-parity would save the next reader a double-take. Purely cosmetic.

Nice work — the extra two commits took this from "correct for the reported case" to "strict Next.js parity, including hostile inputs."

@ask-bonk

ask-bonk Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2767.

Summary

Verdict: LGTM — the fix is correct and achieves strict Next.js parity.

What the PR does

Treats an empty x-middleware-override-headers value ("") — and now also a missing one combined with stray forwarded headers — as "no override," returning null instead of building an empty/base-cloned Headers set.

What I verified

  1. Next.js parity — Fetched resolve-routes.ts from canary. The entire request-header mutation block is gated on a truthiness check (if (middlewareHeaders['x-middleware-override-headers'])), so both "" and missing skip it. The PR's if (!rawOverrideHeader) return null; matches exactly.
  2. The bug was realNextResponse.next({ request: { headers: new Headers() } }) serialized to "", drove the "complete override" path, and wiped auth/cookie headers.
  3. Behavior change caught — The previous "missing override → clone base + apply forwarded" path actually diverged from Next.js; the new code fixes that too. Safe because vinext's own encoder never emits x-middleware-request-* without a non-empty override entry.
  4. The earlier bonk note is resolved — Commits 4330cc2/6e860c2 collapse the guard so stray forwarded headers are ignored for both empty and missing overrides. That's strict parity even for externally crafted inputs.
  5. All 5 call sites treat null as "leave request unchanged."
  6. Parity across runtimes — Pages/App/route-handlers/headers-shim all delegate to the one shared helper; no divergent duplicate to fix.
  7. Tests + vp check pass locally (helper units, shims integration, 94 passing in the two related suites).

One cosmetic note (non-blocking)

The renamed _baseHeaders param is now fully unused; a one-line JSDoc noting it's retained for signature parity would help the next reader.

github run

@james-elicx
james-elicx marked this pull request as ready for review July 30, 2026 23:49
@james-elicx
james-elicx marked this pull request as draft July 30, 2026 23:58
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: fix(middleware): preserve headers for empty override value

Verdict: LGTM. The fix is correct, achieves strict Next.js parity, and is well-tested across every runtime. I verified the change against the current Next.js resolve-routes.ts source, all call sites, the producer invariant, and dev/prod parity.

Correctness confirmed against Next.js source

Fetched resolve-routes.ts from canary. The entire request-header mutation block is gated on a truthiness check:

if (middlewareHeaders['x-middleware-override-headers']) {   // "" and missing → skipped
  // delete req.headers not in the override set
  // then apply x-middleware-request-<key> for each key in the set
}

The PR's final guard, if (!rawOverrideHeader) return null;, matches this exactly:

  • Empty override ("") — emitted by NextResponse.next({ request: { headers: new Headers() } }) — returns null, leaving the request unchanged. This is the reported bug: previously getOverrideHeaderNames("") returned [], drove the "complete override set" path, and produced an empty Headers that dropped every incoming header (auth, cookies). Fixed.
  • Missing override — also returns null. Note this is a genuine behavior change: the old code cloned base headers and applied forwarded x-middleware-request-* on top. Next.js skips the block entirely for a missing header, so the old vinext path actually diverged. The new code matches Next.js.

I also confirmed the per-key semantics match: for an override key with no corresponding x-middleware-request-<key> value, both Next.js (req.headers[key] = undefined) and vinext (if (value !== undefined) skip) leave the header absent.

The producer invariant makes the removed branch safe

encodeMiddlewareRequestHeaders (shims/server.ts:318,331) is the only producer of x-middleware-request-*, and it always sets x-middleware-override-headers to the joined list of every key in requestHeaders. So empty override ⇒ zero forwarded headers, and non-empty override ⇒ forwarded headers present. The "missing/empty override + stray forwarded headers" combination is not producible by real middleware — only by externally crafted headers — and the PR now handles even that case with strict parity. The removed cloneHeaders branch was effectively dead for real requests.

Call-site safety verified (all five)

Every consumer treats null as "leave the request unchanged," so returning null instead of empty Headers is safe:

  • shims/headers.ts:651if (!nextHeaders && ...) return; then guarded if (nextHeaders)
  • config/config-matchers.ts:460if (nextHeaders) { rebuild }, else keep original
  • server/app-middleware.ts:125if (!nextHeaders) return request;
  • server/app-route-handler-runtime.ts:312requestHeaders ? rebuild : input
  • entries/app-rsc-entry.ts:1270 — passes the same helper through codegen

Cookie tests correctly updated (not churn)

The three applyMiddlewareRequestHeaders cookie tests in shims.test.ts now add x-middleware-override-headers: "cookie". This is a correctness fix, not churn: they previously relied on the missing-override clone path that couldn't occur from real middleware. Separately, I confirmed the middleware-set cookies are visible test (line 4189) works via the independent x-middleware-set-cookie / mergeMiddlewareSetCookies path, so it is unaffected by the override change.

Parity across servers

Pages Router prod (pages-request-pipeline.ts:472applyMiddlewareRequestHeaders → shared helper), App Router, route handlers, and the headers shim all delegate to the single buildRequestHeadersFromMiddlewareResponse. rg found no divergent duplicate implementation in prod-server, dev-server, or the worker entry, so this one fix covers every runtime.

Tests & checks (run locally)

  • tests/request-pipeline.test.ts buildRequestHeadersFromMiddlewareResponse — 3 passed (empty override, empty+stray, missing+stray all return null).
  • tests/shims.test.ts "empty override" — 1 passed (external-rewrite proxy integration asserting authorization/cookie/x-keep survive).
  • tests/app-router-external-rewrite.test.ts — 6 passed, including the new "stray forwarded header without override list" end-to-end case that forges x-middleware-request-x-added in the fixture and confirms it is ignored.
  • vp check packages/vinext/src/utils/middleware-request-headers.ts — clean.

Minor / optional (non-blocking)

  • _baseHeaders is now entirely unused in the body (the clone path is gone). Keeping the signature stable for the five callers + the codegen entry is the right call; a one-line @param note that it's retained-for-signature-parity would save the next reader a double-take. Purely cosmetic.

Nice work — the fix correctly identifies that the old missing-override clone path was itself a Next.js divergence, and the added integration test covering hostile inputs is a good touch.

@ask-bonk

ask-bonk Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2767.

Summary

Verdict: LGTM — the fix is correct and achieves strict Next.js parity across all runtimes.

What the PR does

Requires a truthy x-middleware-override-headers value before applying middleware request-header overrides. Both empty ("") and missing values now return null ("leave request unchanged") instead of building an empty/base-cloned Headers set that dropped incoming headers.

What I verified independently

  1. Next.js source — Fetched resolve-routes.ts from canary. The entire mutation block is gated on if (middlewareHeaders['x-middleware-override-headers']) (truthiness), so "" and missing both skip it. The PR's if (!rawOverrideHeader) return null; matches exactly, including per-key semantics for override keys with no forwarded value.
  2. The bug was realNextResponse.next({ request: { headers: new Headers() } }) serialized to "", drove the "complete override set" path, and wiped auth/cookie headers.
  3. Removed branch was safe — Confirmed encodeMiddlewareRequestHeaders (server.ts:318/331) is the only producer of x-middleware-request-* and always emits a matching override list. The old "missing override → clone base + apply forwarded" branch was dead for real requests and was itself a Next.js divergence.
  4. All 5 call sites treat null as "leave request unchanged."
  5. Cookie test updates are correctness fixes, not churn — they relied on the removed clone path. The x-middleware-set-cookie path is independent and unaffected.
  6. Runtime parity — no divergent duplicate; Pages/App/route-handlers/headers-shim all delegate to the shared helper.
  7. Tests pass locally (helper units, shims integration, 6/6 external-rewrite incl. the new hostile-input case) + vp check clean.

One non-blocking note

_baseHeaders is now fully unused; a one-line JSDoc noting it's retained for signature parity would help future readers. Cosmetic only.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx
james-elicx marked this pull request as ready for review July 31, 2026 09:28
@james-elicx
james-elicx merged commit 9f1018c into main Jul 31, 2026
66 checks passed
@james-elicx
james-elicx deleted the codex/fix-empty-middleware-override-parity branch July 31, 2026 09:37
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