From a50c2121e25a20954a18c169fe3bbc06eced66ba Mon Sep 17 00:00:00 2001 From: thribhuvan003 Date: Thu, 9 Jul 2026 21:28:30 +0530 Subject: [PATCH] fix(start-server-core): copy immutable Response.redirect() headers when merging pending set-cookie values --- .changeset/redirect-immutable-headers.md | 9 +++++ .../start-server-core/src/request-response.ts | 32 ++++++++++++---- .../tests/request-response.test.ts | 37 +++++++++++++++++++ 3 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 .changeset/redirect-immutable-headers.md create mode 100644 packages/start-server-core/tests/request-response.test.ts diff --git a/.changeset/redirect-immutable-headers.md b/.changeset/redirect-immutable-headers.md new file mode 100644 index 0000000000..a748bf2c31 --- /dev/null +++ b/.changeset/redirect-immutable-headers.md @@ -0,0 +1,9 @@ +--- +'@tanstack/start-server-core': patch +--- + +fix(start-server-core): merge pending set-cookie headers into `Response.redirect()` without throwing + +`Response.redirect()` returns a response whose headers are immutable per spec, so merging pending event cookies into it crashed the request with `TypeError: Headers are immutable`. The merge now copies the response when its headers can't be modified, so redirects returned from handlers keep their cookies instead of turning into a 500. + +Fixes #7755. diff --git a/packages/start-server-core/src/request-response.ts b/packages/start-server-core/src/request-response.ts index e0ad8a336a..2a1e83ed3e 100644 --- a/packages/start-server-core/src/request-response.ts +++ b/packages/start-server-core/src/request-response.ts @@ -78,24 +78,40 @@ function getSetCookieValues(headers: Headers): Array { return value ? [value] : [] } -function mergeEventResponseHeaders(response: Response, event: H3Event): void { +function mergeEventResponseHeaders( + response: Response, + event: H3Event, +): Response { if (response.ok) { - return + return response } const eventSetCookies = getSetCookieValues(event.res.headers) if (eventSetCookies.length === 0) { - return + return response } const responseSetCookies = getSetCookieValues(response.headers) - response.headers.delete('set-cookie') + let target = response + try { + response.headers.delete('set-cookie') + } catch { + // Response.redirect() responses have immutable headers per spec, + // so merge into a mutable copy instead + target = new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }) + target.headers.delete('set-cookie') + } for (const cookie of responseSetCookies) { - response.headers.append('set-cookie', cookie) + target.headers.append('set-cookie', cookie) } for (const cookie of eventSetCookies) { - response.headers.append('set-cookie', cookie) + target.headers.append('set-cookie', cookie) } + return target } function attachResponseHeaders( @@ -105,14 +121,14 @@ function attachResponseHeaders( if (isPromiseLike(value)) { return value.then((resolved) => { if (resolved instanceof Response) { - mergeEventResponseHeaders(resolved, event) + return mergeEventResponseHeaders(resolved, event) as T } return resolved }) } if (value instanceof Response) { - mergeEventResponseHeaders(value, event) + return mergeEventResponseHeaders(value, event) as T } return value diff --git a/packages/start-server-core/tests/request-response.test.ts b/packages/start-server-core/tests/request-response.test.ts new file mode 100644 index 0000000000..fe4281c594 --- /dev/null +++ b/packages/start-server-core/tests/request-response.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { requestHandler, setCookie } from '../src/request-response' + +// https://github.com/TanStack/router/issues/7755 +describe('requestHandler set-cookie merging', () => { + it('merges pending event cookies into a Response.redirect() without throwing', async () => { + const handler = requestHandler(() => { + setCookie('session', 'abc123') + // Response.redirect() has immutable headers per spec + return Response.redirect('https://example.com/next', 302) + }) + + const response = await handler(new Request('https://example.com/'), {}) + + expect(response.status).toBe(302) + expect(response.headers.get('location')).toBe('https://example.com/next') + expect(response.headers.getSetCookie()).toContain('session=abc123; Path=/') + }) + + it('still merges cookies into a mutable non-ok response', async () => { + const handler = requestHandler(() => { + setCookie('session', 'abc123') + return new Response(null, { + status: 302, + headers: { location: '/next', 'set-cookie': 'from-response=1' }, + }) + }) + + const response = await handler(new Request('https://example.com/'), {}) + + expect(response.status).toBe(302) + expect(response.headers.getSetCookie()).toEqual([ + 'from-response=1', + 'session=abc123; Path=/', + ]) + }) +})