@nxtedition/nxt-undici 7.3.25 → 7.3.27
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.js +23 -4
- package/lib/interceptor/cache.js +124 -24
- package/lib/interceptor/dns.js +47 -11
- package/lib/interceptor/lookup.js +10 -2
- package/lib/interceptor/priority.js +23 -4
- package/lib/interceptor/proxy.js +222 -36
- package/lib/interceptor/query.js +6 -0
- package/lib/interceptor/redirect.js +27 -8
- package/lib/interceptor/request-body-factory.js +10 -3
- package/lib/interceptor/request-id.js +9 -2
- package/lib/interceptor/response-error.js +6 -4
- package/lib/interceptor/response-retry.js +30 -7
- package/lib/interceptor/response-verify.js +20 -7
- package/lib/request.js +36 -7
- package/lib/sqlite-cache-store.js +104 -29
- package/lib/utils.js +16 -12
- package/package.json +2 -1
|
@@ -78,6 +78,7 @@ export class SqliteCacheStore {
|
|
|
78
78
|
#evictQuery
|
|
79
79
|
|
|
80
80
|
#insertBatch = []
|
|
81
|
+
#insertSeq = 0
|
|
81
82
|
#closed = false
|
|
82
83
|
|
|
83
84
|
/**
|
|
@@ -142,7 +143,7 @@ export class SqliteCacheStore {
|
|
|
142
143
|
AND start <= ?
|
|
143
144
|
AND deleteAt > ?
|
|
144
145
|
ORDER BY
|
|
145
|
-
cachedAt DESC
|
|
146
|
+
cachedAt DESC, id DESC
|
|
146
147
|
`)
|
|
147
148
|
|
|
148
149
|
this.#insertValueQuery = this.#db.prepare(`
|
|
@@ -177,6 +178,10 @@ export class SqliteCacheStore {
|
|
|
177
178
|
}
|
|
178
179
|
|
|
179
180
|
gc() {
|
|
181
|
+
if (this.#closed) {
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
180
185
|
try {
|
|
181
186
|
this.#db.exec('PRAGMA busy_timeout = 1000')
|
|
182
187
|
this.#deleteExpiredValuesQuery.run(getFastNow())
|
|
@@ -218,8 +223,13 @@ export class SqliteCacheStore {
|
|
|
218
223
|
|
|
219
224
|
close() {
|
|
220
225
|
stores.delete(this)
|
|
226
|
+
// Drain the entire batch synchronously before closing. A plain #flush()
|
|
227
|
+
// only commits one time-budget slice and reschedules the rest via
|
|
228
|
+
// setImmediate; that deferred flush would see #closed and discard the
|
|
229
|
+
// remainder, silently losing entries. Pass final=true to ignore the
|
|
230
|
+
// budget and flush everything in one pass while #closed is still false.
|
|
221
231
|
if (this.#insertBatch.length > 0) {
|
|
222
|
-
this.#flush()
|
|
232
|
+
this.#flush(true)
|
|
223
233
|
}
|
|
224
234
|
this.#closed = true
|
|
225
235
|
this.#db.close()
|
|
@@ -285,6 +295,9 @@ export class SqliteCacheStore {
|
|
|
285
295
|
}
|
|
286
296
|
|
|
287
297
|
this.#insertBatch.push({
|
|
298
|
+
// Monotonic per-store sequence used only to break cachedAt ties in
|
|
299
|
+
// #findValue (newest write wins). Not persisted — #flush ignores it.
|
|
300
|
+
seq: this.#insertSeq++,
|
|
288
301
|
url: makeValueUrl(key),
|
|
289
302
|
method: key.method,
|
|
290
303
|
body,
|
|
@@ -303,7 +316,7 @@ export class SqliteCacheStore {
|
|
|
303
316
|
})
|
|
304
317
|
}
|
|
305
318
|
|
|
306
|
-
#flush = () => {
|
|
319
|
+
#flush = (final = false) => {
|
|
307
320
|
if (this.#insertBatch.length === 0) return
|
|
308
321
|
if (this.#closed) {
|
|
309
322
|
this.#insertBatch.length = 0
|
|
@@ -346,7 +359,7 @@ export class SqliteCacheStore {
|
|
|
346
359
|
vary,
|
|
347
360
|
cachedAt,
|
|
348
361
|
)
|
|
349
|
-
if ((n & 0xf) === 0 && performance.now() - startTime > 10) {
|
|
362
|
+
if (!final && (n & 0xf) === 0 && performance.now() - startTime > 10) {
|
|
350
363
|
break
|
|
351
364
|
}
|
|
352
365
|
}
|
|
@@ -407,6 +420,23 @@ export class SqliteCacheStore {
|
|
|
407
420
|
const now = getFastNow()
|
|
408
421
|
const requestedStart = range?.start ?? 0
|
|
409
422
|
|
|
423
|
+
if (this.#insertBatch.length === 0) {
|
|
424
|
+
// Fast path: rows arrive sorted (cachedAt DESC, id DESC) and there are
|
|
425
|
+
// no pending batch entries to merge, so fetch only the newest candidate.
|
|
426
|
+
// This covers misses and first-row hits (the overwhelmingly common
|
|
427
|
+
// cases) with a single row materialized — re-cached duplicates of a hot
|
|
428
|
+
// key would otherwise all be read including their blobs. Only when the
|
|
429
|
+
// newest row doesn't match (vary variant, range/206 mismatch) do we
|
|
430
|
+
// fall through to scan the full candidate set.
|
|
431
|
+
const value = this.#getValuesQuery.get(url, method, requestedStart, now)
|
|
432
|
+
if (value === undefined) {
|
|
433
|
+
return undefined
|
|
434
|
+
}
|
|
435
|
+
if (matchesValue(value, range, headers)) {
|
|
436
|
+
return value
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
410
440
|
/**
|
|
411
441
|
* @type {SqliteStoreValue[]}
|
|
412
442
|
*/
|
|
@@ -427,35 +457,68 @@ export class SqliteCacheStore {
|
|
|
427
457
|
return undefined
|
|
428
458
|
}
|
|
429
459
|
|
|
430
|
-
|
|
460
|
+
// Newest representation wins. cachedAt is millisecond-resolution, so a
|
|
461
|
+
// re-cache within the same millisecond produces a tie; break it
|
|
462
|
+
// deterministically toward the freshest write: pending batch entries
|
|
463
|
+
// (tagged with a monotonic seq) are always newer than any flushed DB row,
|
|
464
|
+
// and within each source a higher seq/id wins.
|
|
465
|
+
if (values.length > 1) {
|
|
466
|
+
values.sort((a, b) => {
|
|
467
|
+
if (a.cachedAt !== b.cachedAt) {
|
|
468
|
+
return b.cachedAt - a.cachedAt
|
|
469
|
+
}
|
|
470
|
+
const aBatch = a.seq != null
|
|
471
|
+
const bBatch = b.seq != null
|
|
472
|
+
if (aBatch !== bBatch) {
|
|
473
|
+
return aBatch ? -1 : 1
|
|
474
|
+
}
|
|
475
|
+
if (aBatch) {
|
|
476
|
+
return b.seq - a.seq
|
|
477
|
+
}
|
|
478
|
+
return (b.id ?? 0) - (a.id ?? 0)
|
|
479
|
+
})
|
|
480
|
+
}
|
|
431
481
|
|
|
432
482
|
for (const value of values) {
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
continue
|
|
483
|
+
if (matchesValue(value, range, headers)) {
|
|
484
|
+
return value
|
|
436
485
|
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return undefined
|
|
489
|
+
}
|
|
490
|
+
}
|
|
437
491
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
492
|
+
/**
|
|
493
|
+
* @param {SqliteStoreValue} value
|
|
494
|
+
* @param {import('./utils.js').RangeHeader | undefined} range
|
|
495
|
+
* @param {Record<string, string | string[]> | undefined} headers
|
|
496
|
+
* @returns {boolean}
|
|
497
|
+
*/
|
|
498
|
+
function matchesValue(value, range, headers) {
|
|
499
|
+
// TODO (fix): Allow full and partial match?
|
|
500
|
+
if (range && (range.start !== value.start || range.end !== value.end)) {
|
|
501
|
+
return false
|
|
502
|
+
}
|
|
441
503
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
504
|
+
// A request without a Range header asks for the full representation, so
|
|
505
|
+
// a stored 206 partial (e.g. content-range bytes 0-4/100, which the SQL
|
|
506
|
+
// `start <= 0` filter does not exclude) must not be served verbatim.
|
|
507
|
+
if (!range && value.statusCode === 206) {
|
|
508
|
+
return false
|
|
509
|
+
}
|
|
448
510
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
}
|
|
452
|
-
}
|
|
511
|
+
if (value.vary) {
|
|
512
|
+
const vary = JSON.parse(value.vary)
|
|
453
513
|
|
|
454
|
-
|
|
514
|
+
for (const header in vary) {
|
|
515
|
+
if (!headerValueEquals(headers?.[header], vary[header])) {
|
|
516
|
+
return false
|
|
517
|
+
}
|
|
455
518
|
}
|
|
456
|
-
|
|
457
|
-
return undefined
|
|
458
519
|
}
|
|
520
|
+
|
|
521
|
+
return true
|
|
459
522
|
}
|
|
460
523
|
|
|
461
524
|
/**
|
|
@@ -472,15 +535,21 @@ function headerValueEquals(lhs, rhs) {
|
|
|
472
535
|
return false
|
|
473
536
|
}
|
|
474
537
|
|
|
475
|
-
|
|
476
|
-
|
|
538
|
+
// A single-element array and the bare scalar denote the same logical header
|
|
539
|
+
// value (e.g. 'gzip' vs ['gzip']); normalize so an inconsistently-shaped
|
|
540
|
+
// selecting header doesn't cause an avoidable cache miss.
|
|
541
|
+
const a = Array.isArray(lhs) && lhs.length === 1 ? lhs[0] : lhs
|
|
542
|
+
const b = Array.isArray(rhs) && rhs.length === 1 ? rhs[0] : rhs
|
|
543
|
+
|
|
544
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
545
|
+
if (a.length !== b.length) {
|
|
477
546
|
return false
|
|
478
547
|
}
|
|
479
548
|
|
|
480
|
-
return
|
|
549
|
+
return a.every((x, i) => x === b[i])
|
|
481
550
|
}
|
|
482
551
|
|
|
483
|
-
return
|
|
552
|
+
return a === b
|
|
484
553
|
}
|
|
485
554
|
|
|
486
555
|
/**
|
|
@@ -493,8 +562,14 @@ function makeValueUrl(key) {
|
|
|
493
562
|
|
|
494
563
|
function makeResult(value) {
|
|
495
564
|
return {
|
|
565
|
+
// Batch entries (tagged with seq) must be copied: value.body is the exact
|
|
566
|
+
// Buffer still queued for flushing, so a consumer mutating the served body
|
|
567
|
+
// could corrupt the bytes about to be written. DB rows are safe to alias —
|
|
568
|
+
// node:sqlite allocates a fresh Uint8Array per read, so wrap it zero-copy.
|
|
496
569
|
body: value.body
|
|
497
|
-
?
|
|
570
|
+
? value.seq != null
|
|
571
|
+
? Buffer.from(value.body)
|
|
572
|
+
: Buffer.from(value.body.buffer, value.body.byteOffset, value.body.byteLength)
|
|
498
573
|
: undefined,
|
|
499
574
|
statusCode: value.statusCode,
|
|
500
575
|
statusMessage: value.statusMessage,
|
package/lib/utils.js
CHANGED
|
@@ -291,7 +291,10 @@ export function parseHeaders(headers, obj) {
|
|
|
291
291
|
const key = util.headerNameToString(key2)
|
|
292
292
|
let val = obj[key]
|
|
293
293
|
|
|
294
|
-
if (val)
|
|
294
|
+
// `key in obj`, not `if (val)`: an empty-string value ('') is a valid,
|
|
295
|
+
// present header. A truthy check would treat it as absent and overwrite
|
|
296
|
+
// it on a duplicate occurrence, silently dropping the first value.
|
|
297
|
+
if (key in obj) {
|
|
295
298
|
if (!Array.isArray(val)) {
|
|
296
299
|
val = [val]
|
|
297
300
|
obj[key] = val
|
|
@@ -322,7 +325,9 @@ export function parseHeaders(headers, obj) {
|
|
|
322
325
|
const key = util.headerNameToString(key2)
|
|
323
326
|
let val = obj[key]
|
|
324
327
|
|
|
325
|
-
|
|
328
|
+
// See the array branch above: presence check, not truthiness, so a
|
|
329
|
+
// stored empty string is not clobbered by a later duplicate.
|
|
330
|
+
if (key in obj) {
|
|
326
331
|
if (!Array.isArray(val)) {
|
|
327
332
|
val = [val]
|
|
328
333
|
obj[key] = val
|
|
@@ -332,7 +337,7 @@ export function parseHeaders(headers, obj) {
|
|
|
332
337
|
} else {
|
|
333
338
|
val.push(`${val2}`)
|
|
334
339
|
}
|
|
335
|
-
} else
|
|
340
|
+
} else {
|
|
336
341
|
obj[key] = Array.isArray(val2)
|
|
337
342
|
? val2.filter((x) => x != null).map((x) => `${x}`)
|
|
338
343
|
: `${val2}`
|
|
@@ -342,11 +347,6 @@ export function parseHeaders(headers, obj) {
|
|
|
342
347
|
throw new Error('invalid argument: headers')
|
|
343
348
|
}
|
|
344
349
|
|
|
345
|
-
// See https://github.com/nodejs/node/pull/46528
|
|
346
|
-
if (obj != null && 'content-length' in obj && 'content-disposition' in obj) {
|
|
347
|
-
obj['content-disposition'] = Buffer.from(obj['content-disposition']).toString('latin1')
|
|
348
|
-
}
|
|
349
|
-
|
|
350
350
|
return obj
|
|
351
351
|
}
|
|
352
352
|
|
|
@@ -379,10 +379,14 @@ export function decorateError(err, opts, { statusCode, headers, trailers, body }
|
|
|
379
379
|
body = null
|
|
380
380
|
}
|
|
381
381
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
382
|
+
// A duplicated content-type response header arrives as an array (undici's
|
|
383
|
+
// parseHeaders collapses repeats); coerce to the first value before
|
|
384
|
+
// calling string methods, otherwise this throws and the catch below
|
|
385
|
+
// discards all decoration into an opaque AggregateError.
|
|
386
|
+
const contentType = Array.isArray(headers?.['content-type'])
|
|
387
|
+
? headers['content-type'][0]
|
|
388
|
+
: headers?.['content-type']
|
|
389
|
+
if (typeof body === 'string' && (!contentType || contentType.startsWith('application/json'))) {
|
|
386
390
|
try {
|
|
387
391
|
body = JSON.parse(body)
|
|
388
392
|
} catch {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nxtedition/nxt-undici",
|
|
3
|
-
"version": "7.3.
|
|
3
|
+
"version": "7.3.27",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Robert Nagy <robert.nagy@boffins.se>",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"eslint-plugin-n": "^17.24.0",
|
|
24
24
|
"husky": "^9.1.7",
|
|
25
25
|
"lint-staged": "^16.4.0",
|
|
26
|
+
"mitata": "^1.0.34",
|
|
26
27
|
"pino": "^10.3.1",
|
|
27
28
|
"pinst": "^3.0.0",
|
|
28
29
|
"prettier": "^3.8.1",
|