@nxtedition/nxt-undici 7.4.3 → 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 +11 -0
- package/lib/index.js +15 -0
- package/lib/interceptor/cache.js +243 -23
- 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/redirect.js +26 -0
- package/lib/interceptor/response-retry.js +32 -2
- package/lib/interceptor/response-verify.js +36 -0
- package/lib/trace.js +87 -0
- package/package.json +2 -1
package/lib/index.d.ts
CHANGED
|
@@ -55,6 +55,13 @@ export interface LoggerLike {
|
|
|
55
55
|
info(obj: unknown, msg?: string): void
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/** The @nxtedition/trace writer contract: `write` is the emit fn while tracing
|
|
59
|
+
* is enabled and null while disabled (it flips between the two at runtime).
|
|
60
|
+
* The per-thread fallback writer lives in the Symbol.for('@nxtedition/app/trace')
|
|
61
|
+
* slot and must be installed via the package's installTrace(). */
|
|
62
|
+
export type { TraceWriter } from '@nxtedition/trace'
|
|
63
|
+
import type { TraceWriter } from '@nxtedition/trace'
|
|
64
|
+
|
|
58
65
|
export type BodyFactoryResult =
|
|
59
66
|
| Readable
|
|
60
67
|
| Uint8Array
|
|
@@ -98,6 +105,10 @@ export interface DispatchOptions {
|
|
|
98
105
|
error?: boolean | null
|
|
99
106
|
verify?: VerifyOptions | boolean | null
|
|
100
107
|
logger?: LoggerLike | null
|
|
108
|
+
/** Per-request trace writer: undefined falls back to the per-thread writer
|
|
109
|
+
* installed via @nxtedition/trace's installTrace(), null disables tracing
|
|
110
|
+
* for this request. */
|
|
111
|
+
trace?: TraceWriter | null
|
|
101
112
|
dns?: DnsOptions | boolean | null
|
|
102
113
|
connect?: Record<string, unknown> | null
|
|
103
114
|
priority?: Priority | null
|
package/lib/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { Scheduler } from '@nxtedition/scheduler'
|
|
|
4
4
|
import { parseHeaders } from './utils.js'
|
|
5
5
|
import { request as _request } from './request.js'
|
|
6
6
|
import { SqliteCacheStore } from './sqlite-cache-store.js'
|
|
7
|
+
import { validateTrace } from './trace.js'
|
|
7
8
|
|
|
8
9
|
const dispatcherCache = new WeakMap()
|
|
9
10
|
|
|
@@ -118,6 +119,13 @@ function wrapDispatch(dispatcher) {
|
|
|
118
119
|
interceptors.proxy(),
|
|
119
120
|
interceptors.cache(),
|
|
120
121
|
interceptors.redirect(),
|
|
122
|
+
// log also emits the undici:request trace start/end docs. Later entries
|
|
123
|
+
// in this list wrap earlier ones, so it sits OUTSIDE everything that
|
|
124
|
+
// does real work (redirect, cache, proxy, retry, dns) — durationMs
|
|
125
|
+
// spans the whole inner pipeline including retries, dns and cache
|
|
126
|
+
// lookups — but INSIDE requestId, so opts.id is already stamped and
|
|
127
|
+
// the emitted docs correlate with logs and undici:retry docs by
|
|
128
|
+
// request id.
|
|
121
129
|
interceptors.log(),
|
|
122
130
|
interceptors.lookup(),
|
|
123
131
|
interceptors.requestId(),
|
|
@@ -186,6 +194,13 @@ function wrapDispatch(dispatcher) {
|
|
|
186
194
|
error: opts.error ?? opts.throwOnError ?? true,
|
|
187
195
|
verify: opts.verify ?? { size: true, hash: false },
|
|
188
196
|
logger: opts.logger ?? null,
|
|
197
|
+
// Deliberately NOT defaulted: undefined means "fall back to the
|
|
198
|
+
// per-thread writer installed via installTrace()" (resolved lazily
|
|
199
|
+
// at each emission site — the writer may be installed after startup
|
|
200
|
+
// and its `write` flips at runtime), null means "tracing disabled
|
|
201
|
+
// for this request" (see lib/trace.js). Validation throws
|
|
202
|
+
// InvalidArgumentError for anything that is not a writer.
|
|
203
|
+
trace: validateTrace(opts.trace),
|
|
189
204
|
dns: opts.dns ?? true,
|
|
190
205
|
connect: opts.connect,
|
|
191
206
|
// A duplicated nxt-priority request header parses to an array; the
|
package/lib/interceptor/cache.js
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from '../utils.js'
|
|
11
11
|
import { isHopByHop } from './proxy.js'
|
|
12
12
|
import { SqliteCacheStore } from '../sqlite-cache-store.js'
|
|
13
|
+
import { traceWrite, traceSafe, traceErr, traceUrl } from '../trace.js'
|
|
13
14
|
|
|
14
15
|
let DEFAULT_STORE = null
|
|
15
16
|
const DEFAULT_MAX_ENTRY_SIZE = 128 * 1024
|
|
@@ -19,6 +20,28 @@ const DEFAULT_MAX_ENTRY_TTL = 30 * 24 * 3600 // seconds
|
|
|
19
20
|
const IMMUTABLE_LIFETIME = 31556952 // seconds
|
|
20
21
|
const NOOP = () => {}
|
|
21
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
|
+
|
|
22
45
|
/**
|
|
23
46
|
* Explicit (or opt-in heuristic) freshness lifetime in seconds, or null when
|
|
24
47
|
* the response carries no usable expiration information. RFC 9111 §4.2.1
|
|
@@ -147,7 +170,20 @@ class CacheHandler extends DecoratorHandler {
|
|
|
147
170
|
#heuristic
|
|
148
171
|
#defaultTTL
|
|
149
172
|
|
|
150
|
-
|
|
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
|
+
) {
|
|
151
187
|
super(handler)
|
|
152
188
|
|
|
153
189
|
this.#key = key
|
|
@@ -158,6 +194,39 @@ class CacheHandler extends DecoratorHandler {
|
|
|
158
194
|
this.#maxEntryTTL = maxEntryTTL ?? store.maxEntryTTL ?? DEFAULT_MAX_ENTRY_TTL
|
|
159
195
|
this.#heuristic = heuristic ?? false
|
|
160
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)
|
|
161
230
|
}
|
|
162
231
|
|
|
163
232
|
onConnect(abort) {
|
|
@@ -170,20 +239,28 @@ class CacheHandler extends DecoratorHandler {
|
|
|
170
239
|
}
|
|
171
240
|
|
|
172
241
|
onHeaders(statusCode, headers, resume) {
|
|
173
|
-
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.
|
|
174
247
|
return super.onHeaders(statusCode, headers, resume)
|
|
175
248
|
}
|
|
176
249
|
|
|
250
|
+
if (statusCode !== 307 && statusCode !== 200 && statusCode !== 206) {
|
|
251
|
+
return this.#skip('status', statusCode, headers, resume)
|
|
252
|
+
}
|
|
253
|
+
|
|
177
254
|
// 'trailer' is the RFC 9110 field name; 'trailers' is kept for backwards
|
|
178
255
|
// compatibility with servers that misspell it.
|
|
179
256
|
if (headers.vary === '*' || headers.trailer || headers.trailers) {
|
|
180
257
|
// Not cacheble...
|
|
181
|
-
return
|
|
258
|
+
return this.#skip(headers.vary === '*' ? 'vary-star' : 'trailer', statusCode, headers, resume)
|
|
182
259
|
}
|
|
183
260
|
|
|
184
261
|
if (headers['set-cookie']) {
|
|
185
262
|
// Shared cache: replaying Set-Cookie to other clients leaks sessions.
|
|
186
|
-
return
|
|
263
|
+
return this.#skip('set-cookie', statusCode, headers, resume)
|
|
187
264
|
}
|
|
188
265
|
|
|
189
266
|
let contentRange
|
|
@@ -196,13 +273,13 @@ class CacheHandler extends DecoratorHandler {
|
|
|
196
273
|
(contentRange.size != null && contentRange.end > contentRange.size)))
|
|
197
274
|
) {
|
|
198
275
|
// We don't support caching responses with invalid content-range...
|
|
199
|
-
return
|
|
276
|
+
return this.#skip('content-range', statusCode, headers, resume)
|
|
200
277
|
}
|
|
201
278
|
if (this.#key.method === 'HEAD') {
|
|
202
279
|
// A HEAD response delivers no body, so we never receive the byte
|
|
203
280
|
// window Content-Range describes — storing it would fail the store's
|
|
204
281
|
// body-length validation.
|
|
205
|
-
return
|
|
282
|
+
return this.#skip('head-range', statusCode, headers, resume)
|
|
206
283
|
}
|
|
207
284
|
}
|
|
208
285
|
|
|
@@ -211,13 +288,13 @@ class CacheHandler extends DecoratorHandler {
|
|
|
211
288
|
contentLength = Number(headers['content-length'])
|
|
212
289
|
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
|
213
290
|
// We don't support caching responses with invalid content-length...
|
|
214
|
-
return
|
|
291
|
+
return this.#skip('content-length', statusCode, headers, resume)
|
|
215
292
|
}
|
|
216
293
|
}
|
|
217
294
|
|
|
218
295
|
if (statusCode === 206 && !contentRange) {
|
|
219
296
|
// We don't support caching range responses without content-range...
|
|
220
|
-
return
|
|
297
|
+
return this.#skip('206-no-range', statusCode, headers, resume)
|
|
221
298
|
}
|
|
222
299
|
|
|
223
300
|
const cacheControlDirectives = parseCacheControl(headers['cache-control']) ?? {}
|
|
@@ -238,7 +315,7 @@ class CacheHandler extends DecoratorHandler {
|
|
|
238
315
|
cacheControlDirectives['must-revalidate'] === true
|
|
239
316
|
)
|
|
240
317
|
) {
|
|
241
|
-
return
|
|
318
|
+
return this.#skip('auth', statusCode, headers, resume)
|
|
242
319
|
}
|
|
243
320
|
}
|
|
244
321
|
|
|
@@ -246,7 +323,7 @@ class CacheHandler extends DecoratorHandler {
|
|
|
246
323
|
// qualified form (private="field") only forbids storing the listed
|
|
247
324
|
// fields, which are stripped below (RFC 9111 §5.2.2.7).
|
|
248
325
|
if (cacheControlDirectives['no-store'] || cacheControlDirectives.private === true) {
|
|
249
|
-
return
|
|
326
|
+
return this.#skip('no-store', statusCode, headers, resume)
|
|
250
327
|
}
|
|
251
328
|
|
|
252
329
|
if (cacheControlDirectives['must-understand']) {
|
|
@@ -268,7 +345,7 @@ class CacheHandler extends DecoratorHandler {
|
|
|
268
345
|
// not yet perform — so the responses are not stored (a follow-up adds
|
|
269
346
|
// conditional revalidation and turns these into stored-and-validated
|
|
270
347
|
// entries).
|
|
271
|
-
return
|
|
348
|
+
return this.#skip('revalidate', statusCode, headers, resume)
|
|
272
349
|
}
|
|
273
350
|
|
|
274
351
|
// Null prototype: selector names come from the response Vary header and
|
|
@@ -277,13 +354,13 @@ class CacheHandler extends DecoratorHandler {
|
|
|
277
354
|
const vary = Object.create(null)
|
|
278
355
|
if (headers.vary) {
|
|
279
356
|
if (typeof headers.vary !== 'string') {
|
|
280
|
-
return
|
|
357
|
+
return this.#skip('vary-invalid', statusCode, headers, resume)
|
|
281
358
|
}
|
|
282
359
|
|
|
283
360
|
for (const key of headers.vary.split(',').map((key) => key.trim().toLowerCase())) {
|
|
284
361
|
if (key === '*') {
|
|
285
362
|
// RFC 9111 §4.1: a Vary field containing '*' never matches.
|
|
286
|
-
return
|
|
363
|
+
return this.#skip('vary-star', statusCode, headers, resume)
|
|
287
364
|
}
|
|
288
365
|
// Record every selecting header, using a null sentinel when it was
|
|
289
366
|
// absent from the request. RFC 9111 §4.1: absent-vs-present is a
|
|
@@ -304,14 +381,14 @@ class CacheHandler extends DecoratorHandler {
|
|
|
304
381
|
now,
|
|
305
382
|
)
|
|
306
383
|
if (lifetimeInfo == null) {
|
|
307
|
-
return
|
|
384
|
+
return this.#skip('no-lifetime', statusCode, headers, resume)
|
|
308
385
|
}
|
|
309
386
|
|
|
310
387
|
const etag = typeof headers.etag === 'string' && isEtagUsable(headers.etag) ? headers.etag : ''
|
|
311
388
|
const age = determineAge(headers, now)
|
|
312
389
|
const times = computeEntryTimes(lifetimeInfo.lifetime, age, this.#maxEntryTTL, now)
|
|
313
390
|
if (times == null) {
|
|
314
|
-
return
|
|
391
|
+
return this.#skip('stale', statusCode, headers, resume)
|
|
315
392
|
}
|
|
316
393
|
|
|
317
394
|
const start = contentRange ? contentRange.start : 0
|
|
@@ -389,6 +466,8 @@ class CacheHandler extends DecoratorHandler {
|
|
|
389
466
|
// Handler state.
|
|
390
467
|
size: 0,
|
|
391
468
|
}
|
|
469
|
+
} else {
|
|
470
|
+
return this.#skip('too-large', statusCode, headers, resume)
|
|
392
471
|
}
|
|
393
472
|
|
|
394
473
|
return super.onHeaders(statusCode, headers, resume)
|
|
@@ -400,7 +479,11 @@ class CacheHandler extends DecoratorHandler {
|
|
|
400
479
|
this.#value.body.push(chunk)
|
|
401
480
|
|
|
402
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
|
|
403
485
|
this.#value = null
|
|
486
|
+
this.#trace(statusCode, false, 'too-large', null, null, null)
|
|
404
487
|
}
|
|
405
488
|
}
|
|
406
489
|
|
|
@@ -410,9 +493,11 @@ class CacheHandler extends DecoratorHandler {
|
|
|
410
493
|
onComplete(trailers) {
|
|
411
494
|
if (this.#value && (!trailers || Object.keys(trailers).length === 0)) {
|
|
412
495
|
this.#value.end ??= this.#value.start + this.#value.size
|
|
496
|
+
let storeErr = null
|
|
413
497
|
try {
|
|
414
498
|
this.#store.set(this.#key, this.#value)
|
|
415
499
|
} catch (err) {
|
|
500
|
+
storeErr = err
|
|
416
501
|
if (err.message === 'database is locked') {
|
|
417
502
|
// Database is busy. We don't bother trying again...
|
|
418
503
|
this.#logger?.debug({ err }, 'failed to set cache entry')
|
|
@@ -420,7 +505,23 @@ class CacheHandler extends DecoratorHandler {
|
|
|
420
505
|
this.#logger?.error({ err }, 'failed to set cache entry')
|
|
421
506
|
}
|
|
422
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
|
+
)
|
|
423
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)
|
|
424
525
|
}
|
|
425
526
|
|
|
426
527
|
super.onComplete(trailers)
|
|
@@ -438,27 +539,52 @@ class InvalidationHandler extends DecoratorHandler {
|
|
|
438
539
|
#key
|
|
439
540
|
#store
|
|
440
541
|
#logger
|
|
542
|
+
#write
|
|
543
|
+
#id
|
|
544
|
+
#url
|
|
441
545
|
|
|
442
|
-
constructor(key, { store, logger, handler }) {
|
|
546
|
+
constructor(key, { store, logger, handler, write, id, url }) {
|
|
443
547
|
super(handler)
|
|
444
548
|
this.#key = key
|
|
445
549
|
this.#store = store
|
|
446
550
|
this.#logger = logger
|
|
551
|
+
this.#write = write ?? null
|
|
552
|
+
this.#id = id ?? null
|
|
553
|
+
this.#url = url ?? null
|
|
447
554
|
}
|
|
448
555
|
|
|
449
556
|
onHeaders(statusCode, headers, resume) {
|
|
450
557
|
if (statusCode >= 200 && statusCode <= 399) {
|
|
451
558
|
// Invalidation failures must never break the actual response. Deletes
|
|
452
559
|
// are idempotent, so a retry re-driving onHeaders is harmless.
|
|
560
|
+
let paths = 0
|
|
561
|
+
let invalidateErr = null
|
|
453
562
|
try {
|
|
454
|
-
this.#invalidate(headers)
|
|
563
|
+
paths = this.#invalidate(headers)
|
|
455
564
|
} catch (err) {
|
|
565
|
+
invalidateErr = err
|
|
456
566
|
if (err.message === 'database is locked') {
|
|
457
567
|
this.#logger?.debug({ err }, 'failed to invalidate cache entry')
|
|
458
568
|
} else {
|
|
459
569
|
this.#logger?.error({ err }, 'failed to invalidate cache entry')
|
|
460
570
|
}
|
|
461
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
|
+
}
|
|
462
588
|
}
|
|
463
589
|
return super.onHeaders(statusCode, headers, resume)
|
|
464
590
|
}
|
|
@@ -494,6 +620,8 @@ class InvalidationHandler extends DecoratorHandler {
|
|
|
494
620
|
this.#store.delete({ ...this.#key, path })
|
|
495
621
|
}
|
|
496
622
|
}
|
|
623
|
+
|
|
624
|
+
return invalidated.size
|
|
497
625
|
}
|
|
498
626
|
}
|
|
499
627
|
|
|
@@ -584,6 +712,13 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
584
712
|
return dispatch(opts, handler)
|
|
585
713
|
}
|
|
586
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
|
+
|
|
587
722
|
if (opts.method !== 'GET' && opts.method !== 'HEAD') {
|
|
588
723
|
// RFC 9110 §9.2.1: OPTIONS and TRACE are safe — never cached, but they
|
|
589
724
|
// must not invalidate either. Every other method (POST/PUT/DELETE/...)
|
|
@@ -598,15 +733,26 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
598
733
|
return dispatch(opts, handler)
|
|
599
734
|
}
|
|
600
735
|
|
|
736
|
+
const key = makeKey(opts)
|
|
601
737
|
return dispatch(
|
|
602
738
|
opts,
|
|
603
|
-
new InvalidationHandler(
|
|
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
|
+
}),
|
|
604
747
|
)
|
|
605
748
|
}
|
|
606
749
|
|
|
607
750
|
// TODO (fix): enable range requests
|
|
608
751
|
|
|
609
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
|
|
610
756
|
|
|
611
757
|
// All request-directive and conditional-header guards below MUST read from
|
|
612
758
|
// the lowercased key.headers, not raw opts.headers — otherwise a caller
|
|
@@ -632,7 +778,20 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
632
778
|
const onlyIfCached = requestCacheControl['only-if-cached'] === true
|
|
633
779
|
const store = getStore(opts)
|
|
634
780
|
|
|
635
|
-
|
|
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
|
|
787
|
+
let entry
|
|
788
|
+
if (write !== null) {
|
|
789
|
+
const lookupStart = performance.now()
|
|
790
|
+
entry = tryGetEntry(store, key, opts.logger)
|
|
791
|
+
lookupMs = Math.round(performance.now() - lookupStart)
|
|
792
|
+
} else {
|
|
793
|
+
entry = tryGetEntry(store, key, opts.logger)
|
|
794
|
+
}
|
|
636
795
|
|
|
637
796
|
// RFC 9111 §3.5 serve-side authorization gate: a shared cache must not
|
|
638
797
|
// reuse a stored response for a request with Authorization unless the
|
|
@@ -649,6 +808,7 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
649
808
|
)
|
|
650
809
|
) {
|
|
651
810
|
entry = undefined
|
|
811
|
+
missReason = 'auth'
|
|
652
812
|
}
|
|
653
813
|
}
|
|
654
814
|
|
|
@@ -658,6 +818,9 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
658
818
|
store,
|
|
659
819
|
logger: opts.logger,
|
|
660
820
|
handler,
|
|
821
|
+
write,
|
|
822
|
+
id: opts.id ?? null,
|
|
823
|
+
url,
|
|
661
824
|
})
|
|
662
825
|
|
|
663
826
|
// Request Cache-Control directives that this cache does not evaluate locally
|
|
@@ -678,6 +841,21 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
678
841
|
requestCacheControl['min-fresh'] != null)
|
|
679
842
|
|
|
680
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
|
+
}
|
|
681
859
|
return dispatch(opts, requestCacheControl['no-store'] ? handler : cacheHandler())
|
|
682
860
|
}
|
|
683
861
|
|
|
@@ -696,10 +874,14 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
696
874
|
{ statusCode: 304, headers: entry.headers, cachedAt: entry.cachedAt },
|
|
697
875
|
opts,
|
|
698
876
|
handler,
|
|
877
|
+
write,
|
|
878
|
+
url,
|
|
879
|
+
lookupMs,
|
|
699
880
|
)
|
|
700
881
|
}
|
|
701
882
|
// Etag didn't match — bypass to origin.
|
|
702
883
|
entry = undefined
|
|
884
|
+
missReason = 'etag'
|
|
703
885
|
} else if (entry && headers['if-modified-since']) {
|
|
704
886
|
const lastModified = entry.headers?.['last-modified']
|
|
705
887
|
if (
|
|
@@ -711,32 +893,60 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
711
893
|
{ statusCode: 304, headers: entry.headers, cachedAt: entry.cachedAt },
|
|
712
894
|
opts,
|
|
713
895
|
handler,
|
|
896
|
+
write,
|
|
897
|
+
url,
|
|
898
|
+
lookupMs,
|
|
714
899
|
)
|
|
715
900
|
}
|
|
716
901
|
// No last-modified or modified since — bypass to origin.
|
|
717
902
|
entry = undefined
|
|
903
|
+
missReason = 'modified'
|
|
718
904
|
}
|
|
719
905
|
|
|
720
906
|
if (headers['if-match'] || headers['if-unmodified-since'] || headers['if-range']) {
|
|
721
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
|
+
}
|
|
722
911
|
return dispatch(opts, handler)
|
|
723
912
|
}
|
|
724
913
|
|
|
725
914
|
if (!entry && !onlyIfCached) {
|
|
915
|
+
if (write !== null) {
|
|
916
|
+
traceLookup(write, opts, url, 'miss', missReason, null, null, null, lookupMs)
|
|
917
|
+
}
|
|
726
918
|
// A miss keeps the CacheHandler write-back unless the request's no-store
|
|
727
919
|
// forbids storing.
|
|
728
920
|
return dispatch(opts, requestCacheControl['no-store'] ? handler : cacheHandler())
|
|
729
921
|
}
|
|
730
922
|
|
|
731
923
|
// A hit (fresh, per the store) is served; only-if-cached with no usable
|
|
732
|
-
// entry yields 504 (RFC 9111 §5.2.1.7)
|
|
733
|
-
|
|
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
|
+
)
|
|
734
939
|
}
|
|
735
940
|
|
|
736
|
-
|
|
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) {
|
|
737
946
|
const { statusCode, trailers, body } = entry
|
|
738
947
|
|
|
739
948
|
let headers = entry.headers
|
|
949
|
+
let age = null
|
|
740
950
|
if (entry.cachedAt != null) {
|
|
741
951
|
// RFC 9111 §5.1: every response served from cache must carry an Age
|
|
742
952
|
// header. cachedAt is backdated by the corrected initial age at store
|
|
@@ -744,10 +954,20 @@ function serveFromCache(entry, opts, handler) {
|
|
|
744
954
|
// IS the response's age — no origin-Age addition. Date.now(), not
|
|
745
955
|
// getFastNow(): the lagging clock would understate a relayed response's
|
|
746
956
|
// initial age by up to a second.
|
|
747
|
-
|
|
957
|
+
age = Math.max(0, Math.floor((Date.now() - entry.cachedAt) / 1000))
|
|
748
958
|
headers = { ...headers, age: `${age}` }
|
|
749
959
|
}
|
|
750
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
|
+
|
|
751
971
|
// serveFromCache drives the raw user handler directly (no DecoratorHandler),
|
|
752
972
|
// so it must enforce the contract itself: onError is terminal and mutually
|
|
753
973
|
// exclusive with onComplete. The `completed` guard makes a late abort() a
|