cloudflare / cloudflare/vinext
next/headers: make headers() a live view of the incoming request (observe writes, hide internal headers on read)
- Dominant language
- TypeScript
- Stars
- 8.8k
- Forks
- 406
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 120
Description
## Upstream change
Next.js restored `headers()` so it reads through to the underlying incoming request instead of returning a detached snapshot.
- Commit: [`3c97df5`](https://github.com/vercel/next.js/commit/3c97df56ead9d1df81b36f891ba5ac0724c4eec0) "Restore the live `headers()` view of the incoming request" (#97166)
- fixes vercel/next.js#97049, closes vercel/next.js#97145
## Background
Between vercel/next.js#94703 and vercel/next.js#95116, `headers()` became detached from the request it describes:
- #94703 started stripping dev-only request-id headers alongside flight headers, but `HeadersAdapter.from` returns the `Headers` instance unchanged (no copy), so the deletes mutated the shared `req.headers`.
- #95116 fixed the mutation by **copying** the headers before stripping — but the copy detached the sealed view from the live request.
Result (since 16.3.0): a Proxy/middleware that writes a header onto `request.headers` and reads it back via `headers()` sees the value from *before* the write. Reading the same header directly off `request.headers` returns the new value, so the two APIs disagree. Whether a write is observed depends on ordering, because the view is built lazily on first access.
## Correct behavior (what to implement/verify)
1. `headers()` must be a **live view** of the incoming request — writes made to the request (e.g. by middleware/proxy) are reflected in `headers()`, regardless of ordering relative to the first `headers()` call.
2. A second `headers()` call must return the same live view (not a stale snapshot).
3. Internal/framework headers (flight headers, dev request-id headers) must stay **on the request** for framework plumbing, but stay **hidden from userland `headers()`** on read — even when written after the first `headers()` call. Hiding is done on *read* (via a sealed view that omits a set of header names), not by a one-time delete.
4. `forEach` on the sealed view must pass the **sealed proxy** as the `parent` argument, not the unsealed target (otherwise the callback gets a mutable handle that defeats the seal). `getSetCookie` must also respect the hidden set.
5. `cookies()` is unaffected — it stays a snapshot because `RequestCookies` parses the `cookie` header at construction time.
## Upstream test to port
`test/e2e/app-dir/proxy-headers-live-view/` — a proxy writes `set-during-proxy` onto `NextRequest.headers` and asserts via a `/probe` route:
```js
{
valueBeforeMutation: null,
valueOnRequest: 'set-during-proxy',
valueOnFirstView: 'set-during-proxy',
valueOnSecondView: 'set-during-proxy',
sameView: true,
internalHeaderOnRequest: 'set-during-proxy',
internalHeaderOnView: null,
internalHeaderIsIterated: false,
}
```
Also port the `HeadersAdapter` unit tests (`packages/next/src/server/web/spec-extension/adapters/headers.test.ts`, +130 lines) covering the omit-on-read seal, `forEach` parent, and `getSetCookie`.
## vinext relevance
vinext reimplements `next/headers`. We should verify our `headers()` shim reflects request mutations made by middleware and hides internal headers on read (not via one-time deletes), matching this parity fix. Check dev/prod parity across `entries/app-rsc-entry.ts`, `server/dev-server.ts`, `server/prod-server.ts`, and `cloudflare/worker-entry.ts`.
Contributor guide
Research direction
Start with the `next/headers` shim and compare behavior across `entries/app-rsc-entry.ts`, `server/dev-server.ts`, `server/prod-server.ts`, and `cloudflare/worker-entry.ts`. Port the `proxy-headers-live-view` end-to-end test and the `packages/next/src/server/web/spec-extension/adapters/headers.test.ts` cases, then verify live request updates, hidden internal headers, the sealed `forEach` parent, and `getSetCookie` behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- next.js, typescript
- Domain
- api, backend, testing
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100