diff --git a/src/response.ts b/src/response.ts index 96d34bd..8cea7f5 100644 --- a/src/response.ts +++ b/src/response.ts @@ -25,8 +25,16 @@ export class Response { #init?: ResponseInit; [getResponseCache](): globalThis.Response { + // If `cacheKey` has been populated with a live `Headers` instance, the + // user (or middleware) may have mutated it after construction. Use those + // headers so the GlobalResponse reflects the current state. + 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 + )) } constructor(body?: BodyInit | null, init?: ResponseInit) { @@ -41,8 +49,11 @@ export class Response { return } else { this.#init = init.#init - // clone headers to avoid sharing the same object between parent and child - headers = new Headers((init.#init as ResponseInit).headers) + // Read headers via the live getter so mutations made on `init` after + // construction (e.g. `init.headers.append('Set-Cookie', ...)`) are + // preserved on the clone. `new Headers(...)` still produces an + // independent copy so parent and child do not share the same object. + headers = new Headers(init.headers) } } else { this.#init = init diff --git a/test/response.test.ts b/test/response.test.ts index a8d62f7..64bd6b0 100644 --- a/test/response.test.ts +++ b/test/response.test.ts @@ -89,6 +89,34 @@ describe('Response', () => { expect(childResponse.headers.get('content-type')).toEqual('application/json') }) + it('Should preserve headers mutated after construction when cloned via new Response(body, init)', () => { + // Regression test for https://github.com/honojs/node-server/issues/304. + // Headers appended (or set/deleted) after construction must be visible on + // a clone built with `new Response(body, parent)` — which is the pattern + // used by middleware such as `cors` and `compress`. + const parentResponse = new Response('hello', { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + parentResponse.headers.append('set-cookie', 'session=abc; Path=/; HttpOnly') + + // Pattern 1: clone before any `getResponseCache`-triggering access. + const childResponse = new Response('hello', parentResponse) + expect(childResponse.headers.get('set-cookie')).toEqual('session=abc; Path=/; HttpOnly') + expect(childResponse.headers.get('content-type')).toEqual('application/json') + + // Pattern 2: clone after `.body` has materialized the GlobalResponse — + // this is what middleware does when streaming a raw Response body. + const parentForBody = new Response('hello', { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + parentForBody.headers.append('set-cookie', 'session=xyz; Path=/; HttpOnly') + const streamedChild = new Response(parentForBody.body, parentForBody) + expect(streamedChild.headers.get('set-cookie')).toEqual('session=xyz; Path=/; HttpOnly') + expect(streamedChild.headers.get('content-type')).toEqual('application/json') + }) + it('Nested constructors should not cause an error even if ReadableStream is specified', async () => { const stream = new Response('hono').body const parentResponse = new Response(stream) diff --git a/test/server.test.ts b/test/server.test.ts index a89ce34..2f5bdac 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -1012,6 +1012,35 @@ describe('set child response to c.res', () => { }) }) +describe('Headers appended to a raw Response after construction (issue #304)', () => { + // Regression test: a handler returning `new Response(body, init)` and + // appending headers (e.g. `Set-Cookie`) afterwards must not lose those + // headers when middleware later clones the response via `new Response(...)`. + const app = new Hono() + app.use('*', async (c, next) => { + await next() + // Mimics what middleware such as `cors`/`compress` does internally. + c.res = new Response(c.res.body, c.res) + }) + app.post('/test', () => { + const res = new Response('hello', { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }) + res.headers.append('Set-Cookie', 'session=abc; Path=/; HttpOnly') + return res + }) + + it('Should preserve the appended Set-Cookie header', async () => { + const server = createAdaptorServer(app) + const res = await request(server).post('/test') + expect(res.status).toBe(200) + expect(res.text).toBe('hello') + expect(res.headers['content-type']).toMatch('text/plain') + expect(res.headers['set-cookie']).toEqual(['session=abc; Path=/; HttpOnly']) + }) +}) + describe('forwarding IncomingMessage and ServerResponse in env', () => { const app = new Hono<{ Bindings: HttpBindings }>() app.get('/', (c) =>