@nxtedition/nxt-undici 7.4.2 → 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.
package/lib/index.d.ts CHANGED
@@ -134,8 +134,15 @@ export interface ProxyOptions {
134
134
  export interface CacheOptions {
135
135
  store?: CacheStore
136
136
  maxEntrySize?: number
137
- /** Upper bound on an entry's freshness lifetime, in seconds (default 30 days). */
137
+ /** Upper bound on an entry's freshness lifetime AND retention, in seconds
138
+ * (default 30 days). */
138
139
  maxEntryTTL?: number
140
+ /** Opt-in RFC 9111 §4.2.2 heuristic freshness (10% of time since
141
+ * Last-Modified) for 200 responses without explicit expiration. */
142
+ heuristic?: boolean
143
+ /** Opt-in fallback freshness lifetime in seconds for 200 responses without
144
+ * any expiration information. */
145
+ defaultTTL?: number
139
146
  }
140
147
 
141
148
  export interface VerifyOptions {
@@ -245,6 +252,8 @@ export interface CacheValue {
245
252
  etag?: string
246
253
  vary?: Record<string, string | string[]>
247
254
  cachedAt: number
255
+ /** Optional on set(): omitting it defaults to deleteAt (staleAt === deleteAt). */
256
+ staleAt?: number
248
257
  deleteAt?: number
249
258
  }
250
259
 
@@ -257,6 +266,7 @@ export interface CacheGetResult {
257
266
  cacheControlDirectives?: Record<string, unknown>
258
267
  vary?: Record<string, string | string[]>
259
268
  cachedAt: number
269
+ staleAt: number
260
270
  deleteAt: number
261
271
  }
262
272
 
@@ -266,6 +276,9 @@ export interface CacheStore {
266
276
  key: CacheKey,
267
277
  value: CacheValue & { body: null | Buffer | Buffer[]; start: number; end: number },
268
278
  ): void
279
+ /** RFC 9111 §4.4 invalidation — optional; the cache interceptor
280
+ * feature-detects it and skips invalidation when absent. */
281
+ delete?(key: CacheKey): void
269
282
  gc(): void
270
283
  clear(): void
271
284
  close(): void
@@ -338,6 +351,8 @@ export class SqliteCacheStore implements CacheStore {
338
351
  key: CacheKey,
339
352
  value: CacheValue & { body: null | Buffer | Buffer[]; start: number; end: number },
340
353
  ): void
354
+ /** RFC 9111 §4.4: invalidates every stored response for the key's URI. */
355
+ delete(key: CacheKey): void
341
356
  gc(): void
342
357
  clear(): void
343
358
  close(): void
@@ -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,11 +260,14 @@ 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
 
@@ -144,26 +294,23 @@ class CacheHandler extends DecoratorHandler {
144
294
  }
145
295
  }
146
296
 
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),
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,
155
305
  )
156
- if (!ttl || !Number.isFinite(ttl) || ttl <= 0) {
306
+ if (lifetimeInfo == null) {
157
307
  return super.onHeaders(statusCode, headers, resume)
158
308
  }
159
309
 
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.
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) {
167
314
  return super.onHeaders(statusCode, headers, resume)
168
315
  }
169
316
 
