Skip to content

Commit 4269dab

Browse files
authoredFeb 16, 2025
fix: handle missing vary header values (#4031)
1 parent c14781c commit 4269dab

File tree

7 files changed

+166
-22
lines changed

7 files changed

+166
-22
lines changed
 

‎lib/cache/memory-cache-store.js

+7-1
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,13 @@ class MemoryCacheStore {
7979
const entry = this.#entries.get(topLevelKey)?.find((entry) => (
8080
entry.deleteAt > now &&
8181
entry.method === key.method &&
82-
(entry.vary == null || Object.keys(entry.vary).every(headerName => entry.vary[headerName] === key.headers?.[headerName]))
82+
(entry.vary == null || Object.keys(entry.vary).every(headerName => {
83+
if (entry.vary[headerName] === null) {
84+
return key.headers[headerName] === undefined
85+
}
86+
87+
return entry.vary[headerName] === key.headers[headerName]
88+
}))
8389
))
8490

8591
return entry == null

‎lib/cache/sqlite-cache-store.js

+10-11
Original file line numberDiff line numberDiff line change
@@ -411,10 +411,6 @@ module.exports = class SqliteCacheStore {
411411
let matches = true
412412

413413
if (value.vary) {
414-
if (!headers) {
415-
return undefined
416-
}
417-
418414
const vary = JSON.parse(value.vary)
419415

420416
for (const header in vary) {
@@ -440,18 +436,21 @@ module.exports = class SqliteCacheStore {
440436
* @returns {boolean}
441437
*/
442438
function headerValueEquals (lhs, rhs) {
439+
if (lhs == null && rhs == null) {
440+
return true
441+
}
442+
443+
if ((lhs == null && rhs != null) ||
444+
(lhs != null && rhs == null)) {
445+
return false
446+
}
447+
443448
if (Array.isArray(lhs) && Array.isArray(rhs)) {
444449
if (lhs.length !== rhs.length) {
445450
return false
446451
}
447452

448-
for (let i = 0; i < lhs.length; i++) {
449-
if (rhs.includes(lhs[i])) {
450-
return false
451-
}
452-
}
453-
454-
return true
453+
return lhs.every((x, i) => x === rhs[i])
455454
}
456455

457456
return lhs === rhs

‎lib/util/cache.js

+9-8
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,14 @@ function makeCacheKey (opts) {
2626
if (typeof key !== 'string' || typeof val !== 'string') {
2727
throw new Error('opts.headers is not a valid header map')
2828
}
29-
headers[key] = val
29+
headers[key.toLowerCase()] = val
3030
}
3131
} else if (typeof opts.headers === 'object') {
32-
headers = opts.headers
32+
headers = {}
33+
34+
for (const key of Object.keys(opts.headers)) {
35+
headers[key.toLowerCase()] = opts.headers[key]
36+
}
3337
} else {
3438
throw new Error('opts.headers is not an object')
3539
}
@@ -260,19 +264,16 @@ function parseVaryHeader (varyHeader, headers) {
260264
return headers
261265
}
262266

263-
const output = /** @type {Record<string, string | string[]>} */ ({})
267+
const output = /** @type {Record<string, string | string[] | null>} */ ({})
264268

265269
const varyingHeaders = typeof varyHeader === 'string'
266270
? varyHeader.split(',')
267271
: varyHeader
272+
268273
for (const header of varyingHeaders) {
269274
const trimmedHeader = header.trim().toLowerCase()
270275

271-
if (headers[trimmedHeader]) {
272-
output[trimmedHeader] = headers[trimmedHeader]
273-
} else {
274-
return undefined
275-
}
276+
output[trimmedHeader] = headers[trimmedHeader] ?? null
276277
}
277278

278279
return output

‎test/cache-interceptor/utils.js

+34
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,40 @@ describe('parseVaryHeader', () => {
214214
'another-one': '123'
215215
})
216216
})
217+
218+
test('handles missing headers with null', () => {
219+
const result = parseVaryHeader('Accept-Encoding, Authorization', {})
220+
deepStrictEqual(result, {
221+
'accept-encoding': null,
222+
authorization: null
223+
})
224+
})
225+
226+
test('handles mix of present and missing headers', () => {
227+
const result = parseVaryHeader('Accept-Encoding, Authorization', {
228+
authorization: 'example-value'
229+
})
230+
deepStrictEqual(result, {
231+
'accept-encoding': null,
232+
authorization: 'example-value'
233+
})
234+
})
235+
236+
test('handles array input', () => {
237+
const result = parseVaryHeader(['Accept-Encoding', 'Authorization'], {
238+
'accept-encoding': 'gzip'
239+
})
240+
deepStrictEqual(result, {
241+
'accept-encoding': 'gzip',
242+
authorization: null
243+
})
244+
})
245+
246+
test('preserves existing * behavior', () => {
247+
const headers = { accept: 'text/html' }
248+
const result = parseVaryHeader('*', headers)
249+
deepStrictEqual(result, headers)
250+
})
217251
})
218252

