Skip to content

fix: preserve headers mutated after raw Response construction#357

Open
abdulmunimjemal wants to merge 1 commit into
honojs:mainfrom
abdulmunimjemal:fix/raw-response-headers
Open

fix: preserve headers mutated after raw Response construction#357
abdulmunimjemal wants to merge 1 commit into
honojs:mainfrom
abdulmunimjemal:fix/raw-response-headers

Conversation

@abdulmunimjemal
Copy link
Copy Markdown

Fixes #304.

Problem

When a Hono handler returns new Response(body, init) and appends headers after construction — e.g. res.headers.append('Set-Cookie', ...) — the appended headers are silently dropped once middleware such as cors or compress clones the response. The bug only manifests on @hono/node-server; the same code works correctly on Bun, Cloudflare Workers, and AWS Lambda.

Minimal repro from the issue:

import { Hono } from 'hono'
import { compress } from 'hono/compress'

const app = new Hono()
app.use(compress())

app.post('/test', async (c) => {
  const res = new Response('hello', { status: 200, headers: { 'Content-Type': 'text/plain' } })
  res.headers.append('Set-Cookie', 'session=abc; Path=/; HttpOnly')
  return res
})

The Set-Cookie header never reaches the client.

Root cause

src/response.ts defines a lightweight Response2 that stores headers in two places:

  • cacheKey[2] — the live Headers object exposed by the headers getter, reflecting every .append() / .set() / .delete() mutation.
  • this.#init.headers — the original ResponseInit.headers argument, never updated.

Two code paths read from #init.headers instead of the live headers:

  1. getResponseCache built the underlying GlobalResponse from this.#init. Any access to a delegated property (.body, .text(), …) — which middleware does when streaming a raw body — froze the stale init headers and lost subsequent mutations.
  2. The constructor, when cloning via new Response(body, init) where init is another Response2 without a materialized responseCache, read init.#init.headers instead of init.headers (the getter that returns the live cacheKey[2]).

Fix

Both spots now consult the live Headers instance on cacheKey[2] when present, so post-construction header mutations survive cloning. The fix is local to src/response.ts; no public API changes.

   [getResponseCache](): globalThis.Response {
+    const cache = (this as LightResponse)[cacheKey]
+    const liveHeaders = cache && cache[2] instanceof Headers ? cache[2] : undefined
     delete (this as LightResponse)[cacheKey]
-    return ((this as LightResponse)[responseCache] ||= new GlobalResponse(this.#body, this.#init))
+    return ((this as LightResponse)[responseCache] ||= new GlobalResponse(
+      this.#body,
+      liveHeaders ? { ...this.#init, headers: liveHeaders } : this.#init
+    ))
   }
       } else {
         this.#init = init.#init
-        headers = new Headers((init.#init as ResponseInit).headers)
+        headers = new Headers(init.headers)
       }

new Headers(...) still produces an independent copy, so parent and child do not share the same object — the existing "Should not lose header data" test continues to pass.

Tests

Two regression tests added:

  • test/response.test.ts — unit test covering both clone patterns:
    1. new Response('body', parent) before getResponseCache is triggered (exercises the constructor fix).
    2. new Response(parent.body, parent) after .body materializes the GlobalResponse (exercises the getResponseCache fix).
  • test/server.test.ts — end-to-end test that mirrors the issue: a middleware re-wraps c.res via new Response(c.res.body, c.res), and the handler returns a raw Response with an appended Set-Cookie. Asserts that the Set-Cookie survives.

Both tests fail on main and pass with the fix applied. The full suite stays green: 343 passed / 343 (12 files).

When a handler returns `new Response(body, init)` and appends headers
after construction (e.g. `res.headers.append('Set-Cookie', ...)`), the
headers were silently dropped once middleware cloned the response via
`new Response(c.res.body, c.res)` (the pattern used by `cors` and
`compress`).

There were two paths that lost the mutations:

- The constructor read `init.#init.headers` — the *original* init
  passed at construction time — instead of `init.headers` (the live
  getter backed by `cacheKey[2]`).
- `getResponseCache` built the underlying `GlobalResponse` from
  `this.#init` without consulting the live `cacheKey[2]` headers, so
  any access to a delegated property (`.body`, `.text()`, ...) before
  the clone would freeze the stale init headers.

Both spots now consult the live `Headers` instance on `cacheKey[2]`
when present, so post-construction header mutations survive cloning.

Fixes honojs#304
@usualoma
Copy link
Copy Markdown
Member

Hi @abdulmunimjemal,
Thank you. LGTM!

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.

Header missing when returning raw response

2 participants