@@ -175,7 +322,6 @@ class CacheHandler extends DecoratorHandler {
175
322
  const end = contentRange ? contentRange.end : this.#key.method === 'HEAD' ? 0 : contentLength
176
323
 
177
324
  if (end == null || end - start <= this.#maxEntrySize) {
178
- const cachedAt = Date.now()
179
325
  // Snapshot the headers: the same object is delivered downstream to the
180
326
  // caller (request() resolves with it before the body finishes), and the
181
327
  // entry isn't serialized to the store until onComplete. Without a copy,
@@ -188,23 +334,58 @@ class CacheHandler extends DecoratorHandler {
188
334
  // plain `{}` a header literally named `__proto__` would hit the
189
335
  // Object.prototype setter instead of becoming a data property (silent
190
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
+
191
367
  const storedHeaders = Object.create(null)
192
368
  for (const name of Object.keys(headers)) {
369
+ if (isHopByHop(name) || excludedHeaders.has(name.toLowerCase())) {
370
+ continue
371
+ }
193
372
  const val = headers[name]
194
373
  storedHeaders[name] = Array.isArray(val) ? val.slice() : val
195
374
  }
375
+
196
376
  this.#value = {
197
377
  body: [],
198
378
  start,
199
379
  end,
200
- deleteAt: cachedAt + lifetime * 1e3,
380
+ cachedAt: times.cachedAt,
381
+ staleAt: times.staleAt,
382
+ deleteAt: times.deleteAt,
201
383
  statusCode,
202
384
  statusMessage: '',
203
385
  headers: storedHeaders,
204
386
  cacheControlDirectives,
205
- etag: isEtagUsable(headers.etag) ? headers.etag : '',
387
+ etag,
206
388
  vary,
207
- cachedAt,
208
389
  // Handler state.
209
390
  size: 0,
210
391
  }
@@ -246,24 +427,114 @@ class CacheHandler extends DecoratorHandler {
246
427
  }
247
428
  }
248
429
 
249
- export default () => (dispatch) => (opts, handler) => {
250
- if (!opts.cache || opts.upgrade) {
251
- 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
252
447
  }
253
448
 
254
- if (opts.method !== 'GET' && opts.method !== 'HEAD') {
255
- 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)
256
464
  }
257
465
 
258
- // TODO (fix): enable range requests
466
+ #invalidate(headers) {
467
+ this.#store.delete(this.#key)
259
468
 
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
+ }
479
+
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
+ }
490
+
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
+ }
497
+ }
498
+ }
499
+
500
+ function getStore(opts) {
501
+ return opts.cache.store ?? (DEFAULT_STORE ??= new SqliteCacheStore({ location: ':memory:' }))
502
+ }
503
+
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
+ }
516
+
517
+ /**
518
+ * Builds the cache key shared by the get and set paths.
519
+ */
520
+ function makeKey(opts) {
260
521
  // Build the key the same way for lookups and stores: makeCacheKey
261
522
  // stringifies the origin (e.g. URL objects), so using raw opts on the get
262
523
  // path while the set path normalizes would make the cache permanently miss.
263
- 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
+ )
264
535
 
265
536
  // makeCacheKey preserves request header names verbatim. Vary selector names
266
- // are lowercased (in onHeaders and matchesValue), so lowercase the key's
537
+ // are lowercased (in CacheHandler and matchesValue), so lowercase the key's
267
538
  // header names once here — the same key feeds both the get and set paths, so
268
539
  // this keeps Vary matching symmetric even when a caller supplies non-lowercase
269
540
  // header names (the standalone interceptors.cache() composition; the wrapped
@@ -279,6 +550,64 @@ export default () => (dispatch) => (opts, handler) => {
279
550
  key.headers = lower
280
551
  }
