@nxtedition/nxt-undici 7.4.2 → 7.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,17 @@
1
1
  import { Scheduler } from '@nxtedition/scheduler'
2
2
  import { DecoratorHandler } from '../utils.js'
3
+ import { traceWrite, traceSafe, traceUrl } from '../trace.js'
3
4
 
4
5
  class Handler extends DecoratorHandler {
5
6
  #scheduler
6
7
  #onIdle
8
+ #trace
7
9
 
8
- constructor(handler, scheduler, onIdle) {
10
+ constructor(handler, scheduler, onIdle, trace) {
9
11
  super(handler)
10
12
  this.#scheduler = scheduler
11
13
  this.#onIdle = onIdle
14
+ this.#trace = trace
12
15
  }
13
16
 
14
17
  onConnect(abort) {
@@ -30,8 +33,34 @@ class Handler extends DecoratorHandler {
30
33
  if (this.#scheduler) {
31
34
  const scheduler = this.#scheduler
32
35
  this.#scheduler = null
36
+
37
+ // Slot-release timestamp is captured BEFORE release(): it synchronously
38
+ // pumps queued dispatches, whose work must not inflate this request's
39
+ // holdMs. Emission happens after release + eviction, inside the same
40
+ // once-guard as release() (every terminal callback funnels here), so
41
+ // the end doc cannot double-fire and the writer never observes a
42
+ // handler that still holds the slot.
43
+ const trace = this.#trace
44
+ const released = trace !== null ? performance.now() : 0
45
+
33
46
  scheduler.release()
34
47
  this.#onIdle?.()
48
+
49
+ if (trace !== null) {
50
+ traceSafe(
51
+ trace.write,
52
+ {
53
+ id: trace.id,
54
+ key: trace.key,
55
+ priority: trace.priority,
56
+ phase: 'end',
57
+ pending: null,
58
+ waitMs: Math.round(trace.dispatched - trace.acquired),
59
+ holdMs: Math.round(released - trace.dispatched),
60
+ },
61
+ 'undici:priority',
62
+ )
63
+ }
35
64
  }
36
65
  }
37
66
  }
@@ -66,9 +95,30 @@ export default () => (dispatch) => {
66
95
  }
67
96
  }
68
97
 
69
- const priorityHandler = new Handler(handler, scheduler, onIdle)
70
- scheduler.acquire(
98
+ // Trace state (op 'undici:priority') is resolved once per request:
99
+ // capture-once keeps the queued/end pair on one writer, and when tracing
100
+ // is off the cost is one property read — no clock reads, no string work.
101
+ // `acquired` must be stamped BEFORE acquire(): a free slot invokes the
102
+ // callback synchronously and `dispatched` would otherwise predate it.
103
+ const write = traceWrite(opts.trace)
104
+ const trace =
105
+ write !== null
106
+ ? {
107
+ write,
108
+ id: opts.id ?? null,
109
+ key: traceUrl({ origin: key }),
110
+ priority: String(opts.priority).slice(0, 16),
111
+ acquired: performance.now(),
112
+ dispatched: 0,
113
+ }
114
+ : null
115
+
116
+ const priorityHandler = new Handler(handler, scheduler, onIdle, trace)
117
+ const acquired = scheduler.acquire(
71
118
  (priorityHandler) => {
119
+ if (trace !== null) {
120
+ trace.dispatched = performance.now()
121
+ }
72
122
  try {
73
123
  dispatch(opts, priorityHandler)
74
124
  } catch (err) {
@@ -78,5 +128,25 @@ export default () => (dispatch) => {
78
128
  opts.priority,
79
129
  priorityHandler,
80
130
  )
131
+
132
+ // acquire() returns false only when no slot was free and the request was
133
+ // queued — the breadcrumb for a request that enters the queue and never
134
+ // leaves. pending is the post-enqueue queue depth, so it counts this
135
+ // request.
136
+ if (!acquired && trace !== null) {
137
+ traceSafe(
138
+ trace.write,
139
+ {
140
+ id: trace.id,
141
+ key: trace.key,
142
+ priority: trace.priority,
143
+ phase: 'queued',
144
+ pending: scheduler.pending,
145
+ waitMs: null,
146
+ holdMs: null,
147
+ },
148
+ 'undici:priority',
149
+ )
150
+ }
81
151
  }
82
152
  }
@@ -108,10 +108,13 @@ function eqiLower(a, b) {
108
108
  // allocation-free and letting the common (non-hop) header bail out in a single
109
109
  // comparison.
110
110
  /**
111
+ * Exported for reuse by the cache interceptor: RFC 9111 §3.1 forbids storing
112
+ * hop-by-hop fields, which is the same set a proxy must not retransmit.
113
+ *
111
114
  * @param {string} key
112
115
  * @returns {boolean}
113
116
  */
114
- function isHopByHop(key) {
117
+ export function isHopByHop(key) {
115
118
  switch (key.length) {
116
119
  case 2:
117
120
  return eqiLower(key, 'te')
@@ -1,5 +1,6 @@
1
1
  import assert from 'node:assert'
2
2
  import { DecoratorHandler, isDisturbed, parseURL, parseHeaders, buildURL } from '../utils.js'
3
+ import { traceWrite, traceSafe, traceUrl } from '../trace.js'
3
4
 
4
5
  const redirectableStatusCodes = [300, 301, 302, 303, 307, 308]
5
6
 
@@ -105,6 +106,13 @@ class Handler extends DecoratorHandler {
105
106
  }
106
107
  }
107
108
 
109
+ // The redirect decision is final past this point, so trace work stays off
110
+ // the non-redirect path entirely. Resolve the writer per emission (trace
111
+ // survives the opts spread across hops) and capture `from` before #opts
112
+ // is rebuilt below.
113
+ const write = traceWrite(this.#opts.trace)
114
+ const from = write !== null ? traceUrl(this.#opts) : null
115
+
108
116
  // Build the base URL by concatenating origin + path via buildURL rather
109
117
  // than `new URL(path, origin)`: the latter is unsafe when `path` is
110
118
  // protocol-relative (e.g. `//evil-host/x`, reachable via a request URL like
@@ -140,6 +148,24 @@ class Handler extends DecoratorHandler {
140
148
  body: null,
141
149
  }
142
150
  }
151
+
152
+ if (write !== null) {
153
+ traceSafe(
154
+ write,
155
+ {
156
+ id: this.#opts.id ?? null,
157
+ // Post-rewrite method for the next hop (303 may have swapped it above).
158
+ method: this.#opts.method ?? null,
159
+ statusCode,
160
+ from,
161
+ to: traceUrl(this.#opts),
162
+ // #count is 1-based: incremented above for this hop, so the first
163
+ // followed redirect emits count 1.
164
+ count: this.#count,
165
+ },
166
+ 'undici:redirect',
167
+ )
168
+ }
143
169
  }
144
170
 
145
171
  onData(chunk) {
@@ -8,6 +8,7 @@ import {
8
8
  parseHeaders,
9
9
  } from '../utils.js'
10
10
  import { RequestAbortedError } from '../errors.js'
11
+ import { traceWrite, traceSafe, traceErr, traceUrl } from '../trace.js'
11
12
 
12
13
  // Maximum number of >= 400 response body bytes buffered for replay in case
13
14
  // the response is not retried. Larger bodies are passed straight through and
@@ -16,6 +17,30 @@ const MAX_ERROR_BODY_SIZE = 256 * 1024
16
17
 
17
18
  function noop() {}
18
19
 
20
+ // Emit an `undici:retry` trace doc at the point a retry is actually scheduled.
21
+ // Cold path only — a retry already implies a failed attempt — so resolving the
22
+ // writer per emission (same resolution as the trace interceptor: explicit
23
+ // opts.trace wins, absent falls back to the global) costs nothing on the
24
+ // request hot path. `cause` is the triggering failure: the attempt's error, or
25
+ // the bare status code for a status-code retry without one.
26
+ function traceRetry(opts, retryCount, delay, cause) {
27
+ const write = traceWrite(opts.trace)
28
+ if (write !== null) {
29
+ traceSafe(
30
+ write,
31
+ {
32
+ id: opts.id ?? null,
33
+ method: opts.method ?? null,
34
+ url: traceUrl(opts),
35
+ retryCount,
36
+ delayMs: delay,
37
+ err: traceErr(cause),
38
+ },
39
+ 'undici:retry',
40
+ )
41
+ }
42
+ }
43
+
19
44
  // Subscribe `onAbort` to an EventEmitter-style OR EventTarget-style signal and
20
45
  // return an unsubscribe function; return null when it cannot be done safely.
21
46
  //
@@ -618,6 +643,7 @@ class Handler extends DecoratorHandler {
618
643
  Math.min(retryAfter, 60e3)
619
644
  : Math.min(10e3, retryCount * 1e3)
620
645
  this.#opts.logger?.debug({ statusCode, retryAfter, delay, retryCount }, 'retry delay')
646
+ traceRetry(this.#opts, retryCount, delay, err ?? statusCode)
621
647
  return this.#backoff(delay, opts)
622
648
  }
623
649
 
@@ -638,13 +664,17 @@ class Handler extends DecoratorHandler {
638
664
  'UND_ERR_SOCKET',
639
665
  ].includes(err.code)
640
666
  ) {
667
+ const delay = Math.min(10e3, retryCount * 1e3)
641
668
  this.#opts.logger?.debug({ err, retryCount }, 'retry delay')
642
- return this.#backoff(Math.min(10e3, retryCount * 1e3), opts)
669
+ traceRetry(this.#opts, retryCount, delay, err)
670
+ return this.#backoff(delay, opts)
643
671
  }
644
672
 
645
673
  if (err?.message && ['other side closed'].includes(err.message)) {
674
+ const delay = Math.min(10e3, retryCount * 1e3)
646
675
  this.#opts.logger?.debug({ err, retryCount }, 'retry delay')
647
- return this.#backoff(Math.min(10e3, retryCount * 1e3), opts)
676
+ traceRetry(this.#opts, retryCount, delay, err)
677
+ return this.#backoff(delay, opts)
648
678
  }
649
679
 
650
680
  return false
@@ -1,7 +1,38 @@
1
1
  import crypto from 'node:crypto'
2
2
  import { DecoratorHandler, parseContentRange } from '../utils.js'
3
+ import { traceWrite, traceSafe, traceUrl } from '../trace.js'
4
+
5
+ // Emit an `undici:verify` trace doc carrying the by-how-much/what-values
6
+ // detail of a verification failure, immediately before the error is delivered.
7
+ // The undici:request end doc already tags THAT the request failed; this doc is
8
+ // for fingerprinting truncation vs corruption. Cold path only — every call
9
+ // site is an already-failing branch — so the writer is resolved per emission
10
+ // (explicit opts.trace wins, absent falls back to the global) at zero cost on
11
+ // the per-chunk hot path. Hash values are origin-influenced strings (a
12
+ // duplicated Content-MD5 header keeps `expected` as an array of conflicting
13
+ // values, which String() flattens), so bound them.
14
+ function traceVerify(opts, kind, expectedSize, actualSize, expectedHash, actualHash) {
15
+ const write = traceWrite(opts.trace)
16
+ if (write !== null) {
17
+ traceSafe(
18
+ write,
19
+ {
20
+ id: opts.id ?? null,
21
+ method: opts.method ?? null,
22
+ url: traceUrl(opts),
23
+ kind,
24
+ expectedSize,
25
+ actualSize,
26
+ expectedHash: expectedHash != null ? String(expectedHash).slice(0, 64) : null,
27
+ actualHash: actualHash != null ? String(actualHash).slice(0, 64) : null,
28
+ },
29
+ 'undici:verify',
30
+ )
31
+ }
32
+ }
3
33
 
4
34
  class Handler extends DecoratorHandler {
35
+ #opts
5
36
  #verifyOpts
6
37
  #contentMD5
7
38
  #expectedSize
@@ -12,6 +43,8 @@ class Handler extends DecoratorHandler {
12
43
  constructor(opts, { handler }) {
13
44
  super(handler)
14
45
 
46
+ // Retained only for failure-time trace tagging (id/method/url).
47
+ this.#opts = opts
15
48
  this.#verifyOpts = opts.verify === true ? { hash: true, size: true } : opts.verify
16
49
  }
17
50
 
@@ -70,6 +103,7 @@ class Handler extends DecoratorHandler {
70
103
  expected: this.#expectedSize,
71
104
  actual: this.#pos,
72
105
  })
106
+ traceVerify(this.#opts, 'overrun', this.#expectedSize, this.#pos, null, null)
73
107
  super.onError(err)
74
108
  // Returning false only applies backpressure; the socket would stay
75
109
  // paused until bodyTimeout. Abort to release the connection now.
@@ -84,6 +118,7 @@ class Handler extends DecoratorHandler {
84
118
  const contentMD5 = this.#hasher?.digest('base64')
85
119
 
86
120
  if (this.#expectedSize != null && this.#pos !== this.#expectedSize) {
121
+ traceVerify(this.#opts, 'size', this.#expectedSize, this.#pos, null, null)
87
122
  super.onError(
88
123
  Object.assign(new Error('Response body size mismatch'), {
89
124
  expected: this.#expectedSize,
@@ -91,6 +126,7 @@ class Handler extends DecoratorHandler {
91
126
  }),
92
127
  )
93
128
  } else if (this.#contentMD5 != null && contentMD5 !== this.#contentMD5) {
129
+ traceVerify(this.#opts, 'hash', null, null, this.#contentMD5, contentMD5)
94
130
  super.onError(
95
131
  Object.assign(new Error('Response Content-MD5 mismatch'), {
96
132
  expected: this.#contentMD5,
@@ -2,7 +2,7 @@ import { DatabaseSync } from 'node:sqlite'
2
2
  import { parseRangeHeader, getFastNow } from './utils.js'
3
3
 
4
4
  // Bump version when the URL key format or schema changes to invalidate old caches.
5
- const VERSION = 10
5
+ const VERSION = 11
6
6
 
7
7
  // Registry of live stores so process-level broadcasts (nxt:offPeak,
8
8
  // nxt:clearCache) can reach them. Stores are held via WeakRef so that a store
@@ -51,8 +51,9 @@ function forEachStore(fn) {
51
51
  }
52
52
 
53
53
  /**
54
- * @typedef {import('undici-types/cache-interceptor.d.ts').default.CacheStore} CacheStore
55
- * @implements {CacheStore}
54
+ * Synchronous get/set/delete cache store backed by node:sqlite. Note: this is
55
+ * NOT undici's stream-based CacheStore interface (createWriteStream); the
56
+ * cache interceptor in this package drives it directly.
56
57
  *
57
58
  * @typedef {{
58
59
  * id: Readonly<number>,
@@ -66,6 +67,7 @@ function forEachStore(fn) {
66
67
  * etag?: string
67
68
  * cacheControlDirectives?: string
68
69
  * cachedAt: number
70
+ * staleAt: number
69
71
  * deleteAt: number
70
72
  * }} SqliteStoreValue
71
73
  */
@@ -100,6 +102,21 @@ export class SqliteCacheStore {
100
102
  */
101
103
  #evictQuery
102
104
 
105
+ /**
106
+ * @type {import('node:sqlite').StatementSync}
107
+ */
108
+ #deleteByUrlQuery
109
+
110
+ /**
111
+ * @type {import('node:sqlite').StatementSync}
112
+ */
113
+ #supersedeFullQuery
114
+
115
+ /**
116
+ * @type {import('node:sqlite').StatementSync}
117
+ */
118
+ #supersede206Query
119
+
103
120
  #insertBatch = []
104
121
  #insertSeq = 0
105
122
  #closed = false
@@ -136,6 +153,7 @@ export class SqliteCacheStore {
136
153
  body BLOB NULL,
137
154
  start INTEGER NOT NULL,
138
155
  end INTEGER NOT NULL,
156
+ staleAt INTEGER NOT NULL,
139
157
  deleteAt INTEGER NOT NULL,
140
158
  statusCode INTEGER NOT NULL,
141
159
  statusMessage TEXT NOT NULL,
@@ -148,6 +166,12 @@ export class SqliteCacheStore {
148
166
 
149
167
  CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_getValuesQuery ON cacheInterceptorV${VERSION}(url, method, start, deleteAt);
150
168
  CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_deleteExpiredValuesQuery ON cacheInterceptorV${VERSION}(deleteAt);
169
+ -- Covers the supersede-on-flush DELETEs (#supersedeFullQuery /
170
+ -- #supersede206Query), whose predicate is url+method+vary (+statusCode,
171
+ -- and start/end for 206) — so a re-cache supersedes via an index seek
172
+ -- instead of scanning every row for a hot url+method with many Vary
173
+ -- variants or partial windows.
174
+ CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_supersedeQuery ON cacheInterceptorV${VERSION}(url, method, vary, statusCode, start, end);
151
175
  `)
152
176
 
153
177
  // Drop tables left behind by previous schema versions. gc(), clear() and
@@ -200,6 +224,7 @@ export class SqliteCacheStore {
200
224
  body,
201
225
  start,
202
226
  end,
227
+ staleAt,
203
228
  deleteAt,
204
229
  statusCode,
205
230
  statusMessage,
@@ -215,7 +240,7 @@ export class SqliteCacheStore {
215
240
  AND start <= ?
216
241
  AND deleteAt > ?
217
242
  ORDER BY
218
- cachedAt DESC, id DESC
243
+ id DESC
219
244
  `)
220
245
 
221
246
  this.#insertValueQuery = this.#db.prepare(`
@@ -225,6 +250,7 @@ export class SqliteCacheStore {
225
250
  body,
226
251
  start,
227
252
  end,
253
+ staleAt,
228
254
  deleteAt,
229
255
  statusCode,
230
256
  statusMessage,
@@ -233,7 +259,7 @@ export class SqliteCacheStore {
233
259
  cacheControlDirectives,
234
260
  vary,
235
261
  cachedAt
236
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
262
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
237
263
  `)
238
264
 
239
265
  this.#deleteExpiredValuesQuery = this.#db.prepare(
@@ -246,6 +272,35 @@ export class SqliteCacheStore {
246
272
  `DELETE FROM cacheInterceptorV${VERSION} WHERE id IN (SELECT id FROM cacheInterceptorV${VERSION} ORDER BY deleteAt ASC LIMIT ?)`,
247
273
  )
248
274
 
275
+ // RFC 9111 §4.4 invalidation: a successful unsafe request invalidates every
276
+ // stored response for the URI, across methods and vary variants.
277
+ this.#deleteByUrlQuery = this.#db.prepare(
278
+ `DELETE FROM cacheInterceptorV${VERSION} WHERE url = ?`,
279
+ )
280
+
281
+ // Supersede queries: a newly flushed row replaces prior rows for the same
282
+ // representation so hot keys don't accumulate dead duplicates until gc.
283
+ // Receipt order (insertion order), NOT cachedAt, decides who wins — the
284
+ // same semantics as upstream undici's update-in-place set(). cachedAt is
285
+ // backdated by the corrected initial age (RFC 9111 §4.2.3), so a
286
+ // replacement fetched through a relay advertising a large Age (or a 304
287
+ // freshening with a skewed origin Date) can carry an OLDER cachedAt than
288
+ // the stale row it replaces; keying supersede or the read sort on
289
+ // cachedAt would let the stale row win every read, forever. `vary IS ?`
290
+ // is NULL-safe and compares the serialized-JSON text, deterministic
291
+ // because both sides are produced by the same CacheHandler code path
292
+ // (key order = Vary header order). A new full (non-206) representation
293
+ // supersedes all prior full ones regardless of byte window; a 206 only
294
+ // supersedes the exact same window so distinct partials coexist, and
295
+ // never a 200 row (matchesValue routes range and non-range requests to
296
+ // different rows).
297
+ this.#supersedeFullQuery = this.#db.prepare(
298
+ `DELETE FROM cacheInterceptorV${VERSION} WHERE url = ? AND method = ? AND vary IS ? AND statusCode != 206`,
299
+ )
300
+ this.#supersede206Query = this.#db.prepare(
301
+ `DELETE FROM cacheInterceptorV${VERSION} WHERE url = ? AND method = ? AND vary IS ? AND statusCode = 206 AND start = ? AND end = ?`,
302
+ )
303
+
249
304
  this.#ref = new WeakRef(this)
250
305
  stores.add(this.#ref)
251
306
  // The store itself doubles as the unregister token so close() can drop
@@ -371,7 +426,7 @@ export class SqliteCacheStore {
371
426
  setImmediate(this.#flush)
372
427
  }
373
428
 
374
- this.#insertBatch.push({
429
+ const entry = {
375
430
  // Monotonic per-store sequence used only to break cachedAt ties in
376
431
  // #findValue (newest write wins). Not persisted — #flush ignores it.
377
432
  seq: this.#insertSeq++,
@@ -380,6 +435,9 @@ export class SqliteCacheStore {
380
435
  body,
381
436
  start: value.start,
382
437
  end: value.end,
438
+ // staleAt column is NOT NULL; default to deleteAt when the caller omits
439
+ // it (pre-v11 value shape) — matches this package's staleAt === deleteAt.
440
+ staleAt: value.staleAt ?? value.deleteAt,
383
441
  deleteAt: value.deleteAt,
384
442
  statusCode: value.statusCode,
385
443
  statusMessage: value.statusMessage,
@@ -390,7 +448,56 @@ export class SqliteCacheStore {
390
448
  : null,
391
449
  vary: value.vary ? JSON.stringify(value.vary) : null,
392
450
  cachedAt: value.cachedAt,
393
- })
451
+ }
452
+
453
+ // Coalesce within the pending batch: a stampede of identical misses would
454
+ // otherwise commit N identical rows. Receipt order wins (see the
455
+ // #supersede* query comments): the entry being added supersedes every
456
+ // pending entry for the same representation.
457
+ for (let i = this.#insertBatch.length - 1; i >= 0; i--) {
458
+ const other = this.#insertBatch[i]
459
+ if (
460
+ other.url === entry.url &&
461
+ other.method === entry.method &&
462
+ other.vary === entry.vary &&
463
+ (entry.statusCode !== 206
464
+ ? other.statusCode !== 206
465
+ : other.statusCode === 206 && other.start === entry.start && other.end === entry.end)
466
+ ) {
467
+ this.#insertBatch.splice(i, 1)
468
+ }
469
+ }
470
+
471
+ this.#insertBatch.push(entry)
472
+ }
473
+
474
+ /**
475
+ * Invalidates every stored response for the key's URI (RFC 9111 §4.4) —
476
+ * across methods and vary variants. Pending batched inserts for the URI are
477
+ * dropped as well: a batch entry flushed after the DELETE would otherwise
478
+ * resurrect the invalidated response. Note this only covers THIS process's
479
+ * batch — with a shared on-disk store, another process's in-flight batch
480
+ * can still transiently resurrect an entry (bounded by its ~10ms flush
481
+ * slice); cross-process invalidation is inherently best-effort.
482
+ *
483
+ * @param {import('undici-types/cache-interceptor.d.ts').default.CacheKey} key
484
+ */
485
+ delete(key) {
486
+ assertCacheKey(key)
487
+
488
+ if (this.#closed) {
489
+ return
490
+ }
491
+
492
+ const url = makeValueUrl(key)
493
+
494
+ for (let i = this.#insertBatch.length - 1; i >= 0; i--) {
495
+ if (this.#insertBatch[i].url === url) {
496
+ this.#insertBatch.splice(i, 1)
497
+ }
498
+ }
499
+
500
+ this.#deleteByUrlQuery.run(url)
394
501
  }
395
502
 
396
503
  #flush = (final = false) => {
@@ -412,6 +519,7 @@ export class SqliteCacheStore {
412
519
  body,
413
520
  start,
414
521
  end,
522
+ staleAt,
415
523
  deleteAt,
416
524
  statusCode,
417
525
  statusMessage,
@@ -421,12 +529,21 @@ export class SqliteCacheStore {
421
529
  vary,
422
530
  cachedAt,
423
531
  } = this.#insertBatch[n++]
532
+ // Supersede prior rows for the same representation (see the
533
+ // #supersede* query comments) inside the same transaction, so a
534
+ // re-cache replaces instead of accumulating.
535
+ if (statusCode === 206) {
536
+ this.#supersede206Query.run(url, method, vary, start, end)
537
+ } else {
538
+ this.#supersedeFullQuery.run(url, method, vary)
539
+ }
424
540
  this.#insertValueQuery.run(
425
541
  url,
426
542
  method,
427
543
  body,
428
544
  start,
429
545
  end,
546
+ staleAt,
430
547
  deleteAt,
431
548
  statusCode,
432
549
  statusMessage,
@@ -534,16 +651,14 @@ export class SqliteCacheStore {
534
651
  return undefined
535
652
  }
536
653
 
537
- // Newest representation wins. cachedAt is millisecond-resolution, so a
538
- // re-cache within the same millisecond produces a tie; break it
539
- // deterministically toward the freshest write: pending batch entries
540
- // (tagged with a monotonic seq) are always newer than any flushed DB row,
541
- // and within each source a higher seq/id wins.
654
+ // Most recently WRITTEN representation wins receipt order, not
655
+ // cachedAt (see the #supersede* query comments: cachedAt is backdated by
656
+ // the corrected initial age, so a fresher write can carry an older
657
+ // cachedAt). Pending batch entries (tagged with a monotonic seq) are
658
+ // always newer than any flushed DB row, and within each source a higher
659
+ // seq/id wins.
542
660
  if (values.length > 1) {
543
661
  values.sort((a, b) => {
544
- if (a.cachedAt !== b.cachedAt) {
545
- return b.cachedAt - a.cachedAt
546
- }
547
662
  const aBatch = a.seq != null
548
663
  const bBatch = b.seq != null
549
664
  if (aBatch !== bBatch) {
@@ -657,6 +772,7 @@ function makeResult(value) {
657
772
  ? JSON.parse(value.cacheControlDirectives)
658
773
  : undefined,
659
774
  cachedAt: value.cachedAt,
775
+ staleAt: value.staleAt,
660
776
  deleteAt: value.deleteAt,
661
777
  }
662
778
  }
@@ -704,6 +820,16 @@ function assertCacheValue(value) {
704
820
  }
705
821
  }
706
822
 
823
+ // staleAt is optional for backward compatibility: pre-v11 callers of the
824
+ // public SqliteCacheStore.set() omit it, and set() defaults it to deleteAt
825
+ // (this package's staleAt === deleteAt semantics). Validate the type only
826
+ // when supplied.
827
+ if (value.staleAt !== undefined && typeof value.staleAt !== 'number') {
828
+ throw new TypeError(
829
+ `expected value.staleAt to be number, got ${printType(value.staleAt)} [${value.staleAt}]`,
830
+ )
831
+ }
832
+
707
833
  if (typeof value.statusMessage !== 'string') {
708
834
  throw new TypeError(
709
835
  `expected value.statusMessage to be string, got ${printType(value.statusMessage)} [${value.statusMessage}]`,
package/lib/trace.js ADDED
@@ -0,0 +1,87 @@
1
+ // Trace plumbing shared by the interceptors. The writer contract and the
2
+ // generic helpers live in @nxtedition/trace; only the nxt-undici-specific
3
+ // pieces (dispatch-opts url tagging, undici-flavored option validation) are
4
+ // implemented here.
5
+ //
6
+ // Importing @nxtedition/trace statically is safe despite the package cycle
7
+ // (@nxtedition/trace flushes through nxt-undici): the trace package imports
8
+ // nxt-undici lazily at first flush precisely so consumers can depend on it
9
+ // statically. The only constraint is publish order — @nxtedition/trace must
10
+ // be published before a nxt-undici release that depends on it.
11
+ //
12
+ // The contract in short: a writer is `{ write }` where `write` is the trace
13
+ // function while tracing is enabled and null while disabled (it flips between
14
+ // the two at runtime), so call sites gate on the resolved fn at zero cost when
15
+ // off. `write` must not call back into the request being traced — emission
16
+ // happens inside dispatch/handler control flow, and reentrancy there is
17
+ // unsupported.
18
+
19
+ import { validateTrace as validateTraceWriter } from '@nxtedition/trace'
20
+ import { InvalidArgumentError } from './errors.js'
21
+
22
+ // installTrace is part of the surface: the per-thread default writer lives in
23
+ // the Symbol.for('@nxtedition/app/trace') slot and is mirrored
24
+ // module-locally inside @nxtedition/trace, so a
25
+ // writer must be installed through installTrace — a bare slot assignment only
26
+ // propagates on the next mirror refresh.
27
+ export { traceWrite, traceSafe, traceErr, installTrace } from '@nxtedition/trace'
28
+
29
+ /**
30
+ * @typedef {import('@nxtedition/trace').TraceWriter} TraceWriter
31
+ */
32
+
33
+ /**
34
+ * Validate an opts.trace value, rethrowing the package's plain Error as the
35
+ * InvalidArgumentError (UND_ERR_INVALID_ARG) that dispatch option validation
36
+ * is expected to throw. Returns the input unchanged.
37
+ *
38
+ * @param {unknown} trace
39
+ * @returns {TraceWriter | null | undefined}
40
+ */
41
+ export function validateTrace(trace) {
42
+ try {
43
+ return validateTraceWriter(trace)
44
+ } catch {
45
+ throw new InvalidArgumentError('invalid trace')
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Bounded origin+path tag for trace docs. Mirrors log.js's sanitizeOrigin
51
+ * userinfo guard: an origin string carrying `user:pass@host` credentials is
52
+ * reduced to URL#origin (which never contains userinfo) before it can reach
53
+ * the trace index; if such a string is not a parseable URL, prefer losing the
54
+ * value over risking embedded credentials. Never throws — evaluated while
55
+ * building docs inside handler control flow.
56
+ *
57
+ * @param {{ origin?: unknown, path?: unknown }} opts
58
+ * @returns {string | null}
59
+ */
60
+ export function traceUrl(opts) {
61
+ try {
62
+ const origin = opts.origin
63
+ let str
64
+ if (origin == null) {
65
+ str = ''
66
+ } else if (origin instanceof URL) {
67
+ // Real URL instances already expose a credential-free origin.
68
+ str = origin.origin
69
+ } else {
70
+ // Raw dispatch()/compose() callers may pass URL-like objects or arrays
71
+ // (defaultLookup resolves those deeper in the chain) — stringify rather
72
+ // than lose the doc.
73
+ str = typeof origin === 'string' ? origin : String(origin)
74
+ if (str.includes('@')) {
75
+ try {
76
+ str = new URL(str).origin
77
+ } catch {
78
+ str = '[redacted]'
79
+ }
80
+ }
81
+ }
82
+ const path = typeof opts.path === 'string' ? opts.path : ''
83
+ return `${str}${path}`.slice(0, 256)
84
+ } catch {
85
+ return null
86
+ }
87
+ }