Skip to content

Commit 0fd1520

Browse files
flakey5KhafraDev
andauthored
test: add cache testing suite (nodejs#3842)
* test: add cache testing suite Closes nodejs#3852 Closes nodejs#3869 Signed-off-by: flakey5 <[email protected]> * some cleanup Signed-off-by: flakey5 <[email protected]> * docs Signed-off-by: flakey5 <[email protected]> * Update test/cache-interceptor/cache-tests.mjs Co-authored-by: Khafra <[email protected]> * fetch Signed-off-by: flakey5 <[email protected]> --------- Signed-off-by: flakey5 <[email protected]> Co-authored-by: Khafra <[email protected]>
1 parent 1b58a51 commit 0fd1520

File tree

156 files changed

+69592
-65
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

156 files changed

+69592
-65
lines changed

docs/docs/api/Dispatcher.md

+2
Original file line numberDiff line numberDiff line change
@@ -1260,6 +1260,8 @@ The `cache` interceptor implements client-side response caching as described in
12601260
12611261
- `store` - The [`CacheStore`](/docs/docs/api/CacheStore.md) to store and retrieve responses from. Default is [`MemoryCacheStore`](/docs/docs/api/CacheStore.md#memorycachestore).
12621262
- `methods` - The [**safe** HTTP methods](https://www.rfc-editor.org/rfc/rfc9110#section-9.2.1) to cache the response of.
1263+
- `cacheByDefault` - The default expiration time to cache responses by if they don't have an explicit expiration. If this isn't present, responses without explicit expiration will not be cached. Default `undefined`.
1264+
- `type` - The type of cache for Undici to act as. Can be `shared` or `private`. Default `shared`.
12631265
12641266
## Instance Events
12651267

eslint.config.js

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ module.exports = [
77
ignores: [
88
'lib/llhttp',
99
'test/fixtures/wpt',
10+
'test/fixtures/cache-tests',
1011
'undici-fetch.js'
1112
],
1213
noJsx: true,

lib/cache/memory-cache-store.js

+1
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ class MemoryCacheStore {
8989
statusCode: entry.statusCode,
9090
headers: entry.headers,
9191
body: entry.body,
92+
vary: entry.vary ? entry.vary : undefined,
9293
etag: entry.etag,
9394
cacheControlDirectives: entry.cacheControlDirectives,
9495
cachedAt: entry.cachedAt,

lib/cache/sqlite-cache-store.js

+13-4
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ const { assertCacheKey, assertCacheValue } = require('../util/cache.js')
66

77
const VERSION = 3
88

9+
// 2gb
10+
const MAX_ENTRY_SIZE = 2 * 1000 * 1000 * 1000
11+
912
/**
1013
* @typedef {import('../../types/cache-interceptor.d.ts').default.CacheStore} CacheStore
1114
* @implements {CacheStore}
@@ -18,7 +21,7 @@ const VERSION = 3
1821
* } & import('../../types/cache-interceptor.d.ts').default.CacheValue} SqliteStoreValue
1922
*/
2023
module.exports = class SqliteCacheStore {
21-
#maxEntrySize = Infinity
24+
#maxEntrySize = MAX_ENTRY_SIZE
2225
#maxCount = Infinity
2326

2427
/**
@@ -78,6 +81,11 @@ module.exports = class SqliteCacheStore {
7881
) {
7982
throw new TypeError('SqliteCacheStore options.maxEntrySize must be a non-negative integer')
8083
}
84+
85+
if (opts.maxEntrySize > MAX_ENTRY_SIZE) {
86+
throw new TypeError('SqliteCacheStore options.maxEntrySize must be less than 2gb')
87+
}
88+
8189
this.#maxEntrySize = opts.maxEntrySize
8290
}
8391

@@ -227,6 +235,7 @@ module.exports = class SqliteCacheStore {
227235
statusMessage: value.statusMessage,
228236
headers: value.headers ? JSON.parse(value.headers) : undefined,
229237
etag: value.etag ? value.etag : undefined,
238+
vary: value.vary ?? undefined,
230239
cacheControlDirectives: value.cacheControlDirectives
231240
? JSON.parse(value.cacheControlDirectives)
232241
: undefined,
@@ -394,10 +403,10 @@ module.exports = class SqliteCacheStore {
394403
return undefined
395404
}
396405

397-
const vary = JSON.parse(value.vary)
406+
value.vary = JSON.parse(value.vary)
398407

399-
for (const header in vary) {
400-
if (!headerValueEquals(headers[header], vary[header])) {
408+
for (const header in value.vary) {
409+
if (!headerValueEquals(headers[header], value.vary[header])) {
401410
matches = false
402411
break
403412
}

lib/handler/cache-handler.js

+133-40
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,26 @@ const {
1010
function noop () {}
1111

1212
/**
13-
* @implements {import('../../types/dispatcher.d.ts').default.DispatchHandler}
13+
* @typedef {import('../../types/dispatcher.d.ts').default.DispatchHandler} DispatchHandler
14+
*
15+
* @implements {DispatchHandler}
1416
*/
1517
class CacheHandler {
1618
/**
1719
* @type {import('../../types/cache-interceptor.d.ts').default.CacheKey}
1820
*/
1921
#cacheKey
2022

23+
/**
24+
* @type {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions['type']}
25+
*/
26+
#cacheType
27+
28+
/**
29+
* @type {number | undefined}
30+
*/
31+
#cacheByDefault
32+
2133
/**
2234
* @type {import('../../types/cache-interceptor.d.ts').default.CacheStore}
2335
*/
@@ -38,8 +50,10 @@ class CacheHandler {
3850
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey
3951
* @param {import('../../types/dispatcher.d.ts').default.DispatchHandler} handler
4052
*/
41-
constructor ({ store }, cacheKey, handler) {
53+
constructor ({ store, type, cacheByDefault }, cacheKey, handler) {
4254
this.#store = store
55+
this.#cacheType = type
56+
this.#cacheByDefault = cacheByDefault
4357
this.#cacheKey = cacheKey
4458
this.#handler = handler
4559
}
@@ -83,24 +97,47 @@ class CacheHandler {
8397
}
8498

8599
const cacheControlHeader = headers['cache-control']
86-
if (!cacheControlHeader) {
100+
if (!cacheControlHeader && !headers['expires'] && !this.#cacheByDefault) {
87101
// Don't have the cache control header or the cache is full
88102
return downstreamOnHeaders()
89103
}
90104

91-
const cacheControlDirectives = parseCacheControlHeader(cacheControlHeader)
92-
if (!canCacheResponse(statusCode, headers, cacheControlDirectives)) {
105+
const cacheControlDirectives = cacheControlHeader ? parseCacheControlHeader(cacheControlHeader) : {}
106+
if (!canCacheResponse(this.#cacheType, statusCode, headers, cacheControlDirectives)) {
93107
return downstreamOnHeaders()
94108
}
95109

110+
const age = getAge(headers)
111+
96112
const now = Date.now()
97-
const staleAt = determineStaleAt(now, headers, cacheControlDirectives)
113+
const staleAt = determineStaleAt(this.#cacheType, now, headers, cacheControlDirectives) ?? this.#cacheByDefault
98114
if (staleAt) {
99-
const varyDirectives = this.#cacheKey.headers && headers.vary
100-
? parseVaryHeader(headers.vary, this.#cacheKey.headers)
101-
: undefined
102-
const deleteAt = determineDeleteAt(now, cacheControlDirectives, staleAt)
115+
let baseTime = now
116+
if (headers['date']) {
117+
const parsedDate = parseInt(headers['date'])
118+
const date = new Date(isNaN(parsedDate) ? headers['date'] : parsedDate)
119+
if (date instanceof Date && !isNaN(date)) {
120+
baseTime = date.getTime()
121+
}
122+
}
123+
124+
const absoluteStaleAt = staleAt + baseTime
125+
126+
if (now >= absoluteStaleAt || (age && age >= staleAt)) {
127+
// Response is already stale
128+
return downstreamOnHeaders()
129+
}
130+
131+
let varyDirectives
132+
if (this.#cacheKey.headers && headers.vary) {
133+
varyDirectives = parseVaryHeader(headers.vary, this.#cacheKey.headers)
134+
if (!varyDirectives) {
135+
// Parse error
136+
return downstreamOnHeaders()
137+
}
138+
}
103139

140+
const deleteAt = determineDeleteAt(cacheControlDirectives, absoluteStaleAt)
104141
const strippedHeaders = stripNecessaryHeaders(headers, cacheControlDirectives)
105142

106143
/**
@@ -112,8 +149,8 @@ class CacheHandler {
112149
headers: strippedHeaders,
113150
vary: varyDirectives,
114151
cacheControlDirectives,
115-
cachedAt: now,
116-
staleAt,
152+
cachedAt: age ? now - (age * 1000) : now,
153+
staleAt: absoluteStaleAt,
117154
deleteAt
118155
}
119156

@@ -129,6 +166,7 @@ class CacheHandler {
129166
.on('drain', () => controller.resume())
130167
.on('error', function () {
131168
// TODO (fix): Make error somehow observable?
169+
handler.#writeStream = undefined
132170
})
133171
.on('close', function () {
134172
if (handler.#writeStream === this) {
@@ -167,25 +205,29 @@ class CacheHandler {
167205
/**
168206
* @see https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen
169207
*
208+
* @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType
170209
* @param {number} statusCode
171210
* @param {Record<string, string | string[]>} headers
172211
* @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives
173212
*/
174-
function canCacheResponse (statusCode, headers, cacheControlDirectives) {
213+
function canCacheResponse (cacheType, statusCode, headers, cacheControlDirectives) {
175214
if (statusCode !== 200 && statusCode !== 307) {
176215
return false
177216
}
178217

179218
if (
180-
cacheControlDirectives.private === true ||
181219
cacheControlDirectives['no-cache'] === true ||
182220
cacheControlDirectives['no-store']
183221
) {
184222
return false
185223
}
186224

225+
if (cacheType === 'shared' && cacheControlDirectives.private === true) {
226+
return false
227+
}
228+
187229
// https://www.rfc-editor.org/rfc/rfc9111.html#section-4.1-5
188-
if (headers.vary === '*') {
230+
if (headers.vary?.includes('*')) {
189231
return false
190232
}
191233

@@ -214,60 +256,88 @@ function canCacheResponse (statusCode, headers, cacheControlDirectives) {
214256
}
215257

216258
/**
259+
* @param {Record<string, string | string[]>} headers
260+
* @returns {number | undefined}
261+
*/
262+
function getAge (headers) {
263+
if (!headers.age) {
264+
return undefined
265+
}
266+
267+
const age = parseInt(Array.isArray(headers.age) ? headers.age[0] : headers.age)
268+
if (isNaN(age) || age >= 2147483647) {
269+
return undefined
270+
}
271+
272+
return age
273+
}
274+
275+
/**
276+
* @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType
217277
* @param {number} now
218278
* @param {Record<string, string | string[]>} headers
219279
* @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives
220280
*
221281
* @returns {number | undefined} time that the value is stale at or undefined if it shouldn't be cached
222282
*/
223-
function determineStaleAt (now, headers, cacheControlDirectives) {
224-
// Prioritize s-maxage since we're a shared cache
225-
// s-maxage > max-age > Expire
226-
// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.10-3
227-
const sMaxAge = cacheControlDirectives['s-maxage']
228-
if (sMaxAge) {
229-
return now + (sMaxAge * 1000)
230-
}
231-
232-
if (cacheControlDirectives.immutable) {
233-
// https://www.rfc-editor.org/rfc/rfc8246.html#section-2.2
234-
return now + 31536000
283+
function determineStaleAt (cacheType, now, headers, cacheControlDirectives) {
284+
if (cacheType === 'shared') {
285+
// Prioritize s-maxage since we're a shared cache
286+
// s-maxage > max-age > Expire
287+
// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.10-3
288+
const sMaxAge = cacheControlDirectives['s-maxage']
289+
if (sMaxAge) {
290+
return sMaxAge * 1000
291+
}
235292
}
236293

237294
const maxAge = cacheControlDirectives['max-age']
238295
if (maxAge) {
239-
return now + (maxAge * 1000)
296+
return maxAge * 1000
240297
}
241298

242-
if (headers.expire && typeof headers.expire === 'string') {
299+
if (headers.expires && typeof headers.expires === 'string') {
243300
// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3
244-
const expiresDate = new Date(headers.expire)
301+
const expiresDate = new Date(headers.expires)
245302
if (expiresDate instanceof Date && Number.isFinite(expiresDate.valueOf())) {
246-
return now + (Date.now() - expiresDate.getTime())
303+
if (now >= expiresDate.getTime()) {
304+
return undefined
305+
}
306+
307+
return expiresDate.getTime() - now
247308
}
248309
}
249310

311+
if (cacheControlDirectives.immutable) {
312+
// https://www.rfc-editor.org/rfc/rfc8246.html#section-2.2
313+
return 31536000
314+
}
315+
250316
return undefined
251317
}
252318

253319
/**
254-
* @param {number} now
255320
* @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives
256321
* @param {number} staleAt
257322
*/
258-
function determineDeleteAt (now, cacheControlDirectives, staleAt) {
323+
function determineDeleteAt (cacheControlDirectives, staleAt) {
259324
let staleWhileRevalidate = -Infinity
260325
let staleIfError = -Infinity
326+
let immutable = -Infinity
261327

262328
if (cacheControlDirectives['stale-while-revalidate']) {
263-
staleWhileRevalidate = now + (cacheControlDirectives['stale-while-revalidate'] * 1000)
329+
staleWhileRevalidate = staleAt + (cacheControlDirectives['stale-while-revalidate'] * 1000)
264330
}
265331

266332
if (cacheControlDirectives['stale-if-error']) {
267-
staleIfError = now + (cacheControlDirectives['stale-if-error'] * 1000)
333+
staleIfError = staleAt + (cacheControlDirectives['stale-if-error'] * 1000)
268334
}
269335

270-
return Math.max(staleAt, staleWhileRevalidate, staleIfError)
336+
if (staleWhileRevalidate === -Infinity && staleIfError === -Infinity) {
337+
immutable = 31536000
338+
}
339+
340+
return Math.max(staleAt, staleWhileRevalidate, staleIfError, immutable)
271341
}
272342

273343
/**
@@ -277,7 +347,29 @@ function determineDeleteAt (now, cacheControlDirectives, staleAt) {
277347
* @returns {Record<string, string | string []>}
278348
*/
279349
function stripNecessaryHeaders (headers, cacheControlDirectives) {
280-
const headersToRemove = ['connection']
350+
const headersToRemove = [
351+
'connection',
352+
'proxy-authenticate',
353+
'proxy-authentication-info',
354+
'proxy-authorization',
355+
'proxy-connection',
356+
'te',
357+
'transfer-encoding',
358+
'upgrade',
359+
// We'll add age back when serving it
360+
'age'
361+
]
362+
363+
if (headers['connection']) {
364+
if (Array.isArray(headers['connection'])) {
365+
// connection: a
366+
// connection: b
367+
headersToRemove.push(...headers['connection'].map(header => header.trim()))
368+
} else {
369+
// connection: a, b
370+
headersToRemove.push(...headers['connection'].split(',').map(header => header.trim()))
371+
}
372+
}
281373

282374
if (Array.isArray(cacheControlDirectives['no-cache'])) {
283375
headersToRemove.push(...cacheControlDirectives['no-cache'])
@@ -288,12 +380,13 @@ function stripNecessaryHeaders (headers, cacheControlDirectives) {
288380
}
289381

290382
let strippedHeaders
291-
for (const headerName of Object.keys(headers)) {
292-
if (headersToRemove.includes(headerName)) {
383+
for (const headerName of headersToRemove) {
384+
if (headers[headerName]) {
293385
strippedHeaders ??= { ...headers }
294-
delete headers[headerName]
386+
delete strippedHeaders[headerName]
295387
}
296388
}
389+
297390
return strippedHeaders ?? headers
298391
}
299392

0 commit comments

Comments
 (0)