@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.
- package/lib/index.d.ts +27 -1
- package/lib/index.js +15 -0
- package/lib/interceptor/cache.js +667 -115
- package/lib/interceptor/dns.js +101 -2
- package/lib/interceptor/log.js +126 -59
- package/lib/interceptor/lookup.js +53 -1
- package/lib/interceptor/pressure.js +86 -3
- package/lib/interceptor/priority.js +73 -3
- package/lib/interceptor/proxy.js +4 -1
- package/lib/interceptor/redirect.js +26 -0
- package/lib/interceptor/response-retry.js +32 -2
- package/lib/interceptor/response-verify.js +36 -0
- package/lib/sqlite-cache-store.js +141 -15
- package/lib/trace.js +87 -0
- package/lib/utils.js +288 -8
- package/package.json +2 -2
package/lib/interceptor/cache.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
//
|
|
121
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
cacheControlDirectives
|
|
153
|
-
|
|
154
|
-
|
|
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 (
|
|
157
|
-
return
|
|
383
|
+
if (lifetimeInfo == null) {
|
|
384
|
+
return this.#skip('no-lifetime', statusCode, headers, resume)
|
|
158
385
|
}
|
|
159
386
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
291
|
-
|
|
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
|
-
|
|
771
|
+
requestCacheControl['no-cache'] = true
|
|
306
772
|
}
|
|
307
773
|
|
|
308
|
-
if (
|
|
774
|
+
if (requestCacheControl['no-transform']) {
|
|
309
775
|
// Do nothing. We don't transform requests...
|
|
310
776
|
}
|
|
311
777
|
|
|
312
|
-
if
|
|
313
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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:
|
|
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
|
-
|
|
388
|
-
opts,
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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 age — no 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
|