219253
describe('isEtagUsable', () => {

‎test/issue-3959.js

+65
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
const { describe, test, after } = require('node:test')
2+
const assert = require('node:assert')
3+
const { createServer } = require('node:http')
4+
const MemoryCacheStore = require('../lib/cache/memory-cache-store.js')
5+
const { request, Agent, setGlobalDispatcher } = require('..')
6+
const { interceptors } = require('..')
7+
8+
describe('Cache with Vary headers', () => {
9+
async function runCacheTest (store) {
10+
let requestCount = 0
11+
const server = createServer((req, res) => {
12+
requestCount++
13+
res.setHeader('Vary', 'Accept-Encoding')
14+
res.setHeader('Cache-Control', 'max-age=60')
15+
res.end(`Request count: ${requestCount}`)
16+
})
17+
18+
await new Promise(resolve => server.listen(0, resolve))
19+
const port = server.address().port
20+
const url = `http://localhost:${port}`
21+
22+
const agent = new Agent()
23+
setGlobalDispatcher(
24+
agent.compose(
25+
interceptors.cache({
26+
store,
27+
cacheByDefault: 1000,
28+
methods: ['GET']
29+
})
30+
)
31+
)
32+
33+
const res1 = await request(url)
34+
const body1 = await res1.body.text()
35+
assert.strictEqual(body1, 'Request count: 1')
36+
assert.strictEqual(requestCount, 1)
37+
38+
const res2 = await request(url)
39+
const body2 = await res2.body.text()
40+
assert.strictEqual(body2, 'Request count: 1')
41+
assert.strictEqual(requestCount, 1)
42+
43+
const res3 = await request(url, {
44+
headers: {
45+
'Accept-Encoding': 'gzip'
46+
}
47+
})
48+
const body3 = await res3.body.text()
49+
assert.strictEqual(body3, 'Request count: 2')
50+
assert.strictEqual(requestCount, 2)
51+
52+
await new Promise(resolve => server.close(resolve))
53+
}
54+
55+
test('should cache response with MemoryCacheStore when Vary header exists but request header is missing', async () => {
56+
await runCacheTest(new MemoryCacheStore())
57+
})
58+
59+
test('should cache response with SqliteCacheStore when Vary header exists but request header is missing', { skip: process.versions.node < '22' }, async () => {
60+
const SqliteCacheStore = require('../lib/cache/sqlite-cache-store.js')
61+
const sqliteStore = new SqliteCacheStore()
62+
await runCacheTest(sqliteStore)
63+
after(() => sqliteStore.close())
64+
})
65+
})

‎test/types/cache-interceptor.test-d.ts

+39
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,45 @@ expectNotAssignable<CacheInterceptor.CacheValue>({
7878
deleteAt: ''
7979
})
8080

81+
expectAssignable<CacheInterceptor.CacheValue>({
82+
statusCode: 200,
83+
statusMessage: 'OK',
84+
headers: {},
85+
vary: {
86+
'accept-encoding': null,
87+
authorization: 'example-value'
88+
},
89+
cachedAt: 0,
90+
staleAt: 0,
91+
deleteAt: 0
92+
})
93+
94+
expectAssignable<CacheInterceptor.CacheValue>({
95+
statusCode: 200,
96+
statusMessage: 'OK',
97+
headers: {},
98+
vary: {
99+
'accept-encoding': null,
100+
authorization: null
101+
},
102+
cachedAt: 0,
103+
staleAt: 0,
104+
deleteAt: 0
105+
})
106+
107+
expectNotAssignable<CacheInterceptor.CacheValue>({
108+
statusCode: 200,
109+
statusMessage: 'OK',
110+
headers: {},
111+
vary: {
112+
'accept-encoding': undefined,
113+
authorization: 'example-value'
114+
},
115+
cachedAt: 0,
116+
staleAt: 0,
117+
deleteAt: 0
118+
})
119+
81120
expectAssignable<CacheInterceptor.MemoryCacheStoreOpts>({})
82121
expectAssignable<CacheInterceptor.MemoryCacheStoreOpts>({
83122
maxSize: 0

‎types/cache-interceptor.d.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ declare namespace CacheHandler {
7070
statusCode: number
7171
statusMessage: string
7272
headers: Record<string, string | string[]>
73-
vary?: Record<string, string | string[]>
73+
vary?: Record<string, string | string[] | null>
7474
etag?: string
7575
cacheControlDirectives?: CacheControlDirectives
7676
cachedAt: number
@@ -88,7 +88,7 @@ declare namespace CacheHandler {
8888
statusCode: number
8989
statusMessage: string
9090
headers: Record<string, string | string[]>
91-
vary?: Record<string, string | string[]>
91+
vary?: Record<string, string | string[] | null>
9292
etag?: string
9393
body?: Readable | Iterable<Buffer> | AsyncIterable<Buffer> | Buffer | Iterable<string> | AsyncIterable<string> | string
9494
cacheControlDirectives: CacheControlDirectives,

0 commit comments

Comments
 (0)