Skip to content

Commit 74afcd7

Browse files
authored
Fix setting multiple cookies (#3508)
* Fix multi cookies * Cleanup * Refactor
1 parent da5245b commit 74afcd7

File tree

5 files changed

+175
-14
lines changed

5 files changed

+175
-14
lines changed

packages/pwa-kit-runtime/CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
## v3.15.0-dev (Nov 05, 2025)
1+
## v3.15.0-dev (Dec 11, 2025)
2+
- Fix multiple set-cookie headers [#3508](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3508)
23

34
## v3.14.0 (Nov 04, 2025)
45
- Replace aws-serverless-express with @h4ad/serverless-adapter [#3325](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3325)

packages/pwa-kit-runtime/src/utils/ssr-server.test.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,86 @@ describe('processLambdaResponse', () => {
621621
testCase.validate(res.headers)
622622
})
623623
})
624+
625+
test('preserves single cookie in multiValueHeaders', () => {
626+
const response = {
627+
multiValueHeaders: {
628+
'set-cookie': ['test-cookie=test-value; Path=/']
629+
}
630+
}
631+
const event = {}
632+
const result = processLambdaResponse(response, event)
633+
634+
expect(result.multiValueHeaders).toBeDefined()
635+
expect(result.multiValueHeaders['set-cookie']).toEqual(['test-cookie=test-value; Path=/'])
636+
expect(result.headers['set-cookie']).toBeUndefined()
637+
})
638+
639+
test('preserves multiple cookies in multiValueHeaders', () => {
640+
const response = {
641+
multiValueHeaders: {
642+
'set-cookie': ['test-cookie=test-value; Path=/', 'test-value2', 'test-value3']
643+
}
644+
}
645+
const event = {}
646+
const result = processLambdaResponse(response, event)
647+
648+
expect(result.multiValueHeaders).toBeDefined()
649+
expect(result.multiValueHeaders['set-cookie']).toEqual([
650+
'test-cookie=test-value; Path=/',
651+
'test-value2',
652+
'test-value3'
653+
])
654+
expect(result.headers['set-cookie']).toBeUndefined()
655+
})
656+
657+
test('removes set-cookie from headers when cookies are in multiValueHeaders', () => {
658+
const response = {
659+
multiValueHeaders: {
660+
'set-cookie': ['test-cookie=test-value; Path=/'],
661+
'Accept-Language': ['en-US']
662+
}
663+
}
664+
const event = {}
665+
const result = processLambdaResponse(response, event)
666+
667+
// set-cookie should be removed from headers
668+
expect(result.headers['set-cookie']).toBeUndefined()
669+
// Other headers should still be present
670+
expect(result.headers['accept-language']).toBe('en-US')
671+
// Cookies should be in multiValueHeaders
672+
expect(result.multiValueHeaders['set-cookie']).toEqual(['test-cookie=test-value; Path=/'])
673+
})
674+
675+
test('does not add multiValueHeaders when no cookies are present', () => {
676+
const response = {
677+
multiValueHeaders: {
678+
'Accept-Language': ['en-US']
679+
}
680+
}
681+
const event = {}
682+
const result = processLambdaResponse(response, event)
683+
684+
// multiValueHeaders should be an empty object when no cookies are present
685+
expect(result.multiValueHeaders).toEqual({})
686+
expect(result.headers['accept-language']).toBe('en-US')
687+
})
688+
689+
test('handles cookies with correlation ID', () => {
690+
const response = {
691+
multiValueHeaders: {
692+
'set-cookie': ['test-cookie=test-value; Path=/']
693+
}
694+
}
695+
const event = {
696+
headers: {'x-correlation-id': 'e46cd109-39b7-4173-963e-2c5de78ba087'}
697+
}
698+
const result = processLambdaResponse(response, event)
699+
700+
expect(result.headers['x-correlation-id']).toBe('e46cd109-39b7-4173-963e-2c5de78ba087')
701+
expect(result.multiValueHeaders['set-cookie']).toEqual(['test-cookie=test-value; Path=/'])
702+
expect(result.headers['set-cookie']).toBeUndefined()
703+
})
624704
})
625705

