@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.
@@ -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
- values.sort((a, b) => b.cachedAt - a.cachedAt)
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
- // TODO (fix): Allow full and partial match?
434
- if (range && (range.start !== value.start || range.end !== value.end)) {
435
- continue
483
+ if (matchesValue(value, range, headers)) {
484
+ return value
436
485
  }
486
+ }
487
+
488
+ return undefined
489
+ }
490
+ }
437
491
 
438
- if (value.vary) {
439
- const vary = JSON.parse(value.vary)
440
- let matches = true
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
- for (const header in vary) {
443
- if (!headerValueEquals(headers?.[header], vary[header])) {
444
- matches = false
445
- break
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
- if (!matches) {
450
- continue
451
- }
452
- }
511
+ if (value.vary) {
512
+ const vary = JSON.parse(value.vary)
453
513
 
454
- return value
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
- if (Array.isArray(lhs) && Array.isArray(rhs)) {
476
- if (lhs.length !== rhs.length) {
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 lhs.every((x, i) => x === rhs[i])
549
+ return a.every((x, i) => x === b[i])
481
550
  }
482
551
 
483
- return lhs === rhs
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
- ? Buffer.from(value.body.buffer, value.body.byteOffset, value.body.byteLength)
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
- if (val) {
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 if (val2 != null) {
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
- if (
383
- typeof body === 'string' &&
384
- (!headers?.['content-type'] || headers['content-type'].startsWith('application/json'))
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.25",
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",