undici 7.28.0 → 7.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -218,17 +218,62 @@ class MemoryCacheStore extends EventEmitter {
218
218
  }
219
219
 
220
220
  function findEntry (key, entries, now) {
221
- return entries.find((entry) => (
222
- entry.deleteAt > now &&
223
- entry.method === key.method &&
224
- (entry.vary == null || Object.keys(entry.vary).every(headerName => {
225
- if (entry.vary[headerName] === null) {
226
- return key.headers[headerName] === undefined
221
+ for (let i = 0; i < entries.length; i++) {
222
+ const entry = entries[i]
223
+ if (
224
+ entry.deleteAt > now &&
225
+ entry.method === key.method &&
226
+ varyMatches(key, entry)
227
+ ) {
228
+ return entry
229
+ }
230
+ }
231
+ }
232
+
233
+ function varyMatches (key, entry) {
234
+ if (entry.vary == null) {
235
+ return true
236
+ }
237
+
238
+ for (const headerName in entry.vary) {
239
+ if (Object.hasOwn(entry.vary, headerName) && !headerValueEquals(key.headers?.[headerName], entry.vary[headerName])) {
240
+ return false
241
+ }
242
+ }
243
+
244
+ return true
245
+ }
246
+
247
+ /**
248
+ * @param {string|string[]|null|undefined} lhs
249
+ * @param {string|string[]|null|undefined} rhs
250
+ * @returns {boolean}
251
+ */
252
+ function headerValueEquals (lhs, rhs) {
253
+ if (lhs == null && rhs == null) {
254
+ return true
255
+ }
256
+
257
+ if ((lhs == null && rhs != null) ||
258
+ (lhs != null && rhs == null)) {
259
+ return false
260
+ }
261
+
262
+ if (Array.isArray(lhs) && Array.isArray(rhs)) {
263
+ if (lhs.length !== rhs.length) {
264
+ return false
265
+ }
266
+
267
+ for (let i = 0; i < lhs.length; i++) {
268
+ if (lhs[i] !== rhs[i]) {
269
+ return false
227
270
  }
271
+ }
272
+
273
+ return true
274
+ }
228
275
 
229
- return entry.vary[headerName] === key.headers[headerName]
230
- }))
231
- ))
276
+ return lhs === rhs
232
277
  }
233
278
 
234
279
  module.exports = MemoryCacheStore
@@ -454,7 +454,13 @@ function headerValueEquals (lhs, rhs) {
454
454
  return false
455
455
  }
456
456
 
457
- return lhs.every((x, i) => x === rhs[i])
457
+ for (let i = 0; i < lhs.length; i++) {
458
+ if (lhs[i] !== rhs[i]) {
459
+ return false
460
+ }
461
+ }
462
+
463
+ return true
458
464
  }
459
465
 
460
466
  return lhs === rhs
@@ -390,7 +390,13 @@ function processHeader (request, key, val) {
390
390
  } else if (typeof val[i] === 'object') {
391
391
  throw new InvalidArgumentError(`invalid ${key} header`)
392
392
  } else {
393
- arr.push(`${val[i]}`)
393
+ // Coerce primitives (and reject unsafe coercions such as functions
394
+ // with a crafted toString/Symbol.toPrimitive).
395
+ const str = `${val[i]}`
396
+ if (!isValidHeaderValue(str)) {
397
+ throw new InvalidArgumentError(`invalid ${key} header`)
398
+ }
399
+ arr.push(str)
394
400
  }
395
401
  }
396
402
  val = arr
@@ -401,7 +407,12 @@ function processHeader (request, key, val) {
401
407
  } else if (val === null) {
402
408
  val = ''
403
409
  } else {
410
+ // Coerce primitives (and reject unsafe coercions such as functions
411
+ // with a crafted toString/Symbol.toPrimitive).
404
412
  val = `${val}`
413
+ if (!isValidHeaderValue(val)) {
414
+ throw new InvalidArgumentError(`invalid ${key} header`)
415
+ }
405
416
  }
406
417
 