626706
describe('processExpressResponse', () => {

packages/pwa-kit-runtime/src/utils/ssr-server/process-lambda-response.js

Lines changed: 69 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,32 +8,88 @@
88
import {CONTENT_TYPE, X_ORIGINAL_CONTENT_TYPE} from '../../ssr/server/constants'
99
import {getFlattenedHeadersMap} from '@h4ad/serverless-adapter'
1010

11+
/**
12+
* Processes multi-value headers by flattening them into a single headers object,
13+
* while preserving cookies in multiValueHeaders format.
14+
* Cookies are extracted from multiValueHeaders and kept separate, as they need
15+
* to remain in multiValueHeaders format for AWS Lambda responses.
16+
* Also restores the original content type if it was temporarily replaced
17+
* (e.g., when binary content encoding is used).
18+
*
19+
* @private
20+
* @param {Object} multiValueHeaders - An object containing multi-value headers,
21+
* where each key maps to an array of header values (e.g., {'set-cookie': ['cookie1', 'cookie2']})
22+
* @returns {Object} An object containing:
23+
* - headers: A flattened headers object with all headers joined by commas,
24+
* excluding set-cookie headers, with a 'date' header added and content-type
25+
* restored from x-original-content-type if present
26+
* - multiValueHeaders: An object containing only the 'set-cookie' header if cookies were present,
27+
* otherwise an empty object
28+
*/
29+
export const processHeaders = (multiValueHeaders) => {
30+
const cookies = multiValueHeaders?.['set-cookie']
31+
let headers = getFlattenedHeadersMap(multiValueHeaders || {}, ',', true)
32+
headers['date'] = new Date().toUTCString()
33+
let newMultiValueHeaders = {}
34+
35+
// Only allow set-cookie headers to be in multiValueHeaders
36+
// to return multiple set-cookie headers instead of a single set-cookie header with multiple values
37+
if (cookies) {
38+
delete headers['set-cookie']
39+
newMultiValueHeaders = {'set-cookie': cookies}
40+
}
41+
42+
// If the response contains an X_ORIGINAL_CONTENT_TYPE header,
43+
// then replace the current CONTENT_TYPE header with it.
44+
const originalContentType = headers[X_ORIGINAL_CONTENT_TYPE]
45+
if (originalContentType) {
46+
headers[CONTENT_TYPE] = originalContentType
47+
delete headers[X_ORIGINAL_CONTENT_TYPE]
48+
}
49+
50+
return {headers, multiValueHeaders: newMultiValueHeaders}
51+
}
52+
53+
/**
54+
* Processes a Lambda response by converting multi-value headers to a flattened format,
55+
* preserving cookies in multiValueHeaders, and adding correlation ID from the event.
56+
*
57+
* This function is used to transform Express response headers into the format
58+
* expected by AWS Lambda/API Gateway, ensuring that:
59+
* - Multi-value headers are properly flattened (except for set-cookie)
60+
* - Cookies remain in multiValueHeaders format for proper handling
61+
* - Correlation IDs are propagated from the request to the response
62+
* - Original content types are restored when they were temporarily replaced
63+
* (handled by processHeaders)
64+
*
65+
* @param {Object} response - The Lambda response object containing multiValueHeaders
66+
* @param {Object} event - The Lambda event object containing request headers
67+
* @param {Object} [event.headers] - Request headers from the Lambda event
68+
* @param {string} [event.headers['x-correlation-id']] - Correlation ID to add to response headers
69+
* @returns {Object} The processed response object with:
70+
* - headers: Flattened headers object (all headers joined by commas, except set-cookie)
71+
* - multiValueHeaders: Object containing set-cookie headers if present
72+
* - All other properties from the original response object
73+
*/
1174
export const processLambdaResponse = (response, event) => {
1275
if (!response) return response
1376

1477
// Retrieve the correlation ID from the event headers
1578
const correlationId = event.headers?.['x-correlation-id']
1679

17-
let joinedHeaders = getFlattenedHeadersMap(response.multiValueHeaders || {}, ',', true)
18-
joinedHeaders['date'] = new Date().toUTCString()
19-
delete response['multiValueHeaders']
80+
// The response only has multiValueHeaders but the updated response needs joined single value headers
81+
// except for the set-cookie headers
82+
let {headers, multiValueHeaders} = processHeaders(response.multiValueHeaders)
2083

2184
// Add the correlation ID to the response headers if it exists
2285
if (correlationId) {
23-
joinedHeaders['x-correlation-id'] = correlationId
24-
}
25-
26-
// If the response contains an X_ORIGINAL_CONTENT_TYPE header,
27-
// then replace the current CONTENT_TYPE header with it.
28-
const originalContentType = joinedHeaders[X_ORIGINAL_CONTENT_TYPE]
29-
if (originalContentType) {
30-
joinedHeaders[CONTENT_TYPE] = originalContentType
31-
delete joinedHeaders[X_ORIGINAL_CONTENT_TYPE]
86+
headers['x-correlation-id'] = correlationId
3287
}
3388

3489
const result = {
3590
...response,
36-
headers: joinedHeaders
91+
headers,
92+
multiValueHeaders
3793
}
3894
return result
3995
}

packages/template-mrt-reference-app/app/ssr.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,13 @@ const cookieTest = async (req, res) => {
194194
res.json(jsonFromRequest(req))
195195
}
196196

197+
const multiCookies = async (req, res) => {
198+
res.cookie('test-cookie', 'test-value')
199+
res.append('Set-Cookie', 'test-value2')
200+
res.append('Set-Cookie', 'test-value3')
201+
res.json(jsonFromRequest(req))
202+
}
203+
197204
/**
198205
* Express handler that sets single and multi-value response headers
199206
* and returns a JSON response with diagnostic values.
@@ -369,6 +376,7 @@ const {handler, app, server} = runtime.createHandler(options, (app) => {
369376
app.get('/cache/:duration(\\d+)', cacheTest)
370377
app.get('/memtest', memoryTest)
371378
app.get('/cookie', cookieTest)
379+
app.get('/multi-cookies', multiCookies)
372380
app.get('/headers', headerTest)
373381
app.get('/isolation', isolationTests)
374382
app.get('/set-response-headers', responseHeadersTest)

packages/template-mrt-reference-app/app/ssr.test.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ describe('server', () => {
5858
['/exception', 500, 'text/html; charset=utf-8'],
5959
['/cache', 200, 'application/json; charset=utf-8'],
6060
['/cookie', 200, 'application/json; charset=utf-8'],
61+
['/multi-cookies', 200, 'application/json; charset=utf-8'],
6162
['/set-response-headers', 200, 'application/json; charset=utf-8'],
6263
['/isolation', 200, 'application/json; charset=utf-8'],
6364
['/memtest', 200, 'application/json; charset=utf-8']
@@ -88,6 +89,21 @@ describe('server', () => {
8889
.expect('set-cookie', 'test-cookie=test-value; Path=/')
8990
})
9091

92+
test('Path "/multi-cookies" sets multiple cookies', async () => {
93+
const response = await request(app).get('/multi-cookies')
94+
const setCookieHeaders = response.headers['set-cookie']
95+
expect(setCookieHeaders).toBeDefined()
96+
expect(Array.isArray(setCookieHeaders)).toBe(true)
97+
expect(setCookieHeaders.length).toBeGreaterThanOrEqual(3)
98+
// Check that the first cookie is set using res.cookie (includes Path=/)
99+
expect(setCookieHeaders.some((cookie) => cookie.includes('test-cookie=test-value'))).toBe(
100+
true
101+
)
102+
// Check that the appended cookies are present
103+
expect(setCookieHeaders.some((cookie) => cookie.includes('test-value2'))).toBe(true)
104+
expect(setCookieHeaders.some((cookie) => cookie.includes('test-value3'))).toBe(true)
105+
})
106+
91107
test('Path "/set-response-headers" sets response header', () => {
92108
return request(app)
93109
.get('/set-response-headers?header1=value1&header2=test-value')

0 commit comments

Comments
 (0)