@nxtedition/nxt-undici 7.4.2 → 7.5.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.
@@ -1,18 +1,165 @@
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'
13
+ import { traceWrite, traceSafe, traceErr, traceUrl } from '../trace.js'
10
14
 
11
15
  let DEFAULT_STORE = null
12
16
  const DEFAULT_MAX_ENTRY_SIZE = 128 * 1024
13
- const DEFAULT_MAX_ENTRY_TTL = 30 * 24 * 3600
17
+ const DEFAULT_MAX_ENTRY_TTL = 30 * 24 * 3600 // seconds
18
+ // RFC 8246 'immutable' has no lifetime of its own; this is the customary
19
+ // 1-year default, capped by maxEntryTTL below.
20
+ const IMMUTABLE_LIFETIME = 31556952 // seconds
14
21
  const NOOP = () => {}
15
22
 
23
+ // Emit the per-dispatch `undici:cache` lookup doc at the outcome decision
24
+ // (hit/miss/bypass). Call sites gate on the write fn captured once at the
25
+ // dispatch entry (log.js style), so the off path pays no doc building; result
26
+ // and reason stay low-cardinality (they become ES keywords).
27
+ function traceLookup(write, opts, url, result, reason, statusCode, ageSec, sizeBytes, lookupMs) {
28
+ traceSafe(
29
+ write,
30
+ {
31
+ id: opts.id ?? null,
32
+ method: opts.method ?? null,
33
+ url,
34
+ result,
35
+ reason,
36
+ statusCode,
37
+ ageSec,
38
+ sizeBytes,
39
+ lookupMs,
40
+ },
41
+ 'undici:cache',
42
+ )
43
+ }
44
+
45
+ /**
46
+ * Explicit (or opt-in heuristic) freshness lifetime in seconds, or null when
47
+ * the response carries no usable expiration information. RFC 9111 §4.2.1
48
+ * priority for a shared cache: s-maxage > max-age > Expires. immutable
49
+ * (RFC 8246) and the opt-in heuristics only apply when no explicit lifetime
50
+ * is present. `explicit` marks origin-provided expiration — required for the
51
+ * stale-on-arrival store-and-revalidate path (never keep heuristically-stale
52
+ * content around for revalidation).
53
+ *
54
+ * @returns {{ lifetime: number, explicit: boolean } | null}
55
+ */
56
+ function determineLifetime(
57
+ statusCode,
58
+ headers,
59
+ cacheControlDirectives,
60
+ { heuristic, defaultTTL },
61
+ now,
62
+ ) {
63
+ const explicit = cacheControlDirectives['s-maxage'] ?? cacheControlDirectives['max-age']
64
+ if (explicit != null) {
65
+ return { lifetime: explicit, explicit: true }
66
+ }
67
+
68
+ if (headers.expires != null) {
69
+ // RFC 9111 §5.3: an invalid Expires (notably `Expires: 0`) means already
70
+ // expired — the parse failure must surface as lifetime 0, not fall through
71
+ // to heuristics. Arrays (duplicated, potentially conflicting Expires field
72
+ // lines) are treated the same way.
73
+ const expires = typeof headers.expires === 'string' ? parseHttpDate(headers.expires) : undefined
74
+ if (!expires) {
75
+ return { lifetime: 0, explicit: true }
76
+ }
77
+ const date = typeof headers.date === 'string' ? parseHttpDate(headers.date) : undefined
78
+ return {
79
+ lifetime: Math.floor((expires.getTime() - (date ? date.getTime() : now)) / 1000),
80
+ explicit: true,
81
+ }
82
+ }
83
+
84
+ if (cacheControlDirectives.immutable) {
85
+ return { lifetime: IMMUTABLE_LIFETIME, explicit: false }
86
+ }
87
+
88
+ // Heuristic freshness and defaultTTL are per-request client opt-ins and
89
+ // deliberately restricted to plain 200s — heuristically extending 206/307
90
+ // would cache partials and temporary redirects without origin consent.
91
+ if (statusCode === 200) {
92
+ if (heuristic && typeof headers['last-modified'] === 'string') {
93
+ // RFC 9111 §4.2.2 suggested heuristic: 10% of time since Last-Modified.
94
+ // §4.2.2 forbids heuristics when an explicit expiration exists; Expires
95
+ // was handled (including the invalid form) above, so this is reached
96
+ // only when none does.
97
+ const lastModified = parseHttpDate(headers['last-modified'])
98
+ if (lastModified && lastModified.getTime() < now) {
99
+ return { lifetime: Math.floor((now - lastModified.getTime()) / 10 / 1000), explicit: false }
100
+ }
101
+ }
102
+ if (typeof defaultTTL === 'number' && defaultTTL > 0) {
103
+ return { lifetime: defaultTTL, explicit: false }
104
+ }
105
+ }
106
+
107
+ return null
108
+ }
109
+
110
+ /**
111
+ * Corrected initial age in whole seconds per RFC 9111 §4.2.3 (simplified):
112
+ * the larger of the Age header and the apparent age (receipt time minus the
113
+ * origin Date). A response relayed through intermediaries that don't add Age
114
+ * would otherwise get an over-extended TTL and be served stale.
115
+ */
116
+ function determineAge(headers, now) {
117
+ const rawAge = headers.age
118
+ // A duplicated Age header arrives as an array; take the first value.
119
+ const rawAgeValue = Array.isArray(rawAge) ? rawAge[0] : rawAge
120
+ // RFC 9111 §5.1 Age is delta-seconds (1*DIGIT): require a pure integer so a
121
+ // malformed value like "5junk" isn't parseInt-coerced to 5 and used to
122
+ // backdate cachedAt / extend staleness.
123
+ const age =
124
+ typeof rawAgeValue === 'string' && /^\d+$/.test(rawAgeValue.trim())
125
+ ? parseInt(rawAgeValue, 10)
126
+ : 0
127
+ const date = typeof headers.date === 'string' ? parseHttpDate(headers.date) : undefined
128
+ const apparentAge = date ? Math.max(0, Math.floor((now - date.getTime()) / 1000)) : 0
129
+ return Math.max(age, apparentAge)
130
+ }
131
+
132
+ /**
133
+ * Computes the entry's absolute times, or null when it shouldn't be stored.
134
+ *
135
+ * cachedAt is backdated by the corrected initial age so all downstream age
136
+ * math (served Age header, freshness checks) reduces to `now - cachedAt`; the
137
+ * origin Age header is stripped before storing to match.
138
+ *
139
+ * This cache does not retain entries past freshness for revalidation, so
140
+ * deleteAt == staleAt: an entry is dropped by the store as soon as it goes
141
+ * stale, and the read path never serves a stale entry. (The staleAt column
142
+ * exists for forward compatibility with revalidation.) Everything is capped
143
+ * by maxEntryTTL, measured from the (backdated) cachedAt.
144
+ */
145
+ function computeEntryTimes(lifetime, age, maxEntryTTL, now) {
146
+ if (!Number.isFinite(lifetime)) {
147
+ return null
148
+ }
149
+
150
+ const freshness = Math.min(lifetime, maxEntryTTL) // seconds
151
+ if (freshness - age <= 0) {
152
+ // Stale on arrival — not worth storing.
153
+ return null
154
+ }
155
+
156
+ const cachedAt = now - age * 1000
157
+ const staleAt = cachedAt + freshness * 1000
158
+ const deleteAt = staleAt
159
+
160
+ return { cachedAt, staleAt, deleteAt }
161
+ }
162
+
16
163
  class CacheHandler extends DecoratorHandler {
17
164
  #key
18
165
  #value
@@ -20,8 +167,23 @@ class CacheHandler extends DecoratorHandler {
20
167
  #logger
21
168
  #maxEntrySize
22
169
  #maxEntryTTL
23
-
24
- constructor(key, { store, logger, handler, maxEntrySize, maxEntryTTL }) {
170
+ #heuristic
171
+ #defaultTTL
172
+
173
+ // Trace plumbing captured once at the dispatch entry: the resolved write fn
174
+ // (null when tracing is off), the request id and the bounded url tag. One
175
+ // `undici:cache-store` doc is emitted per response at the storability
176
+ // outcome — no per-attempt emitted-flag is needed because every skip path
177
+ // leaves #value null (making the stored/overflow paths unreachable for the
178
+ // same attempt) and onConnect resets #value for retry re-entry.
179
+ #write
180
+ #id
181
+ #url
182
+
183
+ constructor(
184
+ key,
185
+ { store, logger, handler, maxEntrySize, maxEntryTTL, heuristic, defaultTTL, write, id, url },
186
+ ) {
25
187
  super(handler)
26
188
 
27
189
  this.#key = key
@@ -30,6 +192,41 @@ class CacheHandler extends DecoratorHandler {
30
192
  this.#store = store
31
193
  this.#maxEntrySize = maxEntrySize ?? store.maxEntrySize ?? DEFAULT_MAX_ENTRY_SIZE
32
194
  this.#maxEntryTTL = maxEntryTTL ?? store.maxEntryTTL ?? DEFAULT_MAX_ENTRY_TTL
195
+ this.#heuristic = heuristic ?? false
196
+ this.#defaultTTL = defaultTTL ?? null
197
+ this.#write = write ?? null
198
+ this.#id = id ?? null
199
+ this.#url = url ?? null
200
+ }
201
+
202
+ // The single `undici:cache-store` emitter. `err` is the raw error (or null);
203
+ // tagging is deferred here so no string work happens unless tracing is on.
204
+ #trace(statusCode, stored, reason, sizeBytes, ttlSec, err) {
205
+ if (this.#write !== null) {
206
+ traceSafe(
207
+ this.#write,
208
+ {
209
+ id: this.#id,
210
+ method: this.#key.method ?? null,
211
+ url: this.#url,
212
+ statusCode,
213
+ stored,
214
+ reason,
215
+ sizeBytes,
216
+ ttlSec,
217
+ err: err != null ? traceErr(err) : null,
218
+ },
219
+ 'undici:cache-store',
220
+ )
221
+ }
222
+ }
223
+
224
+ // Storability declined at header time: emit the skip doc and pass the
225
+ // response through untouched. Keeps the many onHeaders early returns
226
+ // single-line; `reason` names the failed gate and must stay low-cardinality.
227
+ #skip(reason, statusCode, headers, resume) {
228
+ this.#trace(statusCode, false, reason, null, null, null)
229
+ return super.onHeaders(statusCode, headers, resume)
33
230
  }
34
231
 
35
232
  onConnect(abort) {
@@ -42,20 +239,28 @@ class CacheHandler extends DecoratorHandler {
42
239
  }
43
240
 
44
241
  onHeaders(statusCode, headers, resume) {
45
- if (statusCode !== 307 && statusCode !== 200 && statusCode !== 206) {
242
+ if (statusCode < 200) {
243
+ // Interim informational responses precede the real response (raw undici
244
+ // strips them, composed/mock dispatchers may forward them — same guard
245
+ // as redirect/response-verify): not a storability outcome, so no
246
+ // cache-store doc either — the final response emits the one doc.
46
247
  return super.onHeaders(statusCode, headers, resume)
47
248
  }
48
249
 
250
+ if (statusCode !== 307 && statusCode !== 200 && statusCode !== 206) {
251
+ return this.#skip('status', statusCode, headers, resume)
252
+ }
253
+
49
254
  // 'trailer' is the RFC 9110 field name; 'trailers' is kept for backwards
50
255
  // compatibility with servers that misspell it.
51
256
  if (headers.vary === '*' || headers.trailer || headers.trailers) {
52
257
  // Not cacheble...
53
- return super.onHeaders(statusCode, headers, resume)
258
+ return this.#skip(headers.vary === '*' ? 'vary-star' : 'trailer', statusCode, headers, resume)
54
259
  }
55
260
 
56
261
  if (headers['set-cookie']) {
57
262
  // Shared cache: replaying Set-Cookie to other clients leaks sessions.
58
- return super.onHeaders(statusCode, headers, resume)
263
+ return this.#skip('set-cookie', statusCode, headers, resume)
59
264
  }
60
265
 
61
266
  let contentRange
@@ -68,13 +273,13 @@ class CacheHandler extends DecoratorHandler {
68
273
  (contentRange.size != null && contentRange.end > contentRange.size)))
69
274
  ) {
70
275
  // We don't support caching responses with invalid content-range...
71
- return super.onHeaders(statusCode, headers, resume)
276
+ return this.#skip('content-range', statusCode, headers, resume)
72
277
  }
73
278
  if (this.#key.method === 'HEAD') {
74
279
  // A HEAD response delivers no body, so we never receive the byte
75
280
  // window Content-Range describes — storing it would fail the store's
76
281
  // body-length validation.
77
- return super.onHeaders(statusCode, headers, resume)
282
+ return this.#skip('head-range', statusCode, headers, resume)
78
283
  }
79
284
  }
80
285
 
@@ -83,23 +288,42 @@ class CacheHandler extends DecoratorHandler {
83
288
  contentLength = Number(headers['content-length'])
84
289
  if (!Number.isFinite(contentLength) || contentLength <= 0) {
85
290
  // We don't support caching responses with invalid content-length...
86
- return super.onHeaders(statusCode, headers, resume)
291
+ return this.#skip('content-length', statusCode, headers, resume)
87
292
  }
88
293
  }
89
294
 
90
295
  if (statusCode === 206 && !contentRange) {
91
296
  // We don't support caching range responses without content-range...
92
- return super.onHeaders(statusCode, headers, resume)
297
+ return this.#skip('206-no-range', statusCode, headers, resume)
93
298
  }
94
299
 
95
300
  const cacheControlDirectives = parseCacheControl(headers['cache-control']) ?? {}
96
301
 
97
- if (this.#key.headers.authorization && !cacheControlDirectives.public) {
98
- return super.onHeaders(statusCode, headers, resume)
302
+ // RFC 9111 §3.5: a shared cache may store a response to a request with
303
+ // Authorization only when the response explicitly allows it: public,
304
+ // s-maxage, or must-revalidate (safe because such entries are never
305
+ // served stale without successful revalidation). Must stay in lockstep
306
+ // with the serve-side gate in the interceptor below. A duplicated
307
+ // (array) authorization header is refused outright.
308
+ const authorization = this.#key.headers.authorization
309
+ if (authorization != null) {
310
+ if (
311
+ typeof authorization !== 'string' ||
312
+ !(
313
+ cacheControlDirectives.public === true ||
314
+ cacheControlDirectives['s-maxage'] != null ||
315
+ cacheControlDirectives['must-revalidate'] === true
316
+ )
317
+ ) {
318
+ return this.#skip('auth', statusCode, headers, resume)
319
+ }
99
320
  }
100
321
 
101
- if (cacheControlDirectives.private || cacheControlDirectives['no-store']) {
102
- return super.onHeaders(statusCode, headers, resume)
322
+ // Unqualified private forbids shared-cache storage entirely; the
323
+ // qualified form (private="field") only forbids storing the listed
324
+ // fields, which are stripped below (RFC 9111 §5.2.2.7).
325
+ if (cacheControlDirectives['no-store'] || cacheControlDirectives.private === true) {
326
+ return this.#skip('no-store', statusCode, headers, resume)
103
327
  }
104
328
 
105
329
  if (cacheControlDirectives['must-understand']) {
@@ -113,12 +337,15 @@ class CacheHandler extends DecoratorHandler {
113
337
  if (
114
338
  cacheControlDirectives['must-revalidate'] ||
115
339
  cacheControlDirectives['proxy-revalidate'] ||
116
- cacheControlDirectives['stale-while-revalidate'] ||
117
- cacheControlDirectives['stale-if-error'] ||
118
- cacheControlDirectives['no-cache']
340
+ cacheControlDirectives['stale-while-revalidate'] != null ||
341
+ cacheControlDirectives['stale-if-error'] != null ||
342
+ cacheControlDirectives['no-cache'] === true
119
343
  ) {
120
- // TODO (fix): Support all cache control directives...
121
- return super.onHeaders(statusCode, headers, resume)
344
+ // These directives require origin revalidation, which this cache does
345
+ // not yet perform — so the responses are not stored (a follow-up adds
346
+ // conditional revalidation and turns these into stored-and-validated
347
+ // entries).
348
+ return this.#skip('revalidate', statusCode, headers, resume)
122
349
  }
123
350
 
124
351
  // Null prototype: selector names come from the response Vary header and
@@ -127,13 +354,13 @@ class CacheHandler extends DecoratorHandler {
127
354
  const vary = Object.create(null)
128
355
  if (headers.vary) {
129
356
  if (typeof headers.vary !== 'string') {
130
- return super.onHeaders(statusCode, headers, resume)
357
+ return this.#skip('vary-invalid', statusCode, headers, resume)
131
358
  }
132
359
 
133
360
  for (const key of headers.vary.split(',').map((key) => key.trim().toLowerCase())) {
134
361
  if (key === '*') {
135
362
  // RFC 9111 §4.1: a Vary field containing '*' never matches.
136
- return super.onHeaders(statusCode, headers, resume)
363
+ return this.#skip('vary-star', statusCode, headers, resume)
137
364
  }
138
365
  // Record every selecting header, using a null sentinel when it was
139
366
  // absent from the request. RFC 9111 §4.1: absent-vs-present is a
@@ -144,27 +371,24 @@ class CacheHandler extends DecoratorHandler {
144
371
  }
145
372
  }
146
373
 
147
- // RFC 8246: 'immutable' means the body won't change during the freshness
148
- // lifetime — it does not define or extend that lifetime. An explicit
149
- // s-maxage/max-age always wins; immutable only supplies a (long) default
150
- // when no explicit lifetime is present. Capped by maxEntryTTL below.
151
- const ttl = Number(
152
- cacheControlDirectives['s-maxage'] ??
153
- cacheControlDirectives['max-age'] ??
154
- (cacheControlDirectives.immutable ? 31556952 : NaN),
374
+ const now = Date.now()
375
+
376
+ const lifetimeInfo = determineLifetime(
377
+ statusCode,
378
+ headers,
379
+ cacheControlDirectives,
380
+ { heuristic: this.#heuristic, defaultTTL: this.#defaultTTL },
381
+ now,
155
382
  )
156
- if (!ttl || !Number.isFinite(ttl) || ttl <= 0) {
157
- return super.onHeaders(statusCode, headers, resume)
383
+ if (lifetimeInfo == null) {
384
+ return this.#skip('no-lifetime', statusCode, headers, resume)
158
385
  }
159
386
 
160
- // RFC 9111 §4.2.3: a response relayed by an upstream/shared cache may
161
- // already be partway through its freshness lifetime. Subtract the
162
- // advertised Age so we don't over-extend the TTL and serve stale content.
163
- const age = Number(headers.age)
164
- const lifetime = Math.min(ttl, this.#maxEntryTTL) - (Number.isFinite(age) && age > 0 ? age : 0)
165
- if (lifetime <= 0) {
166
- // Already stale on arrival — not worth caching.
167
- return super.onHeaders(statusCode, headers, resume)
387
+ const etag = typeof headers.etag === 'string' && isEtagUsable(headers.etag) ? headers.etag : ''
388
+ const age = determineAge(headers, now)
389
+ const times = computeEntryTimes(lifetimeInfo.lifetime, age, this.#maxEntryTTL, now)
390
+ if (times == null) {
391
+ return this.#skip('stale', statusCode, headers, resume)
168
392
  }
169
393
 
170
394
  const start = contentRange ? contentRange.start : 0
@@ -175,7 +399,6 @@ class CacheHandler extends DecoratorHandler {
175
399
  const end = contentRange ? contentRange.end : this.#key.method === 'HEAD' ? 0 : contentLength
176
400
 
177
401
  if (end == null || end - start <= this.#maxEntrySize) {
178
- const cachedAt = Date.now()
179
402
  // Snapshot the headers: the same object is delivered downstream to the
180
403
  // caller (request() resolves with it before the body finishes), and the
181
404
  // entry isn't serialized to the store until onComplete. Without a copy,
@@ -188,26 +411,63 @@ class CacheHandler extends DecoratorHandler {
188
411
  // plain `{}` a header literally named `__proto__` would hit the
189
412
  // Object.prototype setter instead of becoming a data property (silent
190
413
  // drop / prototype-pollution vector).
414
+ //
415
+ // Stripped while copying (RFC 9111 §3.1): hop-by-hop fields, fields
416
+ // listed in the Connection header, fields named by qualified
417
+ // no-cache=/private= directives (§5.2.2.4/§5.2.2.7), and Age — cachedAt
418
+ // is backdated by the corrected initial age, so the served Age is fully
419
+ // recomputed and a stored Age would double-count.
420
+ const excludedHeaders = new Set(['age'])
421
+ const connection = headers.connection
422
+ if (typeof connection === 'string') {
423
+ for (const name of connection.split(',')) {
424
+ excludedHeaders.add(name.trim().toLowerCase())
425
+ }
426
+ } else if (Array.isArray(connection)) {
427
+ for (const line of connection) {
428
+ for (const name of `${line}`.split(',')) {
429
+ excludedHeaders.add(name.trim().toLowerCase())
430
+ }
431
+ }
432
+ }
433
+ if (Array.isArray(cacheControlDirectives['no-cache'])) {
434
+ for (const name of cacheControlDirectives['no-cache']) {
435
+ excludedHeaders.add(name)
436
+ }
437
+ }
438
+ if (Array.isArray(cacheControlDirectives.private)) {
439
+ for (const name of cacheControlDirectives.private) {
440
+ excludedHeaders.add(name)
441
+ }
442
+ }
443
+
191
444
  const storedHeaders = Object.create(null)
192
445
  for (const name of Object.keys(headers)) {
446
+ if (isHopByHop(name) || excludedHeaders.has(name.toLowerCase())) {
447
+ continue
448
+ }
193
449
  const val = headers[name]
194
450
  storedHeaders[name] = Array.isArray(val) ? val.slice() : val
195
451
  }
452
+
196
453
  this.#value = {
197
454
  body: [],
198
455
  start,
199
456
  end,
200
- deleteAt: cachedAt + lifetime * 1e3,
457
+ cachedAt: times.cachedAt,
458
+ staleAt: times.staleAt,
459
+ deleteAt: times.deleteAt,
201
460
  statusCode,
202
461
  statusMessage: '',
203
462
  headers: storedHeaders,
204
463
  cacheControlDirectives,
205
- etag: isEtagUsable(headers.etag) ? headers.etag : '',
464
+ etag,
206
465
  vary,
207
- cachedAt,
208
466
  // Handler state.
209
467
  size: 0,
210
468
  }
469
+ } else {
470
+ return this.#skip('too-large', statusCode, headers, resume)
211
471
  }
212
472
 
213
473
  return super.onHeaders(statusCode, headers, resume)
@@ -219,7 +479,11 @@ class CacheHandler extends DecoratorHandler {
219
479
  this.#value.body.push(chunk)
220
480
 
221
481
  if (this.#value.size > this.#maxEntrySize) {
482
+ // The buffer flips to discarded exactly once per attempt (#value stays
483
+ // null afterwards), so this is the single skip emission for it.
484
+ const statusCode = this.#value.statusCode
222
485
  this.#value = null
486
+ this.#trace(statusCode, false, 'too-large', null, null, null)
223
487
  }
224
488
  }
225
489
 
@@ -229,9 +493,11 @@ class CacheHandler extends DecoratorHandler {
229
493
  onComplete(trailers) {
230
494
  if (this.#value && (!trailers || Object.keys(trailers).length === 0)) {
231
495
  this.#value.end ??= this.#value.start + this.#value.size
496
+ let storeErr = null
232
497
  try {
233
498
  this.#store.set(this.#key, this.#value)
234
499
  } catch (err) {
500
+ storeErr = err
235
501
  if (err.message === 'database is locked') {
236
502
  // Database is busy. We don't bother trying again...
237
503
  this.#logger?.debug({ err }, 'failed to set cache entry')
@@ -239,31 +505,164 @@ class CacheHandler extends DecoratorHandler {
239
505
  this.#logger?.error({ err }, 'failed to set cache entry')
240
506
  }
241
507
  }
508
+ // stored reflects what actually happened: a throwing set() (e.g.
509
+ // 'database is locked' under write contention) persisted nothing, and
510
+ // dashboards keyed on `stored` must not count it. reason stays null —
511
+ // this is a store failure, not a storability gate; err carries the why.
512
+ this.#trace(
513
+ this.#value.statusCode,
514
+ storeErr == null,
515
+ null,
516
+ this.#value.size,
517
+ Math.round((this.#value.staleAt - this.#value.cachedAt) / 1000),
518
+ storeErr,
519
+ )
242
520
  this.#value = null
521
+ } else if (this.#value) {
522
+ // Unexpected trailers arriving at completion decline storability late —
523
+ // still one doc per response at the outcome.
524
+ this.#trace(this.#value.statusCode, false, 'trailer', null, null, null)
243
525
  }
244
526
 
245
527
  super.onComplete(trailers)
246
528
  }
247
529
  }
248
530
 
249
- export default () => (dispatch) => (opts, handler) => {
250
- if (!opts.cache || opts.upgrade) {
251
- return dispatch(opts, handler)
531
+ /**
532
+ * RFC 9111 §4.4: a non-error response to an unsafe method invalidates the
533
+ * stored entries for the target URI and any same-origin Location /
534
+ * Content-Location URIs (undici PR #5514). Cross-origin targets are skipped —
535
+ * honoring an attacker-influenced Location against another origin's entries
536
+ * would be a cache-poisoning vector.
537
+ */
538
+ class InvalidationHandler extends DecoratorHandler {
539
+ #key
540
+ #store
541
+ #logger
542
+ #write
543
+ #id
544
+ #url
545
+
546
+ constructor(key, { store, logger, handler, write, id, url }) {
547
+ super(handler)
548
+ this.#key = key
549
+ this.#store = store
550
+ this.#logger = logger
551
+ this.#write = write ?? null
552
+ this.#id = id ?? null
553
+ this.#url = url ?? null
252
554
  }
253
555
 
254
- if (opts.method !== 'GET' && opts.method !== 'HEAD') {
255
- return dispatch(opts, handler)
556
+ onHeaders(statusCode, headers, resume) {
557
+ if (statusCode >= 200 && statusCode <= 399) {
558
+ // Invalidation failures must never break the actual response. Deletes
559
+ // are idempotent, so a retry re-driving onHeaders is harmless.
560
+ let paths = 0
561
+ let invalidateErr = null
562
+ try {
563
+ paths = this.#invalidate(headers)
564
+ } catch (err) {
565
+ invalidateErr = err
566
+ if (err.message === 'database is locked') {
567
+ this.#logger?.debug({ err }, 'failed to invalidate cache entry')
568
+ } else {
569
+ this.#logger?.error({ err }, 'failed to invalidate cache entry')
570
+ }
571
+ }
572
+ // One `undici:cache-invalidate` doc per settled invalidation; `paths` is
573
+ // the count of invalidated paths, never the list.
574
+ if (this.#write !== null) {
575
+ traceSafe(
576
+ this.#write,
577
+ {
578
+ id: this.#id,
579
+ method: this.#key.method ?? null,
580
+ url: this.#url,
581
+ statusCode,
582
+ paths,
583
+ err: invalidateErr != null ? traceErr(invalidateErr) : null,
584
+ },
585
+ 'undici:cache-invalidate',
586
+ )
587
+ }
588
+ }
589
+ return super.onHeaders(statusCode, headers, resume)
256
590
  }
257
591
 
258
- // TODO (fix): enable range requests
592
+ #invalidate(headers) {
593
+ this.#store.delete(this.#key)
594
+
595
+ const invalidated = new Set([this.#key.path])
596
+ let base
597
+ for (const name of ['location', 'content-location']) {
598
+ let value = headers[name]
599
+ if (Array.isArray(value)) {
600
+ value = value[0]
601
+ }
602
+ if (typeof value !== 'string' || value === '') {
603
+ continue
604
+ }
605
+
606
+ base ??= new URL(this.#key.path, this.#key.origin)
607
+ let target
608
+ try {
609
+ target = new URL(value, base)
610
+ } catch {
611
+ continue
612
+ }
613
+ if (target.origin !== base.origin) {
614
+ continue
615
+ }
616
+
617
+ const path = target.pathname + target.search
618
+ if (!invalidated.has(path)) {
619
+ invalidated.add(path)
620
+ this.#store.delete({ ...this.#key, path })
621
+ }
622
+ }
623
+
624
+ return invalidated.size
625
+ }
626
+ }
627
+
628
+ function getStore(opts) {
629
+ return opts.cache.store ?? (DEFAULT_STORE ??= new SqliteCacheStore({ location: ':memory:' }))
630
+ }
259
631
 
632
+ function tryGetEntry(store, key, logger) {
633
+ try {
634
+ return store.get(key)
635
+ } catch (err) {
636
+ if (err.message === 'database is locked') {
637
+ // Database is busy. We don't bother trying again...
638
+ logger?.debug({ err }, 'failed to get cache entry')
639
+ } else {
640
+ logger?.error({ err }, 'failed to get cache entry')
641
+ }
642
+ }
643
+ }
644
+
645
+ /**
646
+ * Builds the cache key shared by the get and set paths.
647
+ */
648
+ function makeKey(opts) {
260
649
  // Build the key the same way for lookups and stores: makeCacheKey
261
650
  // stringifies the origin (e.g. URL objects), so using raw opts on the get
262
651
  // path while the set path normalizes would make the cache permanently miss.
263
- const key = undici.util.cache.makeCacheKey(opts)
652
+ // The flat name/value array form of opts.headers (legal at the undici
653
+ // client level) makes makeCacheKey throw — normalize it through
654
+ // parseHeaders first (which also lowercases the names). Header names are
655
+ // caller-controlled, so parse into a null-prototype target: a `__proto__`
656
+ // name on a plain `{}` would hit the Object.prototype setter instead of
657
+ // becoming a data property.
658
+ const key = undici.util.cache.makeCacheKey(
659
+ Array.isArray(opts.headers)
660
+ ? { ...opts, headers: parseHeaders(opts.headers, Object.create(null)) }
661
+ : opts,
662
+ )
264
663
 
265
664
  // makeCacheKey preserves request header names verbatim. Vary selector names
266
- // are lowercased (in onHeaders and matchesValue), so lowercase the key's
665
+ // are lowercased (in CacheHandler and matchesValue), so lowercase the key's
267
666
  // header names once here — the same key feeds both the get and set paths, so
268
667
  // this keeps Vary matching symmetric even when a caller supplies non-lowercase
269
668
  // header names (the standalone interceptors.cache() composition; the wrapped
@@ -279,6 +678,82 @@ export default () => (dispatch) => (opts, handler) => {
279
678
  key.headers = lower
280
679
  }
281
680
 
681
+ // The vendored makeCacheKey ignores opts.query. The wrapped pipeline is
682
+ // immune (the query interceptor rewrites path before the cache sees it),
683
+ // but a standalone interceptors.cache() composition would silently collide
684
+ // distinct query strings onto one entry and serve the wrong response
685
+ // (undici issue #4209 / PR #5081) — fold the query into the key path.
686
+ if (
687
+ opts.query &&
688
+ typeof key.path === 'string' &&
689
+ !key.path.includes('?') &&
690
+ !key.path.includes('#')
691
+ ) {
692
+ const qs = stringify(opts.query)
693
+ if (qs) {
694
+ key.path = `${key.path || '/'}?${qs}`
695
+ }
696
+ }
697
+
698
+ return key
699
+ }
700
+
701
+ function cacheOptsOf(opts) {
702
+ return {
703
+ maxEntrySize: opts.cache.maxEntrySize,
704
+ maxEntryTTL: opts.cache.maxEntryTTL,
705
+ heuristic: opts.cache.heuristic,
706
+ defaultTTL: opts.cache.defaultTTL,
707
+ }
708
+ }
709
+
710
+ export default () => (dispatch) => (opts, handler) => {
711
+ if (!opts.cache || opts.upgrade) {
712
+ return dispatch(opts, handler)
713
+ }
714
+
715
+ // Capture-once per dispatch (log.js style): the same resolved fn drives the
716
+ // `undici:cache` lookup doc and is threaded into CacheHandler /
717
+ // InvalidationHandler for the store/invalidate docs, so a writer flipping
718
+ // mid-request cannot split a dispatch across writers. Resolution cost when
719
+ // tracing is off is one property read plus a typeof check.
720
+ const write = traceWrite(opts.trace)
721
+
722
+ if (opts.method !== 'GET' && opts.method !== 'HEAD') {
723
+ // RFC 9110 §9.2.1: OPTIONS and TRACE are safe — never cached, but they
724
+ // must not invalidate either. Every other method (POST/PUT/DELETE/...)
725
+ // invalidates the target URI on a non-error response (RFC 9111 §4.4).
726
+ if (opts.method === 'OPTIONS' || opts.method === 'TRACE') {
727
+ return dispatch(opts, handler)
728
+ }
729
+
730
+ const store = getStore(opts)
731
+ if (typeof store.delete !== 'function') {
732
+ // User-supplied store without invalidation support.
733
+ return dispatch(opts, handler)
734
+ }
735
+
736
+ const key = makeKey(opts)
737
+ return dispatch(
738
+ opts,
739
+ new InvalidationHandler(key, {
740
+ store,
741
+ logger: opts.logger,
742
+ handler,
743
+ write,
744
+ id: opts.id ?? null,
745
+ url: write !== null ? traceUrl(key) : null,
746
+ }),
747
+ )
748
+ }
749
+
750
+ // TODO (fix): enable range requests
751
+
752
+ const key = makeKey(opts)
753
+ // Bounded url tag shared by every doc this dispatch emits; the key (not raw
754
+ // opts) so the tag reflects the canonical path incl. the folded-in query.
755
+ const url = write !== null ? traceUrl(key) : null
756
+
282
757
  // All request-directive and conditional-header guards below MUST read from
283
758
  // the lowercased key.headers, not raw opts.headers — otherwise a caller
284
759
  // supplying capitalized names (e.g. `Authorization`, `Cache-Control` via the
@@ -287,64 +762,106 @@ export default () => (dispatch) => (opts, handler) => {
287
762
  // cached response to an authorized request (RFC 9111 §3.5).
288
763
  const headers = key.headers ?? {}
289
764
 
290
- // Cache-Control is a list-typed field: duplicated field lines arrive as an
291
- // array and combine into a single comma-separated value per RFC 9110 §5.2,
292
- // keeping the raw-string directive checks below working.
293
- let rawCacheControl = headers['cache-control']
294
- if (Array.isArray(rawCacheControl)) {
295
- rawCacheControl = rawCacheControl.join(', ')
296
- }
297
- const cacheControlDirectives = parseCacheControl(rawCacheControl) ?? {}
298
- // cache-control-parser does not recognise 'only-if-cached', so check the raw string.
299
- const onlyIfCached =
300
- typeof rawCacheControl === 'string' && rawCacheControl.includes('only-if-cached')
765
+ const rawCacheControl = headers['cache-control']
766
+ const requestCacheControl = parseCacheControl(rawCacheControl) ?? {}
301
767
 
302
768
  // RFC 9111 Section 5.4: Pragma: no-cache should be treated as
303
769
  // Cache-Control: no-cache when Cache-Control is absent.
304
770
  if (rawCacheControl == null && headers.pragma === 'no-cache') {
305
- cacheControlDirectives['no-cache'] = true
771
+ requestCacheControl['no-cache'] = true
306
772
  }
307
773
 
308
- if (cacheControlDirectives['no-transform']) {
774
+ if (requestCacheControl['no-transform']) {
309
775
  // Do nothing. We don't transform requests...
310
776
  }
311
777
 
312
- if (
313
- // != null: 'max-age=0' parses to 0 (falsy) but still demands revalidation.
314
- cacheControlDirectives['max-age'] != null ||
315
- cacheControlDirectives['no-cache'] ||
316
- cacheControlDirectives['stale-if-error'] != null ||
317
- // cache-control-parser does not recognise 'max-stale'/'min-fresh', so
318
- // check the raw string like we do for 'only-if-cached'.
319
- (typeof rawCacheControl === 'string' &&
320
- (rawCacheControl.includes('max-stale') || rawCacheControl.includes('min-fresh')))
321
- ) {
322
- // TODO (fix): Support all cache control directives...
323
- return dispatch(opts, handler)
324
- }
325
-
326
- const store =
327
- opts.cache.store ?? (DEFAULT_STORE ??= new SqliteCacheStore({ location: ':memory:' }))
778
+ const onlyIfCached = requestCacheControl['only-if-cached'] === true
779
+ const store = getStore(opts)
328
780
 
781
+ // The lookup is timed only while tracing is on (performance.now() is not
782
+ // free); a store get that throws is caught inside tryGetEntry and settles
783
+ // as a miss with reason 'none'. `missReason` tracks which gate cleared a
784
+ // returned entry so the eventual miss doc names it.
785
+ let missReason = 'none'
786
+ let lookupMs = null
329
787
  let entry
330
- try {
331
- entry = store.get(key)
332
- } catch (err) {
333
- if (err.message === 'database is locked') {
334
- // Database is busy. We don't bother trying again...
335
- opts.logger?.debug({ err }, 'failed to get cache entry')
336
- } else {
337
- opts.logger?.error({ err }, 'failed to get cache entry')
788
+ if (write !== null) {
789
+ const lookupStart = performance.now()
790
+ entry = tryGetEntry(store, key, opts.logger)
791
+ lookupMs = Math.round(performance.now() - lookupStart)
792
+ } else {
793
+ entry = tryGetEntry(store, key, opts.logger)
794
+ }
795
+
796
+ // RFC 9111 §3.5 serve-side authorization gate: a shared cache must not
797
+ // reuse a stored response for a request with Authorization unless the
798
+ // response allowed it (public, s-maxage or must-revalidate — the mirror of
799
+ // the store-side gate in CacheHandler; both sites must stay in lockstep).
800
+ if (entry && headers.authorization != null) {
801
+ const directives = entry.cacheControlDirectives
802
+ if (
803
+ typeof headers.authorization !== 'string' ||
804
+ !(
805
+ directives?.public === true ||
806
+ directives?.['s-maxage'] != null ||
807
+ directives?.['must-revalidate'] === true
808
+ )
809
+ ) {
810
+ entry = undefined
811
+ missReason = 'auth'
338
812
  }
339
813
  }
340
814
 
341
- // RFC 9111 Section 3.5: A shared cache must not use a cached response to a
342
- // request with Authorization unless the response includes a public directive.
343
- if (entry && headers.authorization && !entry.cacheControlDirectives?.public) {
344
- entry = undefined
815
+ const cacheHandler = () =>
816
+ new CacheHandler(key, {
817
+ ...cacheOptsOf(opts),
818
+ store,
819
+ logger: opts.logger,
820
+ handler,
821
+ write,
822
+ id: opts.id ?? null,
823
+ url,
824
+ })
825
+
826
+ // Request Cache-Control directives that this cache does not evaluate locally
827
+ // (a follow-up adds conditional revalidation and local evaluation) cause a
828
+ // bypass to the origin. These constrain REUSE of a stored response, not the
829
+ // storage of a fresh one — so the bypass still writes the origin response
830
+ // back through CacheHandler for later callers (undici PR #5510), unless the
831
+ // request's own no-store forbids storing. only-if-cached is the exception:
832
+ // it forbids contacting the origin, so it is handled from the cache below
833
+ // instead of bypassing.
834
+ const bypass =
835
+ !onlyIfCached &&
836
+ // != null: 'max-age=0' parses to 0 (falsy) but still demands revalidation.
837
+ (requestCacheControl['max-age'] != null ||
838
+ requestCacheControl['no-cache'] === true ||
839
+ requestCacheControl['stale-if-error'] != null ||
840
+ requestCacheControl['max-stale'] != null ||
841
+ requestCacheControl['min-fresh'] != null)
842
+
843
+ if (bypass) {
844
+ if (write !== null) {
845
+ // Name the (first, in evaluation order) directive that forced the
846
+ // bypass; the fallthrough is min-fresh by construction of `bypass`.
847
+ const reason =
848
+ requestCacheControl['max-age'] != null
849
+ ? 'max-age'
850
+ : requestCacheControl['no-cache'] === true
851
+ ? 'no-cache'
852
+ : requestCacheControl['stale-if-error'] != null
853
+ ? 'stale-if-error'
854
+ : requestCacheControl['max-stale'] != null
855
+ ? 'max-stale'
856
+ : 'min-fresh'
857
+ traceLookup(write, opts, url, 'bypass', reason, null, null, null, lookupMs)
858
+ }
859
+ return dispatch(opts, requestCacheControl['no-store'] ? handler : cacheHandler())
345
860
  }
346
861
 
347
- // RFC 9110 Section 13: Evaluate conditional request headers against cached entry.
862
+ // RFC 9110 Section 13: evaluate conditional request headers against the
863
+ // cached entry. The store only returns entries that are still fresh
864
+ // (deleteAt === staleAt), so a returned entry is always servable.
348
865
  // typeof guards: duplicated conditional headers arrive as arrays — treat
349
866
  // them as non-matching and bypass to origin rather than crashing.
350
867
  if (entry && headers['if-none-match']) {
@@ -357,10 +874,14 @@ export default () => (dispatch) => (opts, handler) => {
357
874
  { statusCode: 304, headers: entry.headers, cachedAt: entry.cachedAt },
358
875
  opts,
359
876
  handler,
877
+ write,
878
+ url,
879
+ lookupMs,
360
880
  )
361
881
  }
362
882
  // Etag didn't match — bypass to origin.
363
883
  entry = undefined
884
+ missReason = 'etag'
364
885
  } else if (entry && headers['if-modified-since']) {
365
886
  const lastModified = entry.headers?.['last-modified']
366
887
  if (
@@ -372,50 +893,81 @@ export default () => (dispatch) => (opts, handler) => {
372
893
  { statusCode: 304, headers: entry.headers, cachedAt: entry.cachedAt },
373
894
  opts,
374
895
  handler,
896
+ write,
897
+ url,
898
+ lookupMs,
375
899
  )
376
900
  }
377
901
  // No last-modified or modified since — bypass to origin.
378
902
  entry = undefined
903
+ missReason = 'modified'
379
904
  }
380
905
 
381
906
  if (headers['if-match'] || headers['if-unmodified-since'] || headers['if-range']) {
382
907
  // TODO (fix): evaluate these conditional headers against cached entry.
908
+ if (write !== null) {
909
+ traceLookup(write, opts, url, 'bypass', 'conditional', null, null, null, lookupMs)
910
+ }
383
911
  return dispatch(opts, handler)
384
912
  }
385
913
 
386
914
  if (!entry && !onlyIfCached) {
387
- return dispatch(
388
- opts,
389
- cacheControlDirectives['no-store']
390
- ? handler
391
- : new CacheHandler(key, {
392
- maxEntrySize: opts.cache.maxEntrySize,
393
- maxEntryTTL: opts.cache.maxEntryTTL,
394
- store,
395
- logger: opts.logger,
396
- handler,
397
- }),
398
- )
915
+ if (write !== null) {
916
+ traceLookup(write, opts, url, 'miss', missReason, null, null, null, lookupMs)
917
+ }
918
+ // A miss keeps the CacheHandler write-back unless the request's no-store
919
+ // forbids storing.
920
+ return dispatch(opts, requestCacheControl['no-store'] ? handler : cacheHandler())
399
921
  }
400
922
 
401
- return serveFromCache(entry ?? { statusCode: 504 }, opts, handler)
923
+ // A hit (fresh, per the store) is served; only-if-cached with no usable
924
+ // entry yields 504 (RFC 9111 §5.2.1.7) — the cache could NOT satisfy the
925
+ // request, so its doc must not pollute hit-rate aggregations: it is a miss
926
+ // the request forbade going to origin for.
927
+ return entry
928
+ ? serveFromCache(entry, opts, handler, write, url, lookupMs)
929
+ : serveFromCache(
930
+ { statusCode: 504 },
931
+ opts,
932
+ handler,
933
+ write,
934
+ url,
935
+ lookupMs,
936
+ 'miss',
937
+ 'only-if-cached',
938
+ )
402
939
  }
403
940
 
404
- function serveFromCache(entry, opts, handler) {
941
+ /**
942
+ * @param {'hit' | 'miss'} [result] lookup outcome for the undici:cache doc
943
+ * @param {string | null} [reason]
944
+ */
945
+ function serveFromCache(entry, opts, handler, write, url, lookupMs, result = 'hit', reason = null) {
405
946
  const { statusCode, trailers, body } = entry
406
947
 
407
948
  let headers = entry.headers
949
+ let age = null
408
950
  if (entry.cachedAt != null) {
409
- // RFC 9111 §5.1: every response served from cache must carry an Age header
410
- // reflecting time spent in this cache plus any age it arrived with —
411
- // otherwise downstream caches treat it as fresh-from-origin.
412
- // getFastNow has 1s resolution — Age is whole seconds, so that's enough.
413
- const residentAge = Math.max(0, Math.floor((getFastNow() - entry.cachedAt) / 1000))
414
- const originAge = Number(headers?.age)
415
- const age = Number.isFinite(originAge) && originAge > 0 ? originAge + residentAge : residentAge
951
+ // RFC 9111 §5.1: every response served from cache must carry an Age
952
+ // header. cachedAt is backdated by the corrected initial age at store
953
+ // time (§4.2.3) and the origin's Age header is stripped, so resident time
954
+ // IS the response's ageno origin-Age addition. Date.now(), not
955
+ // getFastNow(): the lagging clock would understate a relayed response's
956
+ // initial age by up to a second.
957
+ age = Math.max(0, Math.floor((Date.now() - entry.cachedAt) / 1000))
416
958
  headers = { ...headers, age: `${age}` }
417
959
  }
418
960
 
961
+ // Entry serves and conditional 304s are lookup hits; the only-if-cached
962
+ // synthetic 504 arrives as result 'miss' from the caller. Emitted before
963
+ // onConnect and outside the try/catch below so trace code sits strictly
964
+ // outside the handler-contract enforcement (traceSafe cannot throw, but
965
+ // keep it out of that window anyway). Synthetic entries have no cachedAt,
966
+ // so their ageSec stays null.
967
+ if (write !== null) {
968
+ traceLookup(write, opts, url, result, reason, statusCode, age, body?.byteLength ?? 0, lookupMs)
969
+ }
970
+
419
971
  // serveFromCache drives the raw user handler directly (no DecoratorHandler),
420
972
  // so it must enforce the contract itself: onError is terminal and mutually
421
973
  // exclusive with onComplete. The `completed` guard makes a late abort() a