@nxtedition/nxt-undici 7.4.1 → 7.4.3

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.
@@ -1,18 +1,142 @@
1
1
  import undici from '@nxtedition/undici'
2
+ import { stringify } from 'fast-querystring'
2
3
  import {
3
4
  DecoratorHandler,
4
- getFastNow,
5
5
  isStream,
6
6
  parseCacheControl,
7
7
  parseContentRange,
8
+ parseHeaders,
9
+ parseHttpDate,
8
10
  } from '../utils.js'
11
+ import { isHopByHop } from './proxy.js'
9
12
  import { SqliteCacheStore } from '../sqlite-cache-store.js'
10
13
 
11
14
  let DEFAULT_STORE = null
12
15
  const DEFAULT_MAX_ENTRY_SIZE = 128 * 1024
13
- const DEFAULT_MAX_ENTRY_TTL = 30 * 24 * 3600
16
+ const DEFAULT_MAX_ENTRY_TTL = 30 * 24 * 3600 // seconds
17
+ // RFC 8246 'immutable' has no lifetime of its own; this is the customary
18
+ // 1-year default, capped by maxEntryTTL below.
19
+ const IMMUTABLE_LIFETIME = 31556952 // seconds
14
20
  const NOOP = () => {}
15
21
 
22
+ /**
23
+ * Explicit (or opt-in heuristic) freshness lifetime in seconds, or null when
24
+ * the response carries no usable expiration information. RFC 9111 §4.2.1
25
+ * priority for a shared cache: s-maxage > max-age > Expires. immutable
26
+ * (RFC 8246) and the opt-in heuristics only apply when no explicit lifetime
27
+ * is present. `explicit` marks origin-provided expiration — required for the
28
+ * stale-on-arrival store-and-revalidate path (never keep heuristically-stale
29
+ * content around for revalidation).
30
+ *
31
+ * @returns {{ lifetime: number, explicit: boolean } | null}
32
+ */
33
+ function determineLifetime(
34
+ statusCode,
35
+ headers,
36
+ cacheControlDirectives,
37
+ { heuristic, defaultTTL },
38
+ now,
39
+ ) {
40
+ const explicit = cacheControlDirectives['s-maxage'] ?? cacheControlDirectives['max-age']
41
+ if (explicit != null) {
42
+ return { lifetime: explicit, explicit: true }
43
+ }
44
+
45
+ if (headers.expires != null) {
46
+ // RFC 9111 §5.3: an invalid Expires (notably `Expires: 0`) means already
47
+ // expired — the parse failure must surface as lifetime 0, not fall through
48
+ // to heuristics. Arrays (duplicated, potentially conflicting Expires field
49
+ // lines) are treated the same way.
50
+ const expires = typeof headers.expires === 'string' ? parseHttpDate(headers.expires) : undefined
51
+ if (!expires) {
52
+ return { lifetime: 0, explicit: true }
53
+ }
54
+ const date = typeof headers.date === 'string' ? parseHttpDate(headers.date) : undefined
55
+ return {
56
+ lifetime: Math.floor((expires.getTime() - (date ? date.getTime() : now)) / 1000),
57
+ explicit: true,
58
+ }
59
+ }
60
+
61
+ if (cacheControlDirectives.immutable) {
62
+ return { lifetime: IMMUTABLE_LIFETIME, explicit: false }
63
+ }
64
+
65
+ // Heuristic freshness and defaultTTL are per-request client opt-ins and
66
+ // deliberately restricted to plain 200s — heuristically extending 206/307
67
+ // would cache partials and temporary redirects without origin consent.
68
+ if (statusCode === 200) {
69
+ if (heuristic && typeof headers['last-modified'] === 'string') {
70
+ // RFC 9111 §4.2.2 suggested heuristic: 10% of time since Last-Modified.
71
+ // §4.2.2 forbids heuristics when an explicit expiration exists; Expires
72
+ // was handled (including the invalid form) above, so this is reached
73
+ // only when none does.
74
+ const lastModified = parseHttpDate(headers['last-modified'])
75
+ if (lastModified && lastModified.getTime() < now) {
76
+ return { lifetime: Math.floor((now - lastModified.getTime()) / 10 / 1000), explicit: false }
77
+ }
78
+ }
79
+ if (typeof defaultTTL === 'number' && defaultTTL > 0) {
80
+ return { lifetime: defaultTTL, explicit: false }
81
+ }
82
+ }
83
+
84
+ return null
85
+ }
86
+
87
+ /**
88
+ * Corrected initial age in whole seconds per RFC 9111 §4.2.3 (simplified):
89
+ * the larger of the Age header and the apparent age (receipt time minus the
90
+ * origin Date). A response relayed through intermediaries that don't add Age
91
+ * would otherwise get an over-extended TTL and be served stale.
92
+ */
93
+ function determineAge(headers, now) {
94
+ const rawAge = headers.age
95
+ // A duplicated Age header arrives as an array; take the first value.
96
+ const rawAgeValue = Array.isArray(rawAge) ? rawAge[0] : rawAge
97
+ // RFC 9111 §5.1 Age is delta-seconds (1*DIGIT): require a pure integer so a
98
+ // malformed value like "5junk" isn't parseInt-coerced to 5 and used to
99
+ // backdate cachedAt / extend staleness.
100
+ const age =
101
+ typeof rawAgeValue === 'string' && /^\d+$/.test(rawAgeValue.trim())
102
+ ? parseInt(rawAgeValue, 10)
103
+ : 0
104
+ const date = typeof headers.date === 'string' ? parseHttpDate(headers.date) : undefined
105
+ const apparentAge = date ? Math.max(0, Math.floor((now - date.getTime()) / 1000)) : 0
106
+ return Math.max(age, apparentAge)
107
+ }
108
+
109
+ /**
110
+ * Computes the entry's absolute times, or null when it shouldn't be stored.
111
+ *
112
+ * cachedAt is backdated by the corrected initial age so all downstream age
113
+ * math (served Age header, freshness checks) reduces to `now - cachedAt`; the
114
+ * origin Age header is stripped before storing to match.
115
+ *
116
+ * This cache does not retain entries past freshness for revalidation, so
117
+ * deleteAt == staleAt: an entry is dropped by the store as soon as it goes
118
+ * stale, and the read path never serves a stale entry. (The staleAt column
119
+ * exists for forward compatibility with revalidation.) Everything is capped
120
+ * by maxEntryTTL, measured from the (backdated) cachedAt.
121
+ */
122
+ function computeEntryTimes(lifetime, age, maxEntryTTL, now) {
123
+ if (!Number.isFinite(lifetime)) {
124
+ return null
125
+ }
126
+
127
+ const freshness = Math.min(lifetime, maxEntryTTL) // seconds
128
+ if (freshness - age <= 0) {
129
+ // Stale on arrival — not worth storing.
130
+ return null
131
+ }
132
+
133
+ const cachedAt = now - age * 1000
134
+ const staleAt = cachedAt + freshness * 1000
135
+ const deleteAt = staleAt
136
+
137
+ return { cachedAt, staleAt, deleteAt }
138
+ }
139
+
16
140
  class CacheHandler extends DecoratorHandler {
17
141
  #key
18
142
  #value
@@ -20,8 +144,10 @@ class CacheHandler extends DecoratorHandler {
20
144
  #logger
21
145
  #maxEntrySize
22
146
  #maxEntryTTL
147
+ #heuristic
148
+ #defaultTTL
23
149
 
24
- constructor(key, { store, logger, handler, maxEntrySize, maxEntryTTL }) {
150
+ constructor(key, { store, logger, handler, maxEntrySize, maxEntryTTL, heuristic, defaultTTL }) {
25
151
  super(handler)
26
152
 
27
153
  this.#key = key
@@ -30,6 +156,8 @@ class CacheHandler extends DecoratorHandler {
30
156
  this.#store = store
31
157
  this.#maxEntrySize = maxEntrySize ?? store.maxEntrySize ?? DEFAULT_MAX_ENTRY_SIZE
32
158
  this.#maxEntryTTL = maxEntryTTL ?? store.maxEntryTTL ?? DEFAULT_MAX_ENTRY_TTL
159
+ this.#heuristic = heuristic ?? false
160
+ this.#defaultTTL = defaultTTL ?? null
33
161
  }
34
162
 
35
163
  onConnect(abort) {
@@ -94,11 +222,30 @@ class CacheHandler extends DecoratorHandler {
94
222
 
95
223
  const cacheControlDirectives = parseCacheControl(headers['cache-control']) ?? {}
96
224
 
97
- if (this.#key.headers.authorization && !cacheControlDirectives.public) {
98
- return super.onHeaders(statusCode, headers, resume)
225
+ // RFC 9111 §3.5: a shared cache may store a response to a request with
226
+ // Authorization only when the response explicitly allows it: public,
227
+ // s-maxage, or must-revalidate (safe because such entries are never
228
+ // served stale without successful revalidation). Must stay in lockstep
229
+ // with the serve-side gate in the interceptor below. A duplicated
230
+ // (array) authorization header is refused outright.
231
+ const authorization = this.#key.headers.authorization
232
+ if (authorization != null) {
233
+ if (
234
+ typeof authorization !== 'string' ||
235
+ !(
236
+ cacheControlDirectives.public === true ||
237
+ cacheControlDirectives['s-maxage'] != null ||
238
+ cacheControlDirectives['must-revalidate'] === true
239
+ )
240
+ ) {
241
+ return super.onHeaders(statusCode, headers, resume)
242
+ }
99
243
  }
100
244
 
101
- if (cacheControlDirectives.private || cacheControlDirectives['no-store']) {
245
+ // Unqualified private forbids shared-cache storage entirely; the
246
+ // qualified form (private="field") only forbids storing the listed
247
+ // fields, which are stripped below (RFC 9111 §5.2.2.7).
248
+ if (cacheControlDirectives['no-store'] || cacheControlDirectives.private === true) {
102
249
  return super.onHeaders(statusCode, headers, resume)
103
250
  }
104
251
 
@@ -113,15 +260,21 @@ class CacheHandler extends DecoratorHandler {
113
260
  if (
114
261
  cacheControlDirectives['must-revalidate'] ||
115
262
  cacheControlDirectives['proxy-revalidate'] ||
116
- cacheControlDirectives['stale-while-revalidate'] ||
117
- cacheControlDirectives['stale-if-error'] ||
118
- cacheControlDirectives['no-cache']
263
+ cacheControlDirectives['stale-while-revalidate'] != null ||
264
+ cacheControlDirectives['stale-if-error'] != null ||
265
+ cacheControlDirectives['no-cache'] === true
119
266
  ) {
120
- // TODO (fix): Support all cache control directives...
267
+ // These directives require origin revalidation, which this cache does
268
+ // not yet perform — so the responses are not stored (a follow-up adds
269
+ // conditional revalidation and turns these into stored-and-validated
270
+ // entries).
121
271
  return super.onHeaders(statusCode, headers, resume)
122
272
  }
123
273
 
124
- const vary = {}
274
+ // Null prototype: selector names come from the response Vary header and
275
+ // request header names are caller-controlled — on a plain `{}` a
276
+ // `__proto__` key would hit the prototype setter and be silently dropped.
277
+ const vary = Object.create(null)
125
278
  if (headers.vary) {
126
279
  if (typeof headers.vary !== 'string') {
127
280
  return super.onHeaders(statusCode, headers, resume)
@@ -141,20 +294,23 @@ class CacheHandler extends DecoratorHandler {
141
294
  }
142
295
  }
143
296
 
144
- const ttl = cacheControlDirectives.immutable
145
- ? 31556952
146
- : Number(cacheControlDirectives['s-maxage'] ?? cacheControlDirectives['max-age'])
147
- if (!ttl || !Number.isFinite(ttl) || ttl <= 0) {
297
+ const now = Date.now()
298
+
299
+ const lifetimeInfo = determineLifetime(
300
+ statusCode,
301
+ headers,
302
+ cacheControlDirectives,
303
+ { heuristic: this.#heuristic, defaultTTL: this.#defaultTTL },
304
+ now,
305
+ )
306
+ if (lifetimeInfo == null) {
148
307
  return super.onHeaders(statusCode, headers, resume)
149
308
  }
150
309
 
151
- // RFC 9111 §4.2.3: a response relayed by an upstream/shared cache may
152
- // already be partway through its freshness lifetime. Subtract the
153
- // advertised Age so we don't over-extend the TTL and serve stale content.
154
- const age = Number(headers.age)
155
- const lifetime = Math.min(ttl, this.#maxEntryTTL) - (Number.isFinite(age) && age > 0 ? age : 0)
156
- if (lifetime <= 0) {
157
- // Already stale on arrival — not worth caching.
310
+ const etag = typeof headers.etag === 'string' && isEtagUsable(headers.etag) ? headers.etag : ''
311
+ const age = determineAge(headers, now)
312
+ const times = computeEntryTimes(lifetimeInfo.lifetime, age, this.#maxEntryTTL, now)
313
+ if (times == null) {
158
314
  return super.onHeaders(statusCode, headers, resume)
159
315
  }
160
316
 
@@ -166,19 +322,70 @@ class CacheHandler extends DecoratorHandler {
166
322
  const end = contentRange ? contentRange.end : this.#key.method === 'HEAD' ? 0 : contentLength
167
323
 
168
324
  if (end == null || end - start <= this.#maxEntrySize) {
169
- const cachedAt = Date.now()
325
+ // Snapshot the headers: the same object is delivered downstream to the
326
+ // caller (request() resolves with it before the body finishes), and the
327
+ // entry isn't serialized to the store until onComplete. Without a copy,
328
+ // any mutation the caller makes to res.headers while the body streams
329
+ // would be persisted into the shared cache and replayed to every later
330
+ // request. parseHeaders values are strings or string arrays, so a
331
+ // one-level copy with array values sliced is a full snapshot. Use
332
+ // Object.keys so only own enumerable header fields are copied — never
333
+ // inherited properties from the prototype chain. Null prototype: on a
334
+ // plain `{}` a header literally named `__proto__` would hit the
335
+ // Object.prototype setter instead of becoming a data property (silent
336
+ // drop / prototype-pollution vector).
337
+ //
338
+ // Stripped while copying (RFC 9111 §3.1): hop-by-hop fields, fields
339
+ // listed in the Connection header, fields named by qualified
340
+ // no-cache=/private= directives (§5.2.2.4/§5.2.2.7), and Age — cachedAt
341
+ // is backdated by the corrected initial age, so the served Age is fully
342
+ // recomputed and a stored Age would double-count.
343
+ const excludedHeaders = new Set(['age'])
344
+ const connection = headers.connection
345
+ if (typeof connection === 'string') {
346
+ for (const name of connection.split(',')) {
347
+ excludedHeaders.add(name.trim().toLowerCase())
348
+ }
349
+ } else if (Array.isArray(connection)) {
350
+ for (const line of connection) {
351
+ for (const name of `${line}`.split(',')) {
352
+ excludedHeaders.add(name.trim().toLowerCase())
353
+ }
354
+ }
355
+ }
356
+ if (Array.isArray(cacheControlDirectives['no-cache'])) {
357
+ for (const name of cacheControlDirectives['no-cache']) {
358
+ excludedHeaders.add(name)
359
+ }
360
+ }
361
+ if (Array.isArray(cacheControlDirectives.private)) {
362
+ for (const name of cacheControlDirectives.private) {
363
+ excludedHeaders.add(name)
364
+ }
365
+ }
366
+
367
+ const storedHeaders = Object.create(null)
368
+ for (const name of Object.keys(headers)) {
369
+ if (isHopByHop(name) || excludedHeaders.has(name.toLowerCase())) {
370
+ continue
371
+ }
372
+ const val = headers[name]
373
+ storedHeaders[name] = Array.isArray(val) ? val.slice() : val
374
+ }
375
+
170
376
  this.#value = {
171
377
  body: [],
172
378
  start,
173
379
  end,
174
- deleteAt: cachedAt + lifetime * 1e3,
380
+ cachedAt: times.cachedAt,
381
+ staleAt: times.staleAt,
382
+ deleteAt: times.deleteAt,
175
383
  statusCode,
176
384
  statusMessage: '',
177
- headers,
385
+ headers: storedHeaders,
178
386
  cacheControlDirectives,
179
- etag: isEtagUsable(headers.etag) ? headers.etag : '',
387
+ etag,
180
388
  vary,
181
- cachedAt,
182
389
  // Handler state.
183
390
  size: 0,
184
391
  }
@@ -220,95 +427,270 @@ class CacheHandler extends DecoratorHandler {
220
427
  }
221
428
  }
222
429
 
223
- export default () => (dispatch) => (opts, handler) => {
224
- if (!opts.cache || opts.upgrade) {
225
- return dispatch(opts, handler)
430
+ /**
431
+ * RFC 9111 §4.4: a non-error response to an unsafe method invalidates the
432
+ * stored entries for the target URI and any same-origin Location /
433
+ * Content-Location URIs (undici PR #5514). Cross-origin targets are skipped —
434
+ * honoring an attacker-influenced Location against another origin's entries
435
+ * would be a cache-poisoning vector.
436
+ */
437
+ class InvalidationHandler extends DecoratorHandler {
438
+ #key
439
+ #store
440
+ #logger
441
+
442
+ constructor(key, { store, logger, handler }) {
443
+ super(handler)
444
+ this.#key = key
445
+ this.#store = store
446
+ this.#logger = logger
226
447
  }
227
448
 
228
- if (opts.method !== 'GET' && opts.method !== 'HEAD') {
229
- return dispatch(opts, handler)
449
+ onHeaders(statusCode, headers, resume) {
450
+ if (statusCode >= 200 && statusCode <= 399) {
451
+ // Invalidation failures must never break the actual response. Deletes
452
+ // are idempotent, so a retry re-driving onHeaders is harmless.
453
+ try {
454
+ this.#invalidate(headers)
455
+ } catch (err) {
456
+ if (err.message === 'database is locked') {
457
+ this.#logger?.debug({ err }, 'failed to invalidate cache entry')
458
+ } else {
459
+ this.#logger?.error({ err }, 'failed to invalidate cache entry')
460
+ }
461
+ }
462
+ }
463
+ return super.onHeaders(statusCode, headers, resume)
230
464
  }
231
465
 
232
- const rawCacheControl = opts?.headers?.['cache-control']
233
- const cacheControlDirectives = parseCacheControl(rawCacheControl) ?? {}
234
- // cache-control-parser does not recognise 'only-if-cached', so check the raw string.
235
- const onlyIfCached =
236
- typeof rawCacheControl === 'string' && rawCacheControl.includes('only-if-cached')
466
+ #invalidate(headers) {
467
+ this.#store.delete(this.#key)
237
468
 
238
- // RFC 9111 Section 5.4: Pragma: no-cache should be treated as
239
- // Cache-Control: no-cache when Cache-Control is absent.
240
- if (rawCacheControl == null && opts?.headers?.pragma === 'no-cache') {
241
- cacheControlDirectives['no-cache'] = true
242
- }
469
+ const invalidated = new Set([this.#key.path])
470
+ let base
471
+ for (const name of ['location', 'content-location']) {
472
+ let value = headers[name]
473
+ if (Array.isArray(value)) {
474
+ value = value[0]
475
+ }
476
+ if (typeof value !== 'string' || value === '') {
477
+ continue
478
+ }
243
479
 
244
- if (cacheControlDirectives['no-transform']) {
245
- // Do nothing. We don't transform requests...
246
- }
480
+ base ??= new URL(this.#key.path, this.#key.origin)
481
+ let target
482
+ try {
483
+ target = new URL(value, base)
484
+ } catch {
485
+ continue
486
+ }
487
+ if (target.origin !== base.origin) {
488
+ continue
489
+ }
247
490
 
248
- if (
249
- // != null: 'max-age=0' parses to 0 (falsy) but still demands revalidation.
250
- cacheControlDirectives['max-age'] != null ||
251
- cacheControlDirectives['no-cache'] ||
252
- cacheControlDirectives['stale-if-error'] != null ||
253
- // cache-control-parser does not recognise 'max-stale'/'min-fresh', so
254
- // check the raw string like we do for 'only-if-cached'.
255
- (typeof rawCacheControl === 'string' &&
256
- (rawCacheControl.includes('max-stale') || rawCacheControl.includes('min-fresh')))
257
- ) {
258
- // TODO (fix): Support all cache control directives...
259
- return dispatch(opts, handler)
491
+ const path = target.pathname + target.search
492
+ if (!invalidated.has(path)) {
493
+ invalidated.add(path)
494
+ this.#store.delete({ ...this.#key, path })
495
+ }
496
+ }
260
497
  }
498
+ }
261
499
 
262
- const store =
263
- opts.cache.store ?? (DEFAULT_STORE ??= new SqliteCacheStore({ location: ':memory:' }))
500
+ function getStore(opts) {
501
+ return opts.cache.store ?? (DEFAULT_STORE ??= new SqliteCacheStore({ location: ':memory:' }))
502
+ }
264
503
 
265
- // TODO (fix): enable range requests
504
+ function tryGetEntry(store, key, logger) {
505
+ try {
506
+ return store.get(key)
507
+ } catch (err) {
508
+ if (err.message === 'database is locked') {
509
+ // Database is busy. We don't bother trying again...
510
+ logger?.debug({ err }, 'failed to get cache entry')
511
+ } else {
512
+ logger?.error({ err }, 'failed to get cache entry')
513
+ }
514
+ }
515
+ }
266
516
 
517
+ /**
518
+ * Builds the cache key shared by the get and set paths.
519
+ */
520
+ function makeKey(opts) {
267
521
  // Build the key the same way for lookups and stores: makeCacheKey
268
522
  // stringifies the origin (e.g. URL objects), so using raw opts on the get
269
523
  // path while the set path normalizes would make the cache permanently miss.
270
- const key = undici.util.cache.makeCacheKey(opts)
524
+ // The flat name/value array form of opts.headers (legal at the undici
525
+ // client level) makes makeCacheKey throw — normalize it through
526
+ // parseHeaders first (which also lowercases the names). Header names are
527
+ // caller-controlled, so parse into a null-prototype target: a `__proto__`
528
+ // name on a plain `{}` would hit the Object.prototype setter instead of
529
+ // becoming a data property.
530
+ const key = undici.util.cache.makeCacheKey(
531
+ Array.isArray(opts.headers)
532
+ ? { ...opts, headers: parseHeaders(opts.headers, Object.create(null)) }
533
+ : opts,
534
+ )
271
535
 
272
536
  // makeCacheKey preserves request header names verbatim. Vary selector names
273
- // are lowercased (in onHeaders and matchesValue), so lowercase the key's
537
+ // are lowercased (in CacheHandler and matchesValue), so lowercase the key's
274
538
  // header names once here — the same key feeds both the get and set paths, so
275
539
  // this keeps Vary matching symmetric even when a caller supplies non-lowercase
276
540
  // header names (the standalone interceptors.cache() composition; the wrapped
277
541
  // pipeline already normalizes). A fresh object avoids mutating opts.headers.
542
+ // Header names are caller-controlled, so build a null-prototype map (a
543
+ // `__proto__` key on a plain object would silently overwrite the prototype
544
+ // instead of setting a property) and copy own keys only.
278
545
  if (key.headers && typeof key.headers === 'object') {
279
- const lower = {}
280
- for (const name in key.headers) {
546
+ const lower = Object.create(null)
547
+ for (const name of Object.keys(key.headers)) {
281
548
  lower[name.toLowerCase()] = key.headers[name]
282
549
  }
283
550
  key.headers = lower
284
551
  }
285
552
 
286
- let entry
287
- try {
288
- entry = store.get(key)
289
- } catch (err) {
290
- if (err.message === 'database is locked') {
291
- // Database is busy. We don't bother trying again...
292
- opts.logger?.debug({ err }, 'failed to get cache entry')
293
- } else {
294
- opts.logger?.error({ err }, 'failed to get cache entry')
553
+ // The vendored makeCacheKey ignores opts.query. The wrapped pipeline is
554
+ // immune (the query interceptor rewrites path before the cache sees it),
555
+ // but a standalone interceptors.cache() composition would silently collide
556
+ // distinct query strings onto one entry and serve the wrong response
557
+ // (undici issue #4209 / PR #5081) — fold the query into the key path.
558
+ if (
559
+ opts.query &&
560
+ typeof key.path === 'string' &&
561
+ !key.path.includes('?') &&
562
+ !key.path.includes('#')
563
+ ) {
564
+ const qs = stringify(opts.query)
565
+ if (qs) {
566
+ key.path = `${key.path || '/'}?${qs}`
295
567
  }
296
568
  }
297
569
 
298
- // RFC 9111 Section 3.5: A shared cache must not use a cached response to a
299
- // request with Authorization unless the response includes a public directive.
300
- if (entry && opts.headers?.authorization && !entry.cacheControlDirectives?.public) {
301
- entry = undefined
570
+ return key
571
+ }
572
+
573
+ function cacheOptsOf(opts) {
574
+ return {
575
+ maxEntrySize: opts.cache.maxEntrySize,
576
+ maxEntryTTL: opts.cache.maxEntryTTL,
577
+ heuristic: opts.cache.heuristic,
578
+ defaultTTL: opts.cache.defaultTTL,
579
+ }
580
+ }
581
+
582
+ export default () => (dispatch) => (opts, handler) => {
583
+ if (!opts.cache || opts.upgrade) {
584
+ return dispatch(opts, handler)
302
585
  }
303
586
 
304
- // RFC 9110 Section 13: Evaluate conditional request headers against cached entry.
587
+ if (opts.method !== 'GET' && opts.method !== 'HEAD') {
588
+ // RFC 9110 §9.2.1: OPTIONS and TRACE are safe — never cached, but they
589
+ // must not invalidate either. Every other method (POST/PUT/DELETE/...)
590
+ // invalidates the target URI on a non-error response (RFC 9111 §4.4).
591
+ if (opts.method === 'OPTIONS' || opts.method === 'TRACE') {
592
+ return dispatch(opts, handler)
593
+ }
594
+
595
+ const store = getStore(opts)
596
+ if (typeof store.delete !== 'function') {
597
+ // User-supplied store without invalidation support.
598
+ return dispatch(opts, handler)
599
+ }
600
+
601
+ return dispatch(
602
+ opts,
603
+ new InvalidationHandler(makeKey(opts), { store, logger: opts.logger, handler }),
604
+ )
605
+ }
606
+
607
+ // TODO (fix): enable range requests
608
+
609
+ const key = makeKey(opts)
610
+
611
+ // All request-directive and conditional-header guards below MUST read from
612
+ // the lowercased key.headers, not raw opts.headers — otherwise a caller
613
+ // supplying capitalized names (e.g. `Authorization`, `Cache-Control` via the
614
+ // standalone composition) would silently skip the guards while the store
615
+ // side (CacheHandler) reads the lowercased form, e.g. serving a non-public
616
+ // cached response to an authorized request (RFC 9111 §3.5).
617
+ const headers = key.headers ?? {}
618
+
619
+ const rawCacheControl = headers['cache-control']
620
+ const requestCacheControl = parseCacheControl(rawCacheControl) ?? {}
621
+
622
+ // RFC 9111 Section 5.4: Pragma: no-cache should be treated as
623
+ // Cache-Control: no-cache when Cache-Control is absent.
624
+ if (rawCacheControl == null && headers.pragma === 'no-cache') {
625
+ requestCacheControl['no-cache'] = true
626
+ }
627
+
628
+ if (requestCacheControl['no-transform']) {
629
+ // Do nothing. We don't transform requests...
630
+ }
631
+
632
+ const onlyIfCached = requestCacheControl['only-if-cached'] === true
633
+ const store = getStore(opts)
634
+
635
+ let entry = tryGetEntry(store, key, opts.logger)
636
+
637
+ // RFC 9111 §3.5 serve-side authorization gate: a shared cache must not
638
+ // reuse a stored response for a request with Authorization unless the
639
+ // response allowed it (public, s-maxage or must-revalidate — the mirror of
640
+ // the store-side gate in CacheHandler; both sites must stay in lockstep).
641
+ if (entry && headers.authorization != null) {
642
+ const directives = entry.cacheControlDirectives
643
+ if (
644
+ typeof headers.authorization !== 'string' ||
645
+ !(
646
+ directives?.public === true ||
647
+ directives?.['s-maxage'] != null ||
648
+ directives?.['must-revalidate'] === true
649
+ )
650
+ ) {
651
+ entry = undefined
652
+ }
653
+ }
654
+
655
+ const cacheHandler = () =>
656
+ new CacheHandler(key, {
657
+ ...cacheOptsOf(opts),
658
+ store,
659
+ logger: opts.logger,
660
+ handler,
661
+ })
662
+
663
+ // Request Cache-Control directives that this cache does not evaluate locally
664
+ // (a follow-up adds conditional revalidation and local evaluation) cause a
665
+ // bypass to the origin. These constrain REUSE of a stored response, not the
666
+ // storage of a fresh one — so the bypass still writes the origin response
667
+ // back through CacheHandler for later callers (undici PR #5510), unless the
668
+ // request's own no-store forbids storing. only-if-cached is the exception:
669
+ // it forbids contacting the origin, so it is handled from the cache below
670
+ // instead of bypassing.
671
+ const bypass =
672
+ !onlyIfCached &&
673
+ // != null: 'max-age=0' parses to 0 (falsy) but still demands revalidation.
674
+ (requestCacheControl['max-age'] != null ||
675
+ requestCacheControl['no-cache'] === true ||
676
+ requestCacheControl['stale-if-error'] != null ||
677
+ requestCacheControl['max-stale'] != null ||
678
+ requestCacheControl['min-fresh'] != null)
679
+
680
+ if (bypass) {
681
+ return dispatch(opts, requestCacheControl['no-store'] ? handler : cacheHandler())
682
+ }
683
+
684
+ // RFC 9110 Section 13: evaluate conditional request headers against the
685
+ // cached entry. The store only returns entries that are still fresh
686
+ // (deleteAt === staleAt), so a returned entry is always servable.
305
687
  // typeof guards: duplicated conditional headers arrive as arrays — treat
306
688
  // them as non-matching and bypass to origin rather than crashing.
307
- if (entry && opts.headers?.['if-none-match']) {
689
+ if (entry && headers['if-none-match']) {
308
690
  if (
309
- typeof opts.headers['if-none-match'] === 'string' &&
691
+ typeof headers['if-none-match'] === 'string' &&
310
692
  entry.etag &&
311
- weakMatch(opts.headers['if-none-match'], entry.etag)
693
+ weakMatch(headers['if-none-match'], entry.etag)
312
694
  ) {
313
695
  return serveFromCache(
314
696
  { statusCode: 304, headers: entry.headers, cachedAt: entry.cachedAt },
@@ -318,12 +700,12 @@ export default () => (dispatch) => (opts, handler) => {
318
700
  }
319
701
  // Etag didn't match — bypass to origin.
320
702
  entry = undefined
321
- } else if (entry && opts.headers?.['if-modified-since']) {
703
+ } else if (entry && headers['if-modified-since']) {
322
704
  const lastModified = entry.headers?.['last-modified']
323
705
  if (
324
- typeof opts.headers['if-modified-since'] === 'string' &&
706
+ typeof headers['if-modified-since'] === 'string' &&
325
707
  lastModified &&
326
- new Date(lastModified) <= new Date(opts.headers['if-modified-since'])
708
+ new Date(lastModified) <= new Date(headers['if-modified-since'])
327
709
  ) {
328
710
  return serveFromCache(
329
711
  { statusCode: 304, headers: entry.headers, cachedAt: entry.cachedAt },
@@ -335,30 +717,19 @@ export default () => (dispatch) => (opts, handler) => {
335
717
  entry = undefined
336
718
  }
337
719
 
338
- if (
339
- opts.headers?.['if-match'] ||
340
- opts.headers?.['if-unmodified-since'] ||
341
- opts.headers?.['if-range']
342
- ) {
720
+ if (headers['if-match'] || headers['if-unmodified-since'] || headers['if-range']) {
343
721
  // TODO (fix): evaluate these conditional headers against cached entry.
344
722
  return dispatch(opts, handler)
345
723
  }
346
724
 
347
725
  if (!entry && !onlyIfCached) {
348
- return dispatch(
349
- opts,
350
- cacheControlDirectives['no-store']
351
- ? handler
352
- : new CacheHandler(key, {
353
- maxEntrySize: opts.cache.maxEntrySize,
354
- maxEntryTTL: opts.cache.maxEntryTTL,
355
- store,
356
- logger: opts.logger,
357
- handler,
358
- }),
359
- )
726
+ // A miss keeps the CacheHandler write-back unless the request's no-store
727
+ // forbids storing.
728
+ return dispatch(opts, requestCacheControl['no-store'] ? handler : cacheHandler())
360
729
  }
361
730
 
731
+ // A hit (fresh, per the store) is served; only-if-cached with no usable
732
+ // entry yields 504 (RFC 9111 §5.2.1.7).
362
733
  return serveFromCache(entry ?? { statusCode: 504 }, opts, handler)
363
734
  }
364
735
 
@@ -367,13 +738,13 @@ function serveFromCache(entry, opts, handler) {
367
738
 
368
739
  let headers = entry.headers
369
740
  if (entry.cachedAt != null) {
370
- // RFC 9111 §5.1: every response served from cache must carry an Age header
371
- // reflecting time spent in this cache plus any age it arrived with —
372
- // otherwise downstream caches treat it as fresh-from-origin.
373
- // getFastNow has 1s resolution — Age is whole seconds, so that's enough.
374
- const residentAge = Math.max(0, Math.floor((getFastNow() - entry.cachedAt) / 1000))
375
- const originAge = Number(headers?.age)
376
- const age = Number.isFinite(originAge) && originAge > 0 ? originAge + residentAge : residentAge
741
+ // RFC 9111 §5.1: every response served from cache must carry an Age
742
+ // header. cachedAt is backdated by the corrected initial age at store
743
+ // time (§4.2.3) and the origin's Age header is stripped, so resident time
744
+ // IS the response's ageno origin-Age addition. Date.now(), not
745
+ // getFastNow(): the lagging clock would understate a relayed response's
746
+ // initial age by up to a second.
747
+ const age = Math.max(0, Math.floor((Date.now() - entry.cachedAt) / 1000))
377
748
  headers = { ...headers, age: `${age}` }
378
749
  }
379
750