407
418
  if (headerName === 'host') {
@@ -10,6 +10,7 @@ const {
10
10
  RequestContentLengthMismatchError,
11
11
  ResponseContentLengthMismatchError,
12
12
  RequestAbortedError,
13
+ InvalidArgumentError,
13
14
  HeadersTimeoutError,
14
15
  HeadersOverflowError,
15
16
  SocketError,
@@ -1134,8 +1135,16 @@ function writeH1 (client, request) {
1134
1135
  }
1135
1136
  body = bodyStream.stream
1136
1137
  contentLength = bodyStream.length
1137
- } else if (util.isBlobLike(body) && request.contentType == null && body.type) {
1138
- headers.push('content-type', body.type)
1138
+ } else if (util.isBlobLike(body) && request.contentType == null) {
1139
+ const contentType = body.type
1140
+ if (contentType) {
1141
+ const contentTypeValue = `${contentType}`
1142
+ if (!util.isValidHeaderValue(contentTypeValue)) {
1143
+ util.errorRequest(client, request, new InvalidArgumentError('invalid content-type header'))
1144
+ return false
1145
+ }
1146
+ headers.push('content-type', contentTypeValue)
1147
+ }
1139
1148
  }
1140
1149
 
1141
1150
  if (body && typeof body.read === 'function') {
@@ -3,7 +3,10 @@
3
3
  const util = require('../core/util')
4
4
  const {
5
5
  parseCacheControlHeader,
6
+ hasInvalidCacheControlDirective,
6
7
  parseVaryHeader,
8
+ hasVaryStar,
9
+ isInvalidOrWildcardVaryHeader,
7
10
  isEtagUsable
8
11
  } = require('../util/cache')
9
12
  const { parseHttpDate } = require('../util/date.js')
@@ -26,6 +29,92 @@ const NOT_UNDERSTOOD_STATUS_CODES = [
26
29
 
27
30
  const MAX_RESPONSE_AGE = 2147483647000
28
31
 
32
+ function trimOWS (value) {
33
+ return value.replace(/^[\t ]+|[\t ]+$/g, '')
34
+ }
35
+
36
+ function arrayIncludes (array, value) {
37
+ for (let i = 0; i < array.length; i++) {
38
+ if (array[i] === value) {
39
+ return true
40
+ }
41
+ }
42
+
43
+ return false
44
+ }
45
+
46
+ function appendConnectionHeaderTokens (headersToRemove, connectionHeader) {
47
+ const values = Array.isArray(connectionHeader) ? connectionHeader : [connectionHeader]
48
+
49
+ for (let i = 0; i < values.length; i++) {
50
+ const tokens = values[i].split(',')
51
+ for (let j = 0; j < tokens.length; j++) {
52
+ headersToRemove.push(trimOWS(tokens[j]).toLowerCase())
53
+ }
54
+ }
55
+ }
56
+
57
+ function getSameOriginPath (cacheKey, location) {
58
+ if (typeof location !== 'string') {
59
+ return undefined
60
+ }
61
+
62
+ let originUrl
63
+ let requestUrl
64
+ let locationUrl
65
+ try {
66
+ originUrl = new URL(cacheKey.origin)
67
+ requestUrl = new URL(cacheKey.path, originUrl)
68
+ locationUrl = new URL(location, requestUrl)
69
+ } catch {
70
+ return undefined
71
+ }
72
+
73
+ if (locationUrl.origin !== originUrl.origin) {
74
+ return undefined
75
+ }
76
+
77
+ return locationUrl.pathname + locationUrl.search
78
+ }
79
+
80
+ function deleteCachedUri (store, cacheKey, path) {
81
+ deleteCachedValue(store, {
82
+ ...cacheKey,
83
+ path
84
+ })
85
+
86
+ for (let i = 0; i < util.safeHTTPMethods.length; i++) {
87
+ const method = util.safeHTTPMethods[i]
88
+ if (method !== cacheKey.method) {
89
+ deleteCachedValue(store, {
90
+ ...cacheKey,
91
+ method,
92
+ path
93
+ })
94
+ }
95
+ }
96
+ }
97
+
98
+ function deleteLocationTargets (store, cacheKey, headerValue) {
99
+ if (headerValue === undefined) {
100
+ return
101
+ }
102
+
103
+ const values = Array.isArray(headerValue) ? headerValue : [headerValue]
104
+ for (let i = 0; i < values.length; i++) {
105
+ const path = getSameOriginPath(cacheKey, values[i])
106
+ if (path !== undefined) {
107
+ deleteCachedUri(store, cacheKey, path)
108
+ }
109
+ }
110
+ }
111
+
112
+ function invalidateUnsafeRequest (store, cacheKey, resHeaders) {
113
+ deleteCachedUri(store, cacheKey, cacheKey.path)
114
+ deleteLocationTargets(store, cacheKey, resHeaders.location)
115
+ deleteLocationTargets(store, cacheKey, resHeaders['content-location'])
116
+ }
117
+
29
118
  /**
30
119
  * @typedef {import('../../types/dispatcher.d.ts').default.DispatchHandler} DispatchHandler
31
120
  *
@@ -107,28 +196,28 @@ class CacheHandler {
107
196
  const handler = this
108
197
 
109
198
  if (
110
- !util.safeHTTPMethods.includes(this.#cacheKey.method) &&
199
+ !arrayIncludes(util.safeHTTPMethods, this.#cacheKey.method) &&
111
200
  statusCode >= 200 &&
112
201
  statusCode <= 399
113
202
  ) {
114
203
  // Successful response to an unsafe method, delete it from cache
115
204
  // https://www.rfc-editor.org/rfc/rfc9111.html#name-invalidating-stored-response
116
- try {
117
- this.#store.delete(this.#cacheKey)?.catch?.(noop)
118
- } catch {
119
- // Fail silently
120
- }
205
+ invalidateUnsafeRequest(this.#store, this.#cacheKey, resHeaders)
121
206
  return downstreamOnHeaders()
122
207
  }
123
208
 
124
209
  const cacheControlHeader = resHeaders['cache-control']
125
- const heuristicallyCacheable = resHeaders['last-modified'] && HEURISTICALLY_CACHEABLE_STATUS_CODES.includes(statusCode)
210
+ const heuristicallyCacheable = resHeaders['last-modified'] && arrayIncludes(HEURISTICALLY_CACHEABLE_STATUS_CODES, statusCode)
126
211
  if (
127
212
  !cacheControlHeader &&
128
213
  !resHeaders['expires'] &&
129
214
  !heuristicallyCacheable &&
130
215
  !this.#cacheByDefault
131
216
  ) {
217
+ if (statusCode === 304 && resHeaders.vary && isInvalidOrWildcardVaryHeader(resHeaders.vary)) {
218
+ deleteCachedValue(this.#store, this.#cacheKey)
219
+ }
220
+
132
221
  // Don't have anything to tell us this response is cachable and we're not
133
222
  // caching by default
134
223
  return downstreamOnHeaders()
@@ -136,31 +225,46 @@ class CacheHandler {
136
225
 
137
226
  const cacheControlDirectives = cacheControlHeader ? parseCacheControlHeader(cacheControlHeader) : {}
138
227
  if (!canCacheResponse(this.#cacheType, statusCode, resHeaders, cacheControlDirectives, this.#cacheKey.headers)) {
228
+ if (statusCode === 304 && (cacheControlHeader || revalidationResponseDisallowsCachedReuse(this.#cacheType, resHeaders, cacheControlDirectives))) {
229
+ deleteCachedValue(this.#store, this.#cacheKey)
230
+ }
231
+
139
232
  return downstreamOnHeaders()
140
233
  }
141
234
 
142
235
  const now = Date.now()
143
- const resAge = resHeaders.age ? getAge(resHeaders.age) : undefined
144
- if (resAge && resAge >= MAX_RESPONSE_AGE) {
236
+ const resAge = Object.hasOwn(resHeaders, 'age') ? getAge(resHeaders.age) : undefined
237
+ if (resAge !== undefined && resAge >= MAX_RESPONSE_AGE) {
145
238
  // Response considered stale
239
+ deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey)
146
240
  return downstreamOnHeaders()
147
241
  }
148
242
 
149
- const resDate = typeof resHeaders.date === 'string'
150
- ? parseHttpDate(resHeaders.date)
151
- : undefined
243
+ const resDate = Object.hasOwn(resHeaders, 'date') ? getDate(resHeaders.date) : undefined
244
+ if (resDate === null) {
245
+ deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey)
246
+ return downstreamOnHeaders()
247
+ }
248
+
249
+ const apparentAge = resDate ? Math.max(0, now - resDate.getTime()) : 0
250
+ const currentAge = Math.max(apparentAge, resAge ?? 0)
152
251
 
153
252
  const staleAt =
154
253
  determineStaleAt(this.#cacheType, now, resAge, resHeaders, resDate, cacheControlDirectives) ??
155
254
  this.#cacheByDefault
156
- if (staleAt === undefined || (resAge && resAge > staleAt)) {
255
+ if (staleAt === undefined || currentAge >= staleAt) {
256
+ if (cacheControlHeader || staleAt !== undefined) {
257
+ deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey)
258
+ }
259
+
157
260
  return downstreamOnHeaders()
158
261
  }
159
262
 
160
- const baseTime = resDate ? resDate.getTime() : now
263
+ const baseTime = now - currentAge
161
264
  const absoluteStaleAt = staleAt + baseTime
162
265
  if (now >= absoluteStaleAt) {
163
266
  // Response is already stale
267
+ deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey)
164
268
  return downstreamOnHeaders()
165
269
  }
166
270
 
@@ -173,7 +277,8 @@ class CacheHandler {
173
277
  }
174
278
  }
175
279
 
176
- const deleteAt = determineDeleteAt(baseTime, cacheControlDirectives, absoluteStaleAt)
280
+ const cachedAt = baseTime
281
+ const deleteAt = determineDeleteAt(baseTime, now, cacheControlDirectives, absoluteStaleAt)
177
282
  const strippedHeaders = stripNecessaryHeaders(resHeaders, cacheControlDirectives)
178
283
 
179
284
  /**
@@ -185,7 +290,7 @@ class CacheHandler {
185
290
  headers: strippedHeaders,
186
291
  vary: varyDirectives,
187
292
  cacheControlDirectives,
188
- cachedAt: resAge ? now - resAge : now,
293
+ cachedAt,
189
294
  staleAt: absoluteStaleAt,
190
295
  deleteAt
191
296
  }
@@ -203,6 +308,7 @@ class CacheHandler {
203
308
  value.statusCode = cachedValue.statusCode
204
309
  value.statusMessage = cachedValue.statusMessage
205
310
  value.etag = cachedValue.etag
311
+ value.vary = varyDirectives ?? cachedValue.vary
206
312
  value.headers = { ...cachedValue.headers, ...strippedHeaders }
207
313
 
208
314
  downstreamOnHeaders()
@@ -333,6 +439,36 @@ class CacheHandler {
333
439
  }
334
440
  }
335
441
 
442
+ /**
443
+ * @param {import('../../types/cache-interceptor.d.ts').default.CacheStore} store
444
+ * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey
445
+ */
446
+ function deleteCachedValue (store, cacheKey) {
447
+ try {
448
+ store.delete(cacheKey)?.catch?.(noop)
449
+ } catch {
450
+ // Fail silently
451
+ }
452
+ }
453
+
454
+ function deleteCachedValueIfNotModified (statusCode, store, cacheKey) {
455
+ if (statusCode === 304) {
456
+ deleteCachedValue(store, cacheKey)
457
+ }
458
+ }
459
+
460
+ /**
461
+ * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType
462
+ * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders
463
+ * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives
464
+ * @returns {boolean}
465
+ */
466
+ function revalidationResponseDisallowsCachedReuse (cacheType, resHeaders, cacheControlDirectives) {
467
+ return cacheControlDirectives['no-store'] === true ||
468
+ (cacheType === 'shared' && cacheControlDirectives.private === true) ||
469
+ (resHeaders.vary ? isInvalidOrWildcardVaryHeader(resHeaders.vary) : false)
470
+ }
471
+
336
472
  /**
337
473
  * @see https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen
338
474
  *
@@ -344,12 +480,12 @@ class CacheHandler {
344
480
  */
345
481
  function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirectives, reqHeaders) {
346
482
  // Status code must be final and understood.
347
- if (statusCode < 200 || NOT_UNDERSTOOD_STATUS_CODES.includes(statusCode)) {
483
+ if (statusCode < 200 || arrayIncludes(NOT_UNDERSTOOD_STATUS_CODES, statusCode)) {
348
484
  return false
349
485
  }
350
486
  // Responses with neither status codes that are heuristically cacheable, nor "explicit enough" caching
351
487
  // directives, are not cacheable. "Explicit enough": see https://www.rfc-editor.org/rfc/rfc9111.html#section-3
352
- if (!HEURISTICALLY_CACHEABLE_STATUS_CODES.includes(statusCode) && !resHeaders['expires'] &&
488
+ if (!arrayIncludes(HEURISTICALLY_CACHEABLE_STATUS_CODES, statusCode) && !resHeaders['expires'] &&
353
489
  !cacheControlDirectives.public &&
354
490
  cacheControlDirectives['max-age'] === undefined &&
355
491
  // RFC 9111: a private response directive, if the cache is not shared
@@ -368,12 +504,12 @@ function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirect
368
504
  }
369
505
 
370
506
  // https://www.rfc-editor.org/rfc/rfc9111.html#section-4.1-5
371
- if (resHeaders.vary?.includes('*')) {
507
+ if (resHeaders.vary && hasVaryStar(resHeaders.vary)) {
372
508
  return false
373
509
  }
374
510
 
375
511
  // https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen
376
- if (reqHeaders?.authorization) {
512
+ if (reqHeaders != null && Object.hasOwn(reqHeaders, 'authorization')) {
377
513
  if (
378
514
  !cacheControlDirectives.public &&
379
515
  !cacheControlDirectives['s-maxage'] &&
@@ -388,14 +524,14 @@ function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirect
388
524
 
389
525
  if (
390
526
  Array.isArray(cacheControlDirectives['no-cache']) &&
391
- cacheControlDirectives['no-cache'].includes('authorization')
527
+ arrayIncludes(cacheControlDirectives['no-cache'], 'authorization')
392
528
  ) {
393
529
  return false
394
530
  }
395
531
 
396
532
  if (
397
533
  Array.isArray(cacheControlDirectives['private']) &&
398
- cacheControlDirectives['private'].includes('authorization')
534
+ arrayIncludes(cacheControlDirectives['private'], 'authorization')
399
535
  ) {
400
536
  return false
401
537
  }
@@ -404,14 +540,51 @@ function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirect
404
540
  return true
405
541
  }
406
542
 
543
+ /**
544
+ * @param {string | string[]} dateHeader
545
+ * @returns {Date | null | undefined}
546
+ */
547
+ function getDate (dateHeader) {
548
+ let dateValue = dateHeader
549
+ if (Array.isArray(dateValue)) {
550
+ if (dateValue.length !== 1) {
551
+ return null
552
+ }
553
+
554
+ dateValue = dateValue[0]
555
+ }
556
+
557
+ if (typeof dateValue !== 'string') {
558
+ return null
559
+ }
560
+
561
+ return parseHttpDate(dateValue)
562
+ }
563
+
407
564
  /**
408
565
  * @param {string | string[]} ageHeader
409
566
  * @returns {number | undefined}
410
567
  */
411
568
  function getAge (ageHeader) {
412
- const age = parseInt(Array.isArray(ageHeader) ? ageHeader[0] : ageHeader)
569
+ let ageValue = ageHeader
570
+ if (Array.isArray(ageValue)) {
571
+ if (ageValue.length !== 1) {
572
+ return MAX_RESPONSE_AGE
573
+ }
574
+
575
+ ageValue = ageValue[0]
576
+ }
413
577
 
414
- return isNaN(age) ? undefined : age * 1000
578
+ if (typeof ageValue !== 'string' || !/^[\t ]*[0-9]+[\t ]*$/.test(ageValue)) {
579
+ return MAX_RESPONSE_AGE
580
+ }
581
+
582
+ const age = BigInt(ageValue.replace(/^[\t ]+|[\t ]+$/g, ''))
583
+ if (age >= BigInt(MAX_RESPONSE_AGE / 1000)) {
584
+ return MAX_RESPONSE_AGE
585
+ }
586
+
587
+ return Number(age) * 1000
415
588
  }
416
589
 
417
590
  /**
@@ -429,43 +602,60 @@ function determineStaleAt (cacheType, now, age, resHeaders, responseDate, cacheC
429
602
  // Prioritize s-maxage since we're a shared cache
430
603
  // s-maxage > max-age > Expire
431
604
  // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.10-3
605
+ if (hasInvalidCacheControlDirective(cacheControlDirectives, 's-maxage')) {
606
+ return 0
607
+ }
608
+
432
609
  const sMaxAge = cacheControlDirectives['s-maxage']
433
610
  if (sMaxAge !== undefined) {
434
- return sMaxAge > 0 ? sMaxAge * 1000 : undefined
611
+ return sMaxAge * 1000
435
612
  }
436
613
  }
437
614
 
615
+ if (hasInvalidCacheControlDirective(cacheControlDirectives, 'max-age')) {
616
+ return 0
617
+ }
618
+
438
619
  const maxAge = cacheControlDirectives['max-age']
439
620
  if (maxAge !== undefined) {
440
- return maxAge > 0 ? maxAge * 1000 : undefined
621
+ return maxAge * 1000
441
622
  }
442
623
 
443
- if (typeof resHeaders.expires === 'string') {
624
+ if (Object.hasOwn(resHeaders, 'expires')) {
444
625
  // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3
626
+ if (typeof resHeaders.expires !== 'string') {
627
+ return 0
628
+ }
629
+
445
630
  const expiresDate = parseHttpDate(resHeaders.expires)
446
- if (expiresDate) {
447
- if (now >= expiresDate.getTime()) {
448
- return undefined
449
- }
631
+ if (!expiresDate) {
632
+ return 0
633
+ }
450
634
 
451
- if (responseDate) {
452
- if (responseDate >= expiresDate) {
453
- return undefined
454
- }
635
+ if (now >= expiresDate.getTime()) {
636
+ return 0
637
+ }
455
638
 
456
- if (age !== undefined && age > (expiresDate - responseDate)) {
457
- return undefined
458
- }
639
+ if (responseDate) {
640
+ if (responseDate >= expiresDate) {
641
+ return 0
642
+ }
643
+
644
+ const freshnessLifetime = expiresDate.getTime() - responseDate.getTime()
645
+ if (age !== undefined && age >= freshnessLifetime) {
646
+ return 0
459
647
  }
460
648
 
461
- return expiresDate.getTime() - now
649
+ return freshnessLifetime
462
650
  }
651
+
652
+ return expiresDate.getTime() - now
463
653
  }
464
654
 
465
655
  if (typeof resHeaders['last-modified'] === 'string') {
466
656
  // https://www.rfc-editor.org/rfc/rfc9111.html#name-calculating-heuristic-fresh
467
- const lastModified = new Date(resHeaders['last-modified'])
468
- if (isValidDate(lastModified)) {
657
+ const lastModified = parseHttpDate(resHeaders['last-modified'])
658
+ if (lastModified) {
469
659
  if (lastModified.getTime() >= now) {
470
660
  return undefined
471
661
  }
@@ -478,18 +668,19 @@ function determineStaleAt (cacheType, now, age, resHeaders, responseDate, cacheC
478
668
 
479
669
  if (cacheControlDirectives.immutable) {
480
670
  // https://www.rfc-editor.org/rfc/rfc8246.html#section-2.2
481
- return 31536000
671
+ return 31536000000
482
672
  }
483
673
 
484
674
  return undefined
485
675
  }
486
676
 
487
677
  /**
488
- * @param {number} now
678
+ * @param {number} baseTime
679
+ * @param {number} cachedAt
489
680
  * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives
490
681
  * @param {number} staleAt
491
682
  */
492
- function determineDeleteAt (now, cacheControlDirectives, staleAt) {
683
+ function determineDeleteAt (baseTime, cachedAt, cacheControlDirectives, staleAt) {
493
684
  let staleWhileRevalidate = -Infinity
494
685
  let staleIfError = -Infinity
495
686
  let immutable = -Infinity
@@ -503,15 +694,21 @@ function determineDeleteAt (now, cacheControlDirectives, staleAt) {
503
694
  }
504
695
 
505
696
  if (cacheControlDirectives.immutable && staleWhileRevalidate === -Infinity && staleIfError === -Infinity) {
506
- immutable = now + 31536000000
697
+ immutable = cachedAt + 31536000000
507
698
  }
508
699
 
509
700
  // When no stale directives or immutable flag, add a revalidation buffer
510
701
  // equal to the freshness lifetime so the entry survives past staleAt long
511
702
  // enough to be revalidated instead of silently disappearing.
703
+ //
704
+ // Response Date headers only have second precision, so baseTime can trail the
705
+ // actual cache insertion time by up to ~1s. Pad the buffer by that bounded
706
+ // skew so short-lived entries do not disappear exactly when they should be
707
+ // revalidated.
512
708
  if (staleWhileRevalidate === -Infinity && staleIfError === -Infinity && immutable === -Infinity) {
513
- const freshnessLifetime = staleAt - now
514
- return staleAt + freshnessLifetime
709
+ const freshnessLifetime = staleAt - baseTime
710
+ const datePrecisionPadding = Math.min(Math.max(cachedAt - baseTime, 0), 1000)
711
+ return staleAt + freshnessLifetime + datePrecisionPadding
515
712
  }
516
713
 
517
714
  return Math.max(staleAt, staleWhileRevalidate, staleIfError, immutable)
@@ -538,14 +735,7 @@ function stripNecessaryHeaders (resHeaders, cacheControlDirectives) {
538
735
  ]
539
736
 
540
737
  if (resHeaders['connection']) {
541
- if (Array.isArray(resHeaders['connection'])) {
542
- // connection: a
543
- // connection: b
544
- headersToRemove.push(...resHeaders['connection'].map(header => header.trim()))
545
- } else {
546
- // connection: a, b
547
- headersToRemove.push(...resHeaders['connection'].split(',').map(header => header.trim()))
548
- }
738
+ appendConnectionHeaderTokens(headersToRemove, resHeaders['connection'])
549
739
  }
550
740
 
551
741
  if (Array.isArray(cacheControlDirectives['no-cache'])) {
@@ -558,7 +748,7 @@ function stripNecessaryHeaders (resHeaders, cacheControlDirectives) {
558
748
 
559
749
  let strippedHeaders
560
750
  for (const headerName of headersToRemove) {
561
- if (resHeaders[headerName]) {
751
+ if (Object.hasOwn(resHeaders, headerName)) {
562
752
  strippedHeaders ??= { ...resHeaders }
563
753
  delete strippedHeaders[headerName]
564
754
  }
@@ -567,12 +757,4 @@ function stripNecessaryHeaders (resHeaders, cacheControlDirectives) {
567
757
  return strippedHeaders ?? resHeaders
568
758
  }
569
759
 
570
- /**
571
- * @param {Date} date
572
- * @returns {boolean}
573
- */
574
- function isValidDate (date) {
575
- return date instanceof Date && Number.isFinite(date.valueOf())
576
- }
577
-
578
760
  module.exports = CacheHandler
@@ -19,7 +19,7 @@ class CacheRevalidationHandler {
19
19
  #successful = false
20
20
 
21
21
  /**
22
- * @type {((boolean, any) => void) | null}
22
+ * @type {((success: boolean, context?: any, statusCode?: number, headers?: import('../../types/header.d.ts').IncomingHttpHeaders) => void) | null}
23
23
  */
24
24
  #callback
25
25
 
@@ -36,7 +36,7 @@ class CacheRevalidationHandler {
36
36
  #allowErrorStatusCodes
37
37
 
38
38
  /**
39
- * @param {(boolean) => void} callback Function to call if the cached value is valid
39
+ * @param {(success: boolean, context?: any, statusCode?: number, headers?: import('../../types/header.d.ts').IncomingHttpHeaders) => void} callback Function to call if the cached value is valid
40
40
  * @param {import('../../types/dispatcher.d.ts').default.DispatchHandlers} handler
41
41
  * @param {boolean} allowErrorStatusCodes
42
42
  */
@@ -71,7 +71,7 @@ class CacheRevalidationHandler {
71
71
  // https://datatracker.ietf.org/doc/html/rfc5861#section-4
72
72
  this.#successful = statusCode === 304 ||
73
73
  (this.#allowErrorStatusCodes && statusCode >= 500 && statusCode <= 504)
74
- this.#callback(this.#successful, this.#context)
74
+ this.#callback(this.#successful, this.#context, statusCode, headers)
75
75
  this.#callback = null
76
76
 
77
77
  if (this.#successful) {