281
552
 
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}`
567
+ }
568
+ }
569
+
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)
585
+ }
586
+
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
+
282
611
  // All request-directive and conditional-header guards below MUST read from
283
612
  // the lowercased key.headers, not raw opts.headers — otherwise a caller
284
613
  // supplying capitalized names (e.g. `Authorization`, `Cache-Control` via the
@@ -287,64 +616,74 @@ export default () => (dispatch) => (opts, handler) => {
287
616
  // cached response to an authorized request (RFC 9111 §3.5).
288
617
  const headers = key.headers ?? {}
289
618
 
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')
619
+ const rawCacheControl = headers['cache-control']
620
+ const requestCacheControl = parseCacheControl(rawCacheControl) ?? {}
301
621
 
302
622
  // RFC 9111 Section 5.4: Pragma: no-cache should be treated as
303
623
  // Cache-Control: no-cache when Cache-Control is absent.
304
624
  if (rawCacheControl == null && headers.pragma === 'no-cache') {
305
- cacheControlDirectives['no-cache'] = true
625
+ requestCacheControl['no-cache'] = true
306
626
  }
307
627
 
308
- if (cacheControlDirectives['no-transform']) {
628
+ if (requestCacheControl['no-transform']) {
309
629
  // Do nothing. We don't transform requests...
310
630
  }
311
631
 
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
- }
632
+ const onlyIfCached = requestCacheControl['only-if-cached'] === true
633
+ const store = getStore(opts)
325
634
 
326
- const store =
327
- opts.cache.store ?? (DEFAULT_STORE ??= new SqliteCacheStore({ location: ':memory:' }))
635
+ let entry = tryGetEntry(store, key, opts.logger)
328
636
 
329
- 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')
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
338
652
  }
339
653
  }
340
654
 
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
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())
345
682
  }
346
683
 
347
- // RFC 9110 Section 13: Evaluate conditional request headers against cached entry.
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.
348
687
  // typeof guards: duplicated conditional headers arrive as arrays — treat
349
688
  // them as non-matching and bypass to origin rather than crashing.
350
689
  if (entry && headers['if-none-match']) {
@@ -384,20 +723,13 @@ export default () => (dispatch) => (opts, handler) => {
384
723
  }
385
724
 
386
725
  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
- )
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())
399
729
  }
400
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).
401
733
  return serveFromCache(entry ?? { statusCode: 504 }, opts, handler)
402
734
  }
403
735
 
@@ -406,13 +738,13 @@ function serveFromCache(entry, opts, handler) {
406
738
 
407
739
  let headers = entry.headers
408
740
  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
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))
416
748
  headers = { ...headers, age: `${age}` }
417
749
  }
418
750
 
@@ -108,10 +108,13 @@ function eqiLower(a, b) {
108
108
  // allocation-free and letting the common (non-hop) header bail out in a single
109
109
  // comparison.
110
110
  /**
111
+ * Exported for reuse by the cache interceptor: RFC 9111 §3.1 forbids storing
112
+ * hop-by-hop fields, which is the same set a proxy must not retransmit.
113
+ *
111
114
  * @param {string} key
112
115
  * @returns {boolean}
113
116
  */
114
- function isHopByHop(key) {
117
+ export function isHopByHop(key) {
115
118
  switch (key.length) {
116
119
  case 2:
117
120
  return eqiLower(key, 'te')
@@ -2,7 +2,7 @@ import { DatabaseSync } from 'node:sqlite'
2
2
  import { parseRangeHeader, getFastNow } from './utils.js'
3
3
 
4
4
  // Bump version when the URL key format or schema changes to invalidate old caches.
5
- const VERSION = 10
5
+ const VERSION = 11
6
6
 
7
7
  // Registry of live stores so process-level broadcasts (nxt:offPeak,
8
8
  // nxt:clearCache) can reach them. Stores are held via WeakRef so that a store
@@ -51,8 +51,9 @@ function forEachStore(fn) {
51
51
  }
52
52
 
53
53
  /**
54
- * @typedef {import('undici-types/cache-interceptor.d.ts').default.CacheStore} CacheStore
55
- * @implements {CacheStore}
54
+ * Synchronous get/set/delete cache store backed by node:sqlite. Note: this is
55
+ * NOT undici's stream-based CacheStore interface (createWriteStream); the
56
+ * cache interceptor in this package drives it directly.
56
57
  *
57
58
  * @typedef {{
58
59
  * id: Readonly<number>,
@@ -66,6 +67,7 @@ function forEachStore(fn) {
66
67
  * etag?: string
67
68
  * cacheControlDirectives?: string
68
69
  * cachedAt: number
70
+ * staleAt: number
69
71
  * deleteAt: number
70
72
  * }} SqliteStoreValue
71
73
  */
@@ -100,6 +102,21 @@ export class SqliteCacheStore {
100
102
  */
101
103
  #evictQuery
102
104
 
105
+ /**
106
+ * @type {import('node:sqlite').StatementSync}
107
+ */
108
+ #deleteByUrlQuery
109
+
110
+ /**
111
+ * @type {import('node:sqlite').StatementSync}
112
+ */
113
+ #supersedeFullQuery
114
+
115
+ /**
116
+ * @type {import('node:sqlite').StatementSync}
117
+ */
118
+ #supersede206Query
119
+
103
120
  #insertBatch = []
104
121
  #insertSeq = 0
105
122
  #closed = false
@@ -136,6 +153,7 @@ export class SqliteCacheStore {
136
153
  body BLOB NULL,
137
154
  start INTEGER NOT NULL,
138
155
  end INTEGER NOT NULL,
156
+ staleAt INTEGER NOT NULL,
139
157
  deleteAt INTEGER NOT NULL,
140
158
  statusCode INTEGER NOT NULL,
141
159
  statusMessage TEXT NOT NULL,
@@ -148,6 +166,12 @@ export class SqliteCacheStore {
148
166
 
149
167
  CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_getValuesQuery ON cacheInterceptorV${VERSION}(url, method, start, deleteAt);
150
168
  CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_deleteExpiredValuesQuery ON cacheInterceptorV${VERSION}(deleteAt);
169
+ -- Covers the supersede-on-flush DELETEs (#supersedeFullQuery /
170
+ -- #supersede206Query), whose predicate is url+method+vary (+statusCode,
171
+ -- and start/end for 206) — so a re-cache supersedes via an index seek
172
+ -- instead of scanning every row for a hot url+method with many Vary
173
+ -- variants or partial windows.
174
+ CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_supersedeQuery ON cacheInterceptorV${VERSION}(url, method, vary, statusCode, start, end);
151
175
  `)
152
176
 
153
177
  // Drop tables left behind by previous schema versions. gc(), clear() and
