@nxtedition/nxt-undici 7.4.1 → 7.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.d.ts +51 -8
- package/lib/index.js +1 -0
- package/lib/interceptor/cache.js +479 -108
- package/lib/interceptor/dns.js +73 -6
- package/lib/interceptor/log.js +184 -9
- package/lib/interceptor/pressure.js +11 -0
- package/lib/interceptor/proxy.js +46 -15
- package/lib/interceptor/redirect.js +35 -5
- package/lib/interceptor/request-body-factory.js +59 -8
- package/lib/interceptor/response-error.js +19 -2
- package/lib/interceptor/response-retry.js +273 -21
- package/lib/request.js +5 -1
- package/lib/sqlite-cache-store.js +228 -25
- package/lib/utils.js +320 -17
- package/package.json +1 -2
package/lib/interceptor/cache.js
CHANGED
|
@@ -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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
245
|
+
// Unqualified private forbids shared-cache storage entirely; the
|
|
246
|
+
// qualified form (private="field") only forbids storing the listed
|
|
247
|
+
// fields, which are stripped below (RFC 9111 §5.2.2.7).
|
|
248
|
+
if (cacheControlDirectives['no-store'] || cacheControlDirectives.private === true) {
|
|
102
249
|
return super.onHeaders(statusCode, headers, resume)
|
|
103
250
|
}
|
|
104
251
|
|
|
@@ -113,15 +260,21 @@ class CacheHandler extends DecoratorHandler {
|
|
|
113
260
|
if (
|
|
114
261
|
cacheControlDirectives['must-revalidate'] ||
|
|
115
262
|
cacheControlDirectives['proxy-revalidate'] ||
|
|
116
|
-
cacheControlDirectives['stale-while-revalidate'] ||
|
|
117
|
-
cacheControlDirectives['stale-if-error'] ||
|
|
118
|
-
cacheControlDirectives['no-cache']
|
|
263
|
+
cacheControlDirectives['stale-while-revalidate'] != null ||
|
|
264
|
+
cacheControlDirectives['stale-if-error'] != null ||
|
|
265
|
+
cacheControlDirectives['no-cache'] === true
|
|
119
266
|
) {
|
|
120
|
-
//
|
|
267
|
+
// These directives require origin revalidation, which this cache does
|
|
268
|
+
// not yet perform — so the responses are not stored (a follow-up adds
|
|
269
|
+
// conditional revalidation and turns these into stored-and-validated
|
|
270
|
+
// entries).
|
|
121
271
|
return super.onHeaders(statusCode, headers, resume)
|
|
122
272
|
}
|
|
123
273
|
|
|
124
|
-
|
|
274
|
+
// Null prototype: selector names come from the response Vary header and
|
|
275
|
+
// request header names are caller-controlled — on a plain `{}` a
|
|
276
|
+
// `__proto__` key would hit the prototype setter and be silently dropped.
|
|
277
|
+
const vary = Object.create(null)
|
|
125
278
|
if (headers.vary) {
|
|
126
279
|
if (typeof headers.vary !== 'string') {
|
|
127
280
|
return super.onHeaders(statusCode, headers, resume)
|
|
@@ -141,20 +294,23 @@ class CacheHandler extends DecoratorHandler {
|
|
|
141
294
|
}
|
|
142
295
|
}
|
|
143
296
|
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
297
|
+
const now = Date.now()
|
|
298
|
+
|
|
299
|
+
const lifetimeInfo = determineLifetime(
|
|
300
|
+
statusCode,
|
|
301
|
+
headers,
|
|
302
|
+
cacheControlDirectives,
|
|
303
|
+
{ heuristic: this.#heuristic, defaultTTL: this.#defaultTTL },
|
|
304
|
+
now,
|
|
305
|
+
)
|
|
306
|
+
if (lifetimeInfo == null) {
|
|
148
307
|
return super.onHeaders(statusCode, headers, resume)
|
|
149
308
|
}
|
|
150
309
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const lifetime = Math.min(ttl, this.#maxEntryTTL) - (Number.isFinite(age) && age > 0 ? age : 0)
|
|
156
|
-
if (lifetime <= 0) {
|
|
157
|
-
// Already stale on arrival — not worth caching.
|
|
310
|
+
const etag = typeof headers.etag === 'string' && isEtagUsable(headers.etag) ? headers.etag : ''
|
|
311
|
+
const age = determineAge(headers, now)
|
|
312
|
+
const times = computeEntryTimes(lifetimeInfo.lifetime, age, this.#maxEntryTTL, now)
|
|
313
|
+
if (times == null) {
|
|
158
314
|
return super.onHeaders(statusCode, headers, resume)
|
|
159
315
|
}
|
|
160
316
|
|
|
@@ -166,19 +322,70 @@ class CacheHandler extends DecoratorHandler {
|
|
|
166
322
|
const end = contentRange ? contentRange.end : this.#key.method === 'HEAD' ? 0 : contentLength
|
|
167
323
|
|
|
168
324
|
if (end == null || end - start <= this.#maxEntrySize) {
|
|
169
|
-
|
|
325
|
+
// Snapshot the headers: the same object is delivered downstream to the
|
|
326
|
+
// caller (request() resolves with it before the body finishes), and the
|
|
327
|
+
// entry isn't serialized to the store until onComplete. Without a copy,
|
|
328
|
+
// any mutation the caller makes to res.headers while the body streams
|
|
329
|
+
// would be persisted into the shared cache and replayed to every later
|
|
330
|
+
// request. parseHeaders values are strings or string arrays, so a
|
|
331
|
+
// one-level copy with array values sliced is a full snapshot. Use
|
|
332
|
+
// Object.keys so only own enumerable header fields are copied — never
|
|
333
|
+
// inherited properties from the prototype chain. Null prototype: on a
|
|
334
|
+
// plain `{}` a header literally named `__proto__` would hit the
|
|
335
|
+
// Object.prototype setter instead of becoming a data property (silent
|
|
336
|
+
// drop / prototype-pollution vector).
|
|
337
|
+
//
|
|
338
|
+
// Stripped while copying (RFC 9111 §3.1): hop-by-hop fields, fields
|
|
339
|
+
// listed in the Connection header, fields named by qualified
|
|
340
|
+
// no-cache=/private= directives (§5.2.2.4/§5.2.2.7), and Age — cachedAt
|
|
341
|
+
// is backdated by the corrected initial age, so the served Age is fully
|
|
342
|
+
// recomputed and a stored Age would double-count.
|
|
343
|
+
const excludedHeaders = new Set(['age'])
|
|
344
|
+
const connection = headers.connection
|
|
345
|
+
if (typeof connection === 'string') {
|
|
346
|
+
for (const name of connection.split(',')) {
|
|
347
|
+
excludedHeaders.add(name.trim().toLowerCase())
|
|
348
|
+
}
|
|
349
|
+
} else if (Array.isArray(connection)) {
|
|
350
|
+
for (const line of connection) {
|
|
351
|
+
for (const name of `${line}`.split(',')) {
|
|
352
|
+
excludedHeaders.add(name.trim().toLowerCase())
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (Array.isArray(cacheControlDirectives['no-cache'])) {
|
|
357
|
+
for (const name of cacheControlDirectives['no-cache']) {
|
|
358
|
+
excludedHeaders.add(name)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (Array.isArray(cacheControlDirectives.private)) {
|
|
362
|
+
for (const name of cacheControlDirectives.private) {
|
|
363
|
+
excludedHeaders.add(name)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const storedHeaders = Object.create(null)
|
|
368
|
+
for (const name of Object.keys(headers)) {
|
|
369
|
+
if (isHopByHop(name) || excludedHeaders.has(name.toLowerCase())) {
|
|
370
|
+
continue
|
|
371
|
+
}
|
|
372
|
+
const val = headers[name]
|
|
373
|
+
storedHeaders[name] = Array.isArray(val) ? val.slice() : val
|
|
374
|
+
}
|
|
375
|
+
|
|
170
376
|
this.#value = {
|
|
171
377
|
body: [],
|
|
172
378
|
start,
|
|
173
379
|
end,
|
|
174
|
-
|
|
380
|
+
cachedAt: times.cachedAt,
|
|
381
|
+
staleAt: times.staleAt,
|
|
382
|
+
deleteAt: times.deleteAt,
|
|
175
383
|
statusCode,
|
|
176
384
|
statusMessage: '',
|
|
177
|
-
headers,
|
|
385
|
+
headers: storedHeaders,
|
|
178
386
|
cacheControlDirectives,
|
|
179
|
-
etag
|
|
387
|
+
etag,
|
|
180
388
|
vary,
|
|
181
|
-
cachedAt,
|
|
182
389
|
// Handler state.
|
|
183
390
|
size: 0,
|
|
184
391
|
}
|
|
@@ -220,95 +427,270 @@ class CacheHandler extends DecoratorHandler {
|
|
|
220
427
|
}
|
|
221
428
|
}
|
|
222
429
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
430
|
+
/**
|
|
431
|
+
* RFC 9111 §4.4: a non-error response to an unsafe method invalidates the
|
|
432
|
+
* stored entries for the target URI and any same-origin Location /
|
|
433
|
+
* Content-Location URIs (undici PR #5514). Cross-origin targets are skipped —
|
|
434
|
+
* honoring an attacker-influenced Location against another origin's entries
|
|
435
|
+
* would be a cache-poisoning vector.
|
|
436
|
+
*/
|
|
437
|
+
class InvalidationHandler extends DecoratorHandler {
|
|
438
|
+
#key
|
|
439
|
+
#store
|
|
440
|
+
#logger
|
|
441
|
+
|
|
442
|
+
constructor(key, { store, logger, handler }) {
|
|
443
|
+
super(handler)
|
|
444
|
+
this.#key = key
|
|
445
|
+
this.#store = store
|
|
446
|
+
this.#logger = logger
|
|
226
447
|
}
|
|
227
448
|
|
|
228
|
-
|
|
229
|
-
|
|
449
|
+
onHeaders(statusCode, headers, resume) {
|
|
450
|
+
if (statusCode >= 200 && statusCode <= 399) {
|
|
451
|
+
// Invalidation failures must never break the actual response. Deletes
|
|
452
|
+
// are idempotent, so a retry re-driving onHeaders is harmless.
|
|
453
|
+
try {
|
|
454
|
+
this.#invalidate(headers)
|
|
455
|
+
} catch (err) {
|
|
456
|
+
if (err.message === 'database is locked') {
|
|
457
|
+
this.#logger?.debug({ err }, 'failed to invalidate cache entry')
|
|
458
|
+
} else {
|
|
459
|
+
this.#logger?.error({ err }, 'failed to invalidate cache entry')
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return super.onHeaders(statusCode, headers, resume)
|
|
230
464
|
}
|
|
231
465
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
// cache-control-parser does not recognise 'only-if-cached', so check the raw string.
|
|
235
|
-
const onlyIfCached =
|
|
236
|
-
typeof rawCacheControl === 'string' && rawCacheControl.includes('only-if-cached')
|
|
466
|
+
#invalidate(headers) {
|
|
467
|
+
this.#store.delete(this.#key)
|
|
237
468
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
469
|
+
const invalidated = new Set([this.#key.path])
|
|
470
|
+
let base
|
|
471
|
+
for (const name of ['location', 'content-location']) {
|
|
472
|
+
let value = headers[name]
|
|
473
|
+
if (Array.isArray(value)) {
|
|
474
|
+
value = value[0]
|
|
475
|
+
}
|
|
476
|
+
if (typeof value !== 'string' || value === '') {
|
|
477
|
+
continue
|
|
478
|
+
}
|
|
243
479
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
480
|
+
base ??= new URL(this.#key.path, this.#key.origin)
|
|
481
|
+
let target
|
|
482
|
+
try {
|
|
483
|
+
target = new URL(value, base)
|
|
484
|
+
} catch {
|
|
485
|
+
continue
|
|
486
|
+
}
|
|
487
|
+
if (target.origin !== base.origin) {
|
|
488
|
+
continue
|
|
489
|
+
}
|
|
247
490
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
// check the raw string like we do for 'only-if-cached'.
|
|
255
|
-
(typeof rawCacheControl === 'string' &&
|
|
256
|
-
(rawCacheControl.includes('max-stale') || rawCacheControl.includes('min-fresh')))
|
|
257
|
-
) {
|
|
258
|
-
// TODO (fix): Support all cache control directives...
|
|
259
|
-
return dispatch(opts, handler)
|
|
491
|
+
const path = target.pathname + target.search
|
|
492
|
+
if (!invalidated.has(path)) {
|
|
493
|
+
invalidated.add(path)
|
|
494
|
+
this.#store.delete({ ...this.#key, path })
|
|
495
|
+
}
|
|
496
|
+
}
|
|
260
497
|
}
|
|
498
|
+
}
|
|
261
499
|
|
|
262
|
-
|
|
263
|
-
|
|
500
|
+
function getStore(opts) {
|
|
501
|
+
return opts.cache.store ?? (DEFAULT_STORE ??= new SqliteCacheStore({ location: ':memory:' }))
|
|
502
|
+
}
|
|
264
503
|
|
|
265
|
-
|
|
504
|
+
function tryGetEntry(store, key, logger) {
|
|
505
|
+
try {
|
|
506
|
+
return store.get(key)
|
|
507
|
+
} catch (err) {
|
|
508
|
+
if (err.message === 'database is locked') {
|
|
509
|
+
// Database is busy. We don't bother trying again...
|
|
510
|
+
logger?.debug({ err }, 'failed to get cache entry')
|
|
511
|
+
} else {
|
|
512
|
+
logger?.error({ err }, 'failed to get cache entry')
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
266
516
|
|
|
517
|
+
/**
|
|
518
|
+
* Builds the cache key shared by the get and set paths.
|
|
519
|
+
*/
|
|
520
|
+
function makeKey(opts) {
|
|
267
521
|
// Build the key the same way for lookups and stores: makeCacheKey
|
|
268
522
|
// stringifies the origin (e.g. URL objects), so using raw opts on the get
|
|
269
523
|
// path while the set path normalizes would make the cache permanently miss.
|
|
270
|
-
|
|
524
|
+
// The flat name/value array form of opts.headers (legal at the undici
|
|
525
|
+
// client level) makes makeCacheKey throw — normalize it through
|
|
526
|
+
// parseHeaders first (which also lowercases the names). Header names are
|
|
527
|
+
// caller-controlled, so parse into a null-prototype target: a `__proto__`
|
|
528
|
+
// name on a plain `{}` would hit the Object.prototype setter instead of
|
|
529
|
+
// becoming a data property.
|
|
530
|
+
const key = undici.util.cache.makeCacheKey(
|
|
531
|
+
Array.isArray(opts.headers)
|
|
532
|
+
? { ...opts, headers: parseHeaders(opts.headers, Object.create(null)) }
|
|
533
|
+
: opts,
|
|
534
|
+
)
|
|
271
535
|
|
|
272
536
|
// makeCacheKey preserves request header names verbatim. Vary selector names
|
|
273
|
-
// are lowercased (in
|
|
537
|
+
// are lowercased (in CacheHandler and matchesValue), so lowercase the key's
|
|
274
538
|
// header names once here — the same key feeds both the get and set paths, so
|
|
275
539
|
// this keeps Vary matching symmetric even when a caller supplies non-lowercase
|
|
276
540
|
// header names (the standalone interceptors.cache() composition; the wrapped
|
|
277
541
|
// pipeline already normalizes). A fresh object avoids mutating opts.headers.
|
|
542
|
+
// Header names are caller-controlled, so build a null-prototype map (a
|
|
543
|
+
// `__proto__` key on a plain object would silently overwrite the prototype
|
|
544
|
+
// instead of setting a property) and copy own keys only.
|
|
278
545
|
if (key.headers && typeof key.headers === 'object') {
|
|
279
|
-
const lower =
|
|
280
|
-
for (const name
|
|
546
|
+
const lower = Object.create(null)
|
|
547
|
+
for (const name of Object.keys(key.headers)) {
|
|
281
548
|
lower[name.toLowerCase()] = key.headers[name]
|
|
282
549
|
}
|
|
283
550
|
key.headers = lower
|
|
284
551
|
}
|
|
285
552
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
553
|
+
// The vendored makeCacheKey ignores opts.query. The wrapped pipeline is
|
|
554
|
+
// immune (the query interceptor rewrites path before the cache sees it),
|
|
555
|
+
// but a standalone interceptors.cache() composition would silently collide
|
|
556
|
+
// distinct query strings onto one entry and serve the wrong response
|
|
557
|
+
// (undici issue #4209 / PR #5081) — fold the query into the key path.
|
|
558
|
+
if (
|
|
559
|
+
opts.query &&
|
|
560
|
+
typeof key.path === 'string' &&
|
|
561
|
+
!key.path.includes('?') &&
|
|
562
|
+
!key.path.includes('#')
|
|
563
|
+
) {
|
|
564
|
+
const qs = stringify(opts.query)
|
|
565
|
+
if (qs) {
|
|
566
|
+
key.path = `${key.path || '/'}?${qs}`
|
|
295
567
|
}
|
|
296
568
|
}
|
|
297
569
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
570
|
+
return key
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function cacheOptsOf(opts) {
|
|
574
|
+
return {
|
|
575
|
+
maxEntrySize: opts.cache.maxEntrySize,
|
|
576
|
+
maxEntryTTL: opts.cache.maxEntryTTL,
|
|
577
|
+
heuristic: opts.cache.heuristic,
|
|
578
|
+
defaultTTL: opts.cache.defaultTTL,
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export default () => (dispatch) => (opts, handler) => {
|
|
583
|
+
if (!opts.cache || opts.upgrade) {
|
|
584
|
+
return dispatch(opts, handler)
|
|
302
585
|
}
|
|
303
586
|
|
|
304
|
-
|
|
587
|
+
if (opts.method !== 'GET' && opts.method !== 'HEAD') {
|
|
588
|
+
// RFC 9110 §9.2.1: OPTIONS and TRACE are safe — never cached, but they
|
|
589
|
+
// must not invalidate either. Every other method (POST/PUT/DELETE/...)
|
|
590
|
+
// invalidates the target URI on a non-error response (RFC 9111 §4.4).
|
|
591
|
+
if (opts.method === 'OPTIONS' || opts.method === 'TRACE') {
|
|
592
|
+
return dispatch(opts, handler)
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const store = getStore(opts)
|
|
596
|
+
if (typeof store.delete !== 'function') {
|
|
597
|
+
// User-supplied store without invalidation support.
|
|
598
|
+
return dispatch(opts, handler)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return dispatch(
|
|
602
|
+
opts,
|
|
603
|
+
new InvalidationHandler(makeKey(opts), { store, logger: opts.logger, handler }),
|
|
604
|
+
)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// TODO (fix): enable range requests
|
|
608
|
+
|
|
609
|
+
const key = makeKey(opts)
|
|
610
|
+
|
|
611
|
+
// All request-directive and conditional-header guards below MUST read from
|
|
612
|
+
// the lowercased key.headers, not raw opts.headers — otherwise a caller
|
|
613
|
+
// supplying capitalized names (e.g. `Authorization`, `Cache-Control` via the
|
|
614
|
+
// standalone composition) would silently skip the guards while the store
|
|
615
|
+
// side (CacheHandler) reads the lowercased form, e.g. serving a non-public
|
|
616
|
+
// cached response to an authorized request (RFC 9111 §3.5).
|
|
617
|
+
const headers = key.headers ?? {}
|
|
618
|
+
|
|
619
|
+
const rawCacheControl = headers['cache-control']
|
|
620
|
+
const requestCacheControl = parseCacheControl(rawCacheControl) ?? {}
|
|
621
|
+
|
|
622
|
+
// RFC 9111 Section 5.4: Pragma: no-cache should be treated as
|
|
623
|
+
// Cache-Control: no-cache when Cache-Control is absent.
|
|
624
|
+
if (rawCacheControl == null && headers.pragma === 'no-cache') {
|
|
625
|
+
requestCacheControl['no-cache'] = true
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (requestCacheControl['no-transform']) {
|
|
629
|
+
// Do nothing. We don't transform requests...
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const onlyIfCached = requestCacheControl['only-if-cached'] === true
|
|
633
|
+
const store = getStore(opts)
|
|
634
|
+
|
|
635
|
+
let entry = tryGetEntry(store, key, opts.logger)
|
|
636
|
+
|
|
637
|
+
// RFC 9111 §3.5 serve-side authorization gate: a shared cache must not
|
|
638
|
+
// reuse a stored response for a request with Authorization unless the
|
|
639
|
+
// response allowed it (public, s-maxage or must-revalidate — the mirror of
|
|
640
|
+
// the store-side gate in CacheHandler; both sites must stay in lockstep).
|
|
641
|
+
if (entry && headers.authorization != null) {
|
|
642
|
+
const directives = entry.cacheControlDirectives
|
|
643
|
+
if (
|
|
644
|
+
typeof headers.authorization !== 'string' ||
|
|
645
|
+
!(
|
|
646
|
+
directives?.public === true ||
|
|
647
|
+
directives?.['s-maxage'] != null ||
|
|
648
|
+
directives?.['must-revalidate'] === true
|
|
649
|
+
)
|
|
650
|
+
) {
|
|
651
|
+
entry = undefined
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const cacheHandler = () =>
|
|
656
|
+
new CacheHandler(key, {
|
|
657
|
+
...cacheOptsOf(opts),
|
|
658
|
+
store,
|
|
659
|
+
logger: opts.logger,
|
|
660
|
+
handler,
|
|
661
|
+
})
|
|
662
|
+
|
|
663
|
+
// Request Cache-Control directives that this cache does not evaluate locally
|
|
664
|
+
// (a follow-up adds conditional revalidation and local evaluation) cause a
|
|
665
|
+
// bypass to the origin. These constrain REUSE of a stored response, not the
|
|
666
|
+
// storage of a fresh one — so the bypass still writes the origin response
|
|
667
|
+
// back through CacheHandler for later callers (undici PR #5510), unless the
|
|
668
|
+
// request's own no-store forbids storing. only-if-cached is the exception:
|
|
669
|
+
// it forbids contacting the origin, so it is handled from the cache below
|
|
670
|
+
// instead of bypassing.
|
|
671
|
+
const bypass =
|
|
672
|
+
!onlyIfCached &&
|
|
673
|
+
// != null: 'max-age=0' parses to 0 (falsy) but still demands revalidation.
|
|
674
|
+
(requestCacheControl['max-age'] != null ||
|
|
675
|
+
requestCacheControl['no-cache'] === true ||
|
|
676
|
+
requestCacheControl['stale-if-error'] != null ||
|
|
677
|
+
requestCacheControl['max-stale'] != null ||
|
|
678
|
+
requestCacheControl['min-fresh'] != null)
|
|
679
|
+
|
|
680
|
+
if (bypass) {
|
|
681
|
+
return dispatch(opts, requestCacheControl['no-store'] ? handler : cacheHandler())
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// RFC 9110 Section 13: evaluate conditional request headers against the
|
|
685
|
+
// cached entry. The store only returns entries that are still fresh
|
|
686
|
+
// (deleteAt === staleAt), so a returned entry is always servable.
|
|
305
687
|
// typeof guards: duplicated conditional headers arrive as arrays — treat
|
|
306
688
|
// them as non-matching and bypass to origin rather than crashing.
|
|
307
|
-
if (entry &&
|
|
689
|
+
if (entry && headers['if-none-match']) {
|
|
308
690
|
if (
|
|
309
|
-
typeof
|
|
691
|
+
typeof headers['if-none-match'] === 'string' &&
|
|
310
692
|
entry.etag &&
|
|
311
|
-
weakMatch(
|
|
693
|
+
weakMatch(headers['if-none-match'], entry.etag)
|
|
312
694
|
) {
|
|
313
695
|
return serveFromCache(
|
|
314
696
|
{ statusCode: 304, headers: entry.headers, cachedAt: entry.cachedAt },
|
|
@@ -318,12 +700,12 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
318
700
|
}
|
|
319
701
|
// Etag didn't match — bypass to origin.
|
|
320
702
|
entry = undefined
|
|
321
|
-
} else if (entry &&
|
|
703
|
+
} else if (entry && headers['if-modified-since']) {
|
|
322
704
|
const lastModified = entry.headers?.['last-modified']
|
|
323
705
|
if (
|
|
324
|
-
typeof
|
|
706
|
+
typeof headers['if-modified-since'] === 'string' &&
|
|
325
707
|
lastModified &&
|
|
326
|
-
new Date(lastModified) <= new Date(
|
|
708
|
+
new Date(lastModified) <= new Date(headers['if-modified-since'])
|
|
327
709
|
) {
|
|
328
710
|
return serveFromCache(
|
|
329
711
|
{ statusCode: 304, headers: entry.headers, cachedAt: entry.cachedAt },
|
|
@@ -335,30 +717,19 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
335
717
|
entry = undefined
|
|
336
718
|
}
|
|
337
719
|
|
|
338
|
-
if (
|
|
339
|
-
opts.headers?.['if-match'] ||
|
|
340
|
-
opts.headers?.['if-unmodified-since'] ||
|
|
341
|
-
opts.headers?.['if-range']
|
|
342
|
-
) {
|
|
720
|
+
if (headers['if-match'] || headers['if-unmodified-since'] || headers['if-range']) {
|
|
343
721
|
// TODO (fix): evaluate these conditional headers against cached entry.
|
|
344
722
|
return dispatch(opts, handler)
|
|
345
723
|
}
|
|
346
724
|
|
|
347
725
|
if (!entry && !onlyIfCached) {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
? handler
|
|
352
|
-
: new CacheHandler(key, {
|
|
353
|
-
maxEntrySize: opts.cache.maxEntrySize,
|
|
354
|
-
maxEntryTTL: opts.cache.maxEntryTTL,
|
|
355
|
-
store,
|
|
356
|
-
logger: opts.logger,
|
|
357
|
-
handler,
|
|
358
|
-
}),
|
|
359
|
-
)
|
|
726
|
+
// A miss keeps the CacheHandler write-back unless the request's no-store
|
|
727
|
+
// forbids storing.
|
|
728
|
+
return dispatch(opts, requestCacheControl['no-store'] ? handler : cacheHandler())
|
|
360
729
|
}
|
|
361
730
|
|
|
731
|
+
// A hit (fresh, per the store) is served; only-if-cached with no usable
|
|
732
|
+
// entry yields 504 (RFC 9111 §5.2.1.7).
|
|
362
733
|
return serveFromCache(entry ?? { statusCode: 504 }, opts, handler)
|
|
363
734
|
}
|
|
364
735
|
|
|
@@ -367,13 +738,13 @@ function serveFromCache(entry, opts, handler) {
|
|
|
367
738
|
|
|
368
739
|
let headers = entry.headers
|
|
369
740
|
if (entry.cachedAt != null) {
|
|
370
|
-
// RFC 9111 §5.1: every response served from cache must carry an Age
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const age =
|
|
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 age — no origin-Age addition. Date.now(), not
|
|
745
|
+
// getFastNow(): the lagging clock would understate a relayed response's
|
|
746
|
+
// initial age by up to a second.
|
|
747
|
+
const age = Math.max(0, Math.floor((Date.now() - entry.cachedAt) / 1000))
|
|
377
748
|
headers = { ...headers, age: `${age}` }
|
|
378
749
|
}
|
|
379
750
|
|