@@ -200,6 +224,7 @@ export class SqliteCacheStore {
200
224
  body,
201
225
  start,
202
226
  end,
227
+ staleAt,
203
228
  deleteAt,
204
229
  statusCode,
205
230
  statusMessage,
@@ -215,7 +240,7 @@ export class SqliteCacheStore {
215
240
  AND start <= ?
216
241
  AND deleteAt > ?
217
242
  ORDER BY
218
- cachedAt DESC, id DESC
243
+ id DESC
219
244
  `)
220
245
 
221
246
  this.#insertValueQuery = this.#db.prepare(`
@@ -225,6 +250,7 @@ export class SqliteCacheStore {
225
250
  body,
226
251
  start,
227
252
  end,
253
+ staleAt,
228
254
  deleteAt,
229
255
  statusCode,
230
256
  statusMessage,
@@ -233,7 +259,7 @@ export class SqliteCacheStore {
233
259
  cacheControlDirectives,
234
260
  vary,
235
261
  cachedAt
236
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
262
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
237
263
  `)
238
264
 
239
265
  this.#deleteExpiredValuesQuery = this.#db.prepare(
@@ -246,6 +272,35 @@ export class SqliteCacheStore {
246
272
  `DELETE FROM cacheInterceptorV${VERSION} WHERE id IN (SELECT id FROM cacheInterceptorV${VERSION} ORDER BY deleteAt ASC LIMIT ?)`,
247
273
  )
248
274
 
275
+ // RFC 9111 §4.4 invalidation: a successful unsafe request invalidates every
276
+ // stored response for the URI, across methods and vary variants.
277
+ this.#deleteByUrlQuery = this.#db.prepare(
278
+ `DELETE FROM cacheInterceptorV${VERSION} WHERE url = ?`,
279
+ )
280
+
281
+ // Supersede queries: a newly flushed row replaces prior rows for the same
282
+ // representation so hot keys don't accumulate dead duplicates until gc.
283
+ // Receipt order (insertion order), NOT cachedAt, decides who wins — the
284
+ // same semantics as upstream undici's update-in-place set(). cachedAt is
285
+ // backdated by the corrected initial age (RFC 9111 §4.2.3), so a
286
+ // replacement fetched through a relay advertising a large Age (or a 304
287
+ // freshening with a skewed origin Date) can carry an OLDER cachedAt than
288
+ // the stale row it replaces; keying supersede or the read sort on
289
+ // cachedAt would let the stale row win every read, forever. `vary IS ?`
290
+ // is NULL-safe and compares the serialized-JSON text, deterministic
291
+ // because both sides are produced by the same CacheHandler code path
292
+ // (key order = Vary header order). A new full (non-206) representation
293
+ // supersedes all prior full ones regardless of byte window; a 206 only
294
+ // supersedes the exact same window so distinct partials coexist, and
295
+ // never a 200 row (matchesValue routes range and non-range requests to
296
+ // different rows).
297
+ this.#supersedeFullQuery = this.#db.prepare(
298
+ `DELETE FROM cacheInterceptorV${VERSION} WHERE url = ? AND method = ? AND vary IS ? AND statusCode != 206`,
299
+ )
300
+ this.#supersede206Query = this.#db.prepare(
301
+ `DELETE FROM cacheInterceptorV${VERSION} WHERE url = ? AND method = ? AND vary IS ? AND statusCode = 206 AND start = ? AND end = ?`,
302
+ )
303
+
249
304
  this.#ref = new WeakRef(this)
250
305
  stores.add(this.#ref)
251
306
  // The store itself doubles as the unregister token so close() can drop
@@ -371,7 +426,7 @@ export class SqliteCacheStore {
371
426
  setImmediate(this.#flush)
372
427
  }
373
428
 
374
- this.#insertBatch.push({
429
+ const entry = {
375
430
  // Monotonic per-store sequence used only to break cachedAt ties in
376
431
  // #findValue (newest write wins). Not persisted — #flush ignores it.
377
432
  seq: this.#insertSeq++,
@@ -380,6 +435,9 @@ export class SqliteCacheStore {
380
435
  body,
381
436
  start: value.start,
382
437
  end: value.end,
438
+ // staleAt column is NOT NULL; default to deleteAt when the caller omits
439
+ // it (pre-v11 value shape) — matches this package's staleAt === deleteAt.
440
+ staleAt: value.staleAt ?? value.deleteAt,
383
441
  deleteAt: value.deleteAt,
384
442
  statusCode: value.statusCode,
385
443
  statusMessage: value.statusMessage,
@@ -390,7 +448,56 @@ export class SqliteCacheStore {
390
448
  : null,
391
449
  vary: value.vary ? JSON.stringify(value.vary) : null,
392
450
  cachedAt: value.cachedAt,
393
- })
451
+ }
452
+
453
+ // Coalesce within the pending batch: a stampede of identical misses would
454
+ // otherwise commit N identical rows. Receipt order wins (see the
455
+ // #supersede* query comments): the entry being added supersedes every
456
+ // pending entry for the same representation.
457
+ for (let i = this.#insertBatch.length - 1; i >= 0; i--) {
458
+ const other = this.#insertBatch[i]
459
+ if (
460
+ other.url === entry.url &&
461
+ other.method === entry.method &&
462
+ other.vary === entry.vary &&
463
+ (entry.statusCode !== 206
464
+ ? other.statusCode !== 206
465
+ : other.statusCode === 206 && other.start === entry.start && other.end === entry.end)
466
+ ) {
467
+ this.#insertBatch.splice(i, 1)
468
+ }
469
+ }
470
+
471
+ this.#insertBatch.push(entry)
472
+ }
473
+
474
+ /**
475
+ * Invalidates every stored response for the key's URI (RFC 9111 §4.4) —
476
+ * across methods and vary variants. Pending batched inserts for the URI are
477
+ * dropped as well: a batch entry flushed after the DELETE would otherwise
478
+ * resurrect the invalidated response. Note this only covers THIS process's
479
+ * batch — with a shared on-disk store, another process's in-flight batch
480
+ * can still transiently resurrect an entry (bounded by its ~10ms flush
481
+ * slice); cross-process invalidation is inherently best-effort.
482
+ *
483
+ * @param {import('undici-types/cache-interceptor.d.ts').default.CacheKey} key
484
+ */
485
+ delete(key) {
486
+ assertCacheKey(key)
487
+
488
+ if (this.#closed) {
489
+ return
490
+ }
491
+
492
+ const url = makeValueUrl(key)
493
+
494
+ for (let i = this.#insertBatch.length - 1; i >= 0; i--) {
495
+ if (this.#insertBatch[i].url === url) {
496
+ this.#insertBatch.splice(i, 1)
497
+ }
498
+ }
499
+
500
+ this.#deleteByUrlQuery.run(url)
394
501
  }
395
502
 
396
503
  #flush = (final = false) => {
@@ -412,6 +519,7 @@ export class SqliteCacheStore {
412
519
  body,
413
520
  start,
414
521
  end,
522
+ staleAt,
415
523
  deleteAt,
416
524
  statusCode,
417
525
  statusMessage,
@@ -421,12 +529,21 @@ export class SqliteCacheStore {
421
529
  vary,
422
530
  cachedAt,
423
531
  } = this.#insertBatch[n++]
532
+ // Supersede prior rows for the same representation (see the
533
+ // #supersede* query comments) inside the same transaction, so a
534
+ // re-cache replaces instead of accumulating.
535
+ if (statusCode === 206) {
536
+ this.#supersede206Query.run(url, method, vary, start, end)
537
+ } else {
538
+ this.#supersedeFullQuery.run(url, method, vary)
539
+ }
424
540
  this.#insertValueQuery.run(
425
541
  url,
426
542
  method,
427
543
  body,
428
544
  start,
429
545
  end,
546
+ staleAt,
430
547
  deleteAt,
431
548
  statusCode,
432
549
  statusMessage,
@@ -534,16 +651,14 @@ export class SqliteCacheStore {
534
651
  return undefined
535
652
  }
536
653
 
537
- // Newest representation wins. cachedAt is millisecond-resolution, so a
538
- // re-cache within the same millisecond produces a tie; break it
539
- // deterministically toward the freshest write: pending batch entries
540
- // (tagged with a monotonic seq) are always newer than any flushed DB row,
541
- // and within each source a higher seq/id wins.
654
+ // Most recently WRITTEN representation wins receipt order, not
655
+ // cachedAt (see the #supersede* query comments: cachedAt is backdated by
656
+ // the corrected initial age, so a fresher write can carry an older
657
+ // cachedAt). Pending batch entries (tagged with a monotonic seq) are
658
+ // always newer than any flushed DB row, and within each source a higher
659
+ // seq/id wins.
542
660
  if (values.length > 1) {
543
661
  values.sort((a, b) => {
544
- if (a.cachedAt !== b.cachedAt) {
545
- return b.cachedAt - a.cachedAt
546
- }
547
662
  const aBatch = a.seq != null
548
663
  const bBatch = b.seq != null
549
664
  if (aBatch !== bBatch) {
@@ -657,6 +772,7 @@ function makeResult(value) {
657
772
  ? JSON.parse(value.cacheControlDirectives)
658
773
  : undefined,
659
774
  cachedAt: value.cachedAt,
775
+ staleAt: value.staleAt,
660
776
  deleteAt: value.deleteAt,
661
777
  }
662
778
  }
@@ -704,6 +820,16 @@ function assertCacheValue(value) {
704
820
  }
705
821
  }
706
822
 
823
+ // staleAt is optional for backward compatibility: pre-v11 callers of the
824
+ // public SqliteCacheStore.set() omit it, and set() defaults it to deleteAt
825
+ // (this package's staleAt === deleteAt semantics). Validate the type only
826
+ // when supplied.
827
+ if (value.staleAt !== undefined && typeof value.staleAt !== 'number') {
828
+ throw new TypeError(
829
+ `expected value.staleAt to be number, got ${printType(value.staleAt)} [${value.staleAt}]`,
830
+ )
831
+ }
832
+
707
833
  if (typeof value.statusMessage !== 'string') {
708
834
  throw new TypeError(
709
835
  `expected value.statusMessage to be string, got ${printType(value.statusMessage)} [${value.statusMessage}]`,
package/lib/utils.js CHANGED
@@ -1,4 +1,3 @@
1
- import cacheControlParser from 'cache-control-parser'
2
1
  import stream from 'node:stream'
3
2
  import assert from 'node:assert'
4
3
  import { util } from '@nxtedition/undici'
@@ -14,14 +13,295 @@ export function getFastNow() {
14
13
  return fastNow
15
14
  }
16
15
 
17
- export function parseCacheControl(str) {
18
- if (Array.isArray(str)) {
19
- // Cache-Control is a list-typed field, so duplicated field lines are legal
20
- // (e.g. CDN + origin each adding one) and combine into a single
21
- // comma-separated value per RFC 9110 §5.2.
22
- str = str.join(', ')
16
+ /**
17
+ * Vendored from undici's parseCacheControlHeader (lib/util/cache.js) with two
18
+ * deliberate deviations, both toward the conservative reading of RFC 9111:
19
+ * - duplicated max-age keeps the SMALLER value (§4.2.1 says a cache is free to
20
+ * pick, so pick the one that revalidates sooner), upstream keeps the larger;
21
+ * - a bare (valueless) max-stale is represented as Infinity per §5.2.1.2
22
+ * ("willing to accept a stale response of any age"), upstream drops it.
23
+ *
24
+ * Qualified no-cache/private (e.g. `no-cache="set-cookie"`) parse to an array
25
+ * of the listed field names; the unqualified forms parse to `true`. Callers
26
+ * that mean "unqualified" must check `=== true`, not truthiness.
27
+ *
28
+ * Keeps the historical wrapper contract: '', [], and non-strings return null
29
+ * (call sites rely on `?? {}`); an array of field lines is accepted directly
30
+ * (Cache-Control is list-typed, duplicated lines are legal per RFC 9110 §5.2).
31
+ *
32
+ * @param {string | string[] | null | undefined} header
33
+ * @returns {Record<string, boolean | number | string[]> | null}
34
+ */
35
+ export function parseCacheControl(header) {
36
+ let directives
37
+ if (Array.isArray(header)) {
38
+ directives = []
39
+ for (const line of header) {
40
+ if (typeof line !== 'string') {
41
+ return null
42
+ }
43
+ directives.push(...line.split(','))
44
+ }
45
+ if (directives.length === 0) {
46
+ return null
47
+ }
48
+ } else if (header && typeof header === 'string') {
49
+ directives = header.split(',')
50
+ } else {
51
+ return null
52
+ }
53
+
54
+ const output = {}
55
+
56
+ for (let i = 0; i < directives.length; i++) {
57
+ const directive = directives[i].toLowerCase()
58
+ const keyValueDelimiter = directive.indexOf('=')
59
+
60
+ let key
61
+ let value
62
+ if (keyValueDelimiter !== -1) {
63
+ key = directive.substring(0, keyValueDelimiter).trim()
64
+ value = directive.substring(keyValueDelimiter + 1)
65
+ } else {
66
+ key = directive.trim()
67
+ }
68
+
69
+ switch (key) {
70
+ case 'min-fresh':
71
+ case 'max-stale':
72
+ case 'max-age':
73
+ case 's-maxage':
74
+ case 'stale-while-revalidate':
75
+ case 'stale-if-error': {
76
+ if (value === undefined) {
77
+ if (key === 'max-stale') {
78
+ // Bare max-stale: any staleness is acceptable (RFC 9111
79
+ // §5.2.1.2). Number semantics keep call-site math
80
+ // (`staleAt + max-stale * 1000`) working unchanged.
81
+ output[key] = Infinity
82
+ }
83
+ continue
84
+ }
85
+
86
+ // RFC 9110 §5.6.3: a recipient may remove BWS/OWS around the value, so
87
+ // tolerate whitespace (`max-age= 60`, `max-age=60 `) rather than
88
+ // dropping the directive.
89
+ value = value.trim()
90
+
91
+ if (value.length >= 2 && value[0] === '"' && value[value.length - 1] === '"') {
92
+ value = value.substring(1, value.length - 1)
93
+ }
94
+
95
+ // RFC 9111 delta-seconds are 1*DIGIT: require the whole value to be
96
+ // digits so malformed inputs (`max-age=60junk`, `-1`, `1.5`) are
97
+ // dropped rather than parseInt-coerced into freshness math.
98
+ if (!/^\d+$/.test(value)) {
99
+ continue
100
+ }
101
+ const parsedValue = parseInt(value, 10)
102
+
103
+ if (key === 'max-age' && key in output && output[key] <= parsedValue) {
104
+ // Duplicate max-age: keep the smaller (sooner-stale) value.
105
+ continue
106
+ }
107
+
108
+ output[key] = parsedValue
109
+
110
+ break
111
+ }
112
+ case 'private':
113
+ case 'no-cache': {
114
+ if (value) {
115
+ // Qualified form: a quoted, possibly comma-separated list of field
116
+ // names (`no-cache="set-cookie, warning"`). The split-by-comma above
117
+ // may have cut the quoted list apart, so scan forward until the part
118
+ // that carries the closing quote. https://www.rfc-editor.org/rfc/rfc9111.html#name-no-cache-2
119
+ //
120
+ // The unqualified form is strictly more restrictive (forbids
121
+ // storing / demands validation for the WHOLE response), so when
122
+ // both forms appear (`private, private="x"` — invalid but seen in
123
+ // the wild), the qualified form must never clobber the `true`.
124
+ value = value.trim()
125
+ if (value[0] === '"') {
126
+ const headerNames = [value.substring(1)]
127
+
128
+ // length > 1: a lone `"` is an opening quote, not a closed list.
129
+ let foundEndingQuote = value.length > 1 && value[value.length - 1] === '"'
130
+ if (!foundEndingQuote) {
131
+ for (let j = i + 1; j < directives.length; j++) {
132
+ // Trim before the closing-quote check: optional whitespace
133
+ // between the quote and the next comma (`no-cache="a, b" ,x`)
134
+ // must not defeat the scan.
135
+ const nextPart = directives[j].trim()
136
+ headerNames.push(nextPart)
137
+ if (nextPart.length !== 0 && nextPart[nextPart.length - 1] === '"') {
138
+ foundEndingQuote = true
139
+ // Consume the scanned parts so a quoted fragment (e.g. a
140
+ // literal `max-age=1` inside the field list) is not
141
+ // re-parsed as a real directive.
142
+ i = j
143
+ break
144
+ }
145
+ }
146
+ }
147
+
148
+ if (foundEndingQuote) {
149
+ const lastHeader = headerNames[headerNames.length - 1]
150
+ if (lastHeader[lastHeader.length - 1] === '"') {
151
+ headerNames[headerNames.length - 1] = lastHeader.substring(0, lastHeader.length - 1)
152
+ }
153
+ if (output[key] !== true) {
154
+ const fields = headerNames.map((name) => name.trim().toLowerCase())
155
+ output[key] = Array.isArray(output[key]) ? output[key].concat(fields) : fields
156
+ }
157
+ } else {
158
+ // Unterminated quoted list (invalid header). Fail restrictive:
159
+ // treat as the unqualified form, and consume the remaining
160
+ // parts — they are fragments of the broken quoted string, not
161
+ // real directives.
162
+ output[key] = true
163
+ i = directives.length
164
+ }
165
+ } else {
166
+ // Unquoted value (e.g. `private=set-cookie`): the qualified form
167
+ // MUST be a quoted field-list (RFC 9111 §5.2.2.7). A bare token is
168
+ // malformed — fail restrictive and treat it as the unqualified
169
+ // directive (forbid storing / demand revalidation for the whole
170
+ // response) rather than a more permissive field list.
171
+ output[key] = true
172
+ }
173
+
174
+ break
175
+ }
176
+ }
177
+ // eslint-disable-next-line no-fallthrough
178
+ case 'public':
179
+ case 'no-store':
180
+ case 'must-revalidate':
181
+ case 'proxy-revalidate':
182
+ case 'immutable':
183
+ case 'no-transform':
184
+ case 'must-understand':
185
+ case 'only-if-cached':
186
+ // These directives take no value. An explicit `=` (even empty, e.g.
187
+ // `public=`) is an invalid qualified form and is ignored — not
188
+ // treated as the bare directive. `value === undefined` is the genuine
189
+ // valueless form (including the private/no-cache fallthrough above).
190
+ if (value !== undefined) {
191
+ continue
192
+ }
193
+
194
+ output[key] = true
195
+ break
196
+ default:
197
+ // Unknown directives are ignored per RFC 9111 §5.2.3.
198
+ continue
199
+ }
200
+ }
201
+
202
+ return output
203
+ }
204
+
205
+ const HTTP_DATE_MONTHS = {
206
+ Jan: 0,
207
+ Feb: 1,
208
+ Mar: 2,
209
+ Apr: 3,
210
+ May: 4,
211
+ Jun: 5,
212
+ Jul: 6,
213
+ Aug: 7,
214
+ Sep: 8,
215
+ Oct: 9,
216
+ Nov: 10,
217
+ Dec: 11,
218
+ }
219
+
220
+ const HTTP_DATE_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
221
+ const HTTP_DATE_FULL_DAYS = [
222
+ 'Sunday',
223
+ 'Monday',
224
+ 'Tuesday',
225
+ 'Wednesday',
226
+ 'Thursday',
227
+ 'Friday',
228
+ 'Saturday',
229
+ ]
230
+
231
+ // IMF-fixdate: Sun, 06 Nov 1994 08:49:37 GMT
232
+ const IMF_DATE_RE =
233
+ /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/
234
+ // obsolete RFC 850: Sunday, 06-Nov-94 08:49:37 GMT
235
+ const RFC850_DATE_RE =
236
+ /^(Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday), (\d{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/
237
+ // ANSI C asctime(): Sun Nov 6 08:49:37 1994 (day space-padded)
238
+ const ASCTIME_DATE_RE =
239
+ /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ([ \d]\d) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/
240
+
241
+ /**
242
+ * Strict HTTP-date parser per RFC 9110 §5.6.7 (the three accepted formats
243
+ * only). Deliberately NOT `new Date(str)`: V8 accepts many non-HTTP formats,
244
+ * and RFC 9111 §5.3 requires an invalid Expires (notably `Expires: 0`) to be
245
+ * treated as already expired rather than silently ignored — which needs the
246
+ * parse failure to be observable.
247
+ *
248
+ * @param {string} date
249
+ * @returns {Date | undefined} undefined when not a valid HTTP-date
250
+ */
251
+ export function parseHttpDate(date) {
252
+ if (typeof date !== 'string') {
253
+ return undefined
254
+ }
255
+
256
+ let weekday
257
+ let day
258
+ let month
259
+ let year
260
+ let hour
261
+ let minute
262
+ let second
263
+
264
+ let m = IMF_DATE_RE.exec(date)
265
+ if (m) {
266
+ weekday = HTTP_DATE_DAYS.indexOf(m[1])
267
+ day = Number(m[2])
268
+ month = HTTP_DATE_MONTHS[m[3]]
269
+ year = Number(m[4])
270
+ hour = Number(m[5])
271
+ minute = Number(m[6])
272
+ second = Number(m[7])
273
+ } else if ((m = RFC850_DATE_RE.exec(date))) {
274
+ weekday = HTTP_DATE_FULL_DAYS.indexOf(m[1])
275
+ day = Number(m[2])
276
+ month = HTTP_DATE_MONTHS[m[3]]
277
+ // RFC 6265 §5.1.1 two-digit year windowing: 70-99 → 19xx, 00-69 → 20xx.
278
+ year = Number(m[4])
279
+ year += year < 70 ? 2000 : 1900
280
+ hour = Number(m[5])
281
+ minute = Number(m[6])
282
+ second = Number(m[7])
283
+ } else if ((m = ASCTIME_DATE_RE.exec(date))) {
284
+ weekday = HTTP_DATE_DAYS.indexOf(m[1])
285
+ month = HTTP_DATE_MONTHS[m[2]]
286
+ day = Number(m[3].trim())
287
+ hour = Number(m[4])
288
+ minute = Number(m[5])
289
+ second = Number(m[6])
290
+ year = Number(m[7])
291
+ } else {
292
+ return undefined
293
+ }
294
+
295
+ if (day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59) {
296
+ return undefined
23
297
  }
24
- return str && typeof str === 'string' ? cacheControlParser.parse(str) : null
298
+
299
+ const result = new Date(Date.UTC(year, month, day, hour, minute, second))
300
+ // Date.UTC normalizes out-of-range components (Feb 30 → Mar 2) and the named
301
+ // weekday must match the actual date — both are rejected by the same check
302
+ // upstream uses (getUTCDay comparison catches normalization only by luck;
303
+ // check the day-of-month explicitly as well).
304
+ return result.getUTCDate() === day && result.getUTCDay() === weekday ? result : undefined
25
305
  }
26
306
 
27
307
  export function isDisturbed(body) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxtedition/nxt-undici",
3
- "version": "7.4.2",
3
+ "version": "7.4.3",
4
4
  "license": "MIT",
5
5
  "author": "Robert Nagy <robert.nagy@boffins.se>",
6
6
  "main": "lib/index.js",
@@ -11,7 +11,6 @@
11
11
  "dependencies": {
12
12
  "@nxtedition/scheduler": "^4.1.1",
13
13
  "@nxtedition/undici": "^11.1.4",
14
- "cache-control-parser": "^2.2.0",
15
14
  "fast-querystring": "^1.1.2",
16
15
  "http-errors": "^2.0.1",
17
16
  "xxhash-wasm": "^1.1.0"