@nxtedition/nxt-undici 7.4.1 → 7.4.3
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 +51 -8
- package/lib/index.js +1 -0
- package/lib/interceptor/cache.js +479 -108
- package/lib/interceptor/dns.js +73 -6
- package/lib/interceptor/log.js +184 -9
- package/lib/interceptor/pressure.js +11 -0
- package/lib/interceptor/proxy.js +46 -15
- package/lib/interceptor/redirect.js +35 -5
- package/lib/interceptor/request-body-factory.js +59 -8
- package/lib/interceptor/response-error.js +19 -2
- package/lib/interceptor/response-retry.js +273 -21
- package/lib/request.js +5 -1
- package/lib/sqlite-cache-store.js +228 -25
- package/lib/utils.js +320 -17
- package/package.json +1 -2
|
@@ -2,18 +2,43 @@ 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 =
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
const VERSION = 11
|
|
6
|
+
|
|
7
|
+
// Registry of live stores so process-level broadcasts (nxt:offPeak,
|
|
8
|
+
// nxt:clearCache) can reach them. Stores are held via WeakRef so that a store
|
|
9
|
+
// dropped without close() is not pinned forever (together with its open
|
|
10
|
+
// DatabaseSync handle and page cache) — GC can still collect it. close()
|
|
11
|
+
// remains the recommended, deterministic cleanup path.
|
|
12
|
+
/** @type {Set<WeakRef<SqliteCacheStore>>} */
|
|
8
13
|
const stores = new Set()
|
|
9
14
|
|
|
15
|
+
// Removes a collected store's WeakRef entry once the store has been GC'd.
|
|
16
|
+
// The callback runs after the store is already gone, so it must not (and
|
|
17
|
+
// cannot) touch the store or its DatabaseSync — the native handle has its own
|
|
18
|
+
// lifecycle and is released by GC/process exit. This only drops bookkeeping.
|
|
19
|
+
const registry = new FinalizationRegistry((ref) => {
|
|
20
|
+
stores.delete(ref)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {(store: SqliteCacheStore) => void} fn
|
|
25
|
+
*/
|
|
26
|
+
function forEachStore(fn) {
|
|
27
|
+
for (const ref of stores) {
|
|
28
|
+
const store = ref.deref()
|
|
29
|
+
if (store === undefined) {
|
|
30
|
+
stores.delete(ref)
|
|
31
|
+
} else {
|
|
32
|
+
fn(store)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
10
37
|
{
|
|
11
38
|
const offPeakBC = new BroadcastChannel('nxt:offPeak')
|
|
12
39
|
offPeakBC.unref()
|
|
13
40
|
offPeakBC.onmessage = () => {
|
|
14
|
-
|
|
15
|
-
store.gc()
|
|
16
|
-
}
|
|
41
|
+
forEachStore((store) => store.gc())
|
|
17
42
|
}
|
|
18
43
|
}
|
|
19
44
|
|
|
@@ -21,15 +46,14 @@ const stores = new Set()
|
|
|
21
46
|
const clearCacheBC = new BroadcastChannel('nxt:clearCache')
|
|
22
47
|
clearCacheBC.unref()
|
|
23
48
|
clearCacheBC.onmessage = () => {
|
|
24
|
-
|
|
25
|
-
store.clear()
|
|
26
|
-
}
|
|
49
|
+
forEachStore((store) => store.clear())
|
|
27
50
|
}
|
|
28
51
|
}
|
|
29
52
|
|
|
30
53
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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.
|
|
33
57
|
*
|
|
34
58
|
* @typedef {{
|
|
35
59
|
* id: Readonly<number>,
|
|
@@ -43,6 +67,7 @@ const stores = new Set()
|
|
|
43
67
|
* etag?: string
|
|
44
68
|
* cacheControlDirectives?: string
|
|
45
69
|
* cachedAt: number
|
|
70
|
+
* staleAt: number
|
|
46
71
|
* deleteAt: number
|
|
47
72
|
* }} SqliteStoreValue
|
|
48
73
|
*/
|
|
@@ -77,10 +102,30 @@ export class SqliteCacheStore {
|
|
|
77
102
|
*/
|
|
78
103
|
#evictQuery
|
|
79
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
|
+
|
|
80
120
|
#insertBatch = []
|
|
81
121
|
#insertSeq = 0
|
|
82
122
|
#closed = false
|
|
83
123
|
|
|
124
|
+
/**
|
|
125
|
+
* @type {WeakRef<SqliteCacheStore>}
|
|
126
|
+
*/
|
|
127
|
+
#ref
|
|
128
|
+
|
|
84
129
|
/**
|
|
85
130
|
* @param {import('undici-types/cache-interceptor.d.ts').default.SqliteCacheStoreOpts & { maxSize?: number } | undefined} opts
|
|
86
131
|
*/
|
|
@@ -108,6 +153,7 @@ export class SqliteCacheStore {
|
|
|
108
153
|
body BLOB NULL,
|
|
109
154
|
start INTEGER NOT NULL,
|
|
110
155
|
end INTEGER NOT NULL,
|
|
156
|
+
staleAt INTEGER NOT NULL,
|
|
111
157
|
deleteAt INTEGER NOT NULL,
|
|
112
158
|
statusCode INTEGER NOT NULL,
|
|
113
159
|
statusMessage TEXT NOT NULL,
|
|
@@ -120,14 +166,65 @@ export class SqliteCacheStore {
|
|
|
120
166
|
|
|
121
167
|
CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_getValuesQuery ON cacheInterceptorV${VERSION}(url, method, start, deleteAt);
|
|
122
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);
|
|
123
175
|
`)
|
|
124
176
|
|
|
177
|
+
// Drop tables left behind by previous schema versions. gc(), clear() and
|
|
178
|
+
// the SQLITE_FULL eviction only ever touch the current version's table, so
|
|
179
|
+
// after a VERSION bump the old table's pages would otherwise stay allocated
|
|
180
|
+
// to its b-tree forever while max_page_count caps the whole file — new
|
|
181
|
+
// inserts hit SQLITE_FULL almost immediately and eviction frees nothing.
|
|
182
|
+
// Dropping returns the pages to SQLite's freelist, which subsequent inserts
|
|
183
|
+
// reuse, so no VACUUM is needed. SQLite drops the table's indexes and its
|
|
184
|
+
// sqlite_sequence row along with it.
|
|
185
|
+
try {
|
|
186
|
+
// LIKE is only a coarse pre-filter; the regexp restricts matches to
|
|
187
|
+
// digit-only version suffixes so user tables sharing the prefix (e.g.
|
|
188
|
+
// "cacheInterceptorVBackup") in a shared database file are never dropped.
|
|
189
|
+
const staleTables = this.#db
|
|
190
|
+
.prepare(
|
|
191
|
+
`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'cacheInterceptorV%'`,
|
|
192
|
+
)
|
|
193
|
+
.all()
|
|
194
|
+
.filter(
|
|
195
|
+
({ name }) =>
|
|
196
|
+
/^cacheInterceptorV\d+$/.test(name) && name !== `cacheInterceptorV${VERSION}`,
|
|
197
|
+
)
|
|
198
|
+
if (staleTables.length > 0) {
|
|
199
|
+
this.#db.exec('BEGIN')
|
|
200
|
+
try {
|
|
201
|
+
for (const { name } of staleTables) {
|
|
202
|
+
// name comes from sqlite_master; quote it defensively anyway.
|
|
203
|
+
this.#db.exec(`DROP TABLE IF EXISTS "${String(name).replaceAll('"', '""')}"`)
|
|
204
|
+
}
|
|
205
|
+
this.#db.exec('COMMIT')
|
|
206
|
+
} catch (err) {
|
|
207
|
+
try {
|
|
208
|
+
this.#db.exec('ROLLBACK')
|
|
209
|
+
} catch {
|
|
210
|
+
// already rolled back automatically
|
|
211
|
+
}
|
|
212
|
+
throw err
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
} catch (err) {
|
|
216
|
+
// A failed cleanup must not brick construction — the current version's
|
|
217
|
+
// table still works, the stale one just keeps occupying pages.
|
|
218
|
+
process.emitWarning(err)
|
|
219
|
+
}
|
|
220
|
+
|
|
125
221
|
this.#getValuesQuery = this.#db.prepare(`
|
|
126
222
|
SELECT
|
|
127
223
|
id,
|
|
128
224
|
body,
|
|
129
225
|
start,
|
|
130
226
|
end,
|
|
227
|
+
staleAt,
|
|
131
228
|
deleteAt,
|
|
132
229
|
statusCode,
|
|
133
230
|
statusMessage,
|
|
@@ -143,7 +240,7 @@ export class SqliteCacheStore {
|
|
|
143
240
|
AND start <= ?
|
|
144
241
|
AND deleteAt > ?
|
|
145
242
|
ORDER BY
|
|
146
|
-
|
|
243
|
+
id DESC
|
|
147
244
|
`)
|
|
148
245
|
|
|
149
246
|
this.#insertValueQuery = this.#db.prepare(`
|
|
@@ -153,6 +250,7 @@ export class SqliteCacheStore {
|
|
|
153
250
|
body,
|
|
154
251
|
start,
|
|
155
252
|
end,
|
|
253
|
+
staleAt,
|
|
156
254
|
deleteAt,
|
|
157
255
|
statusCode,
|
|
158
256
|
statusMessage,
|
|
@@ -161,7 +259,7 @@ export class SqliteCacheStore {
|
|
|
161
259
|
cacheControlDirectives,
|
|
162
260
|
vary,
|
|
163
261
|
cachedAt
|
|
164
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
262
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
165
263
|
`)
|
|
166
264
|
|
|
167
265
|
this.#deleteExpiredValuesQuery = this.#db.prepare(
|
|
@@ -174,7 +272,40 @@ export class SqliteCacheStore {
|
|
|
174
272
|
`DELETE FROM cacheInterceptorV${VERSION} WHERE id IN (SELECT id FROM cacheInterceptorV${VERSION} ORDER BY deleteAt ASC LIMIT ?)`,
|
|
175
273
|
)
|
|
176
274
|
|
|
177
|
-
|
|
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
|
+
|
|
304
|
+
this.#ref = new WeakRef(this)
|
|
305
|
+
stores.add(this.#ref)
|
|
306
|
+
// The store itself doubles as the unregister token so close() can drop
|
|
307
|
+
// the registry entry deterministically.
|
|
308
|
+
registry.register(this, this.#ref, this)
|
|
178
309
|
}
|
|
179
310
|
|
|
180
311
|
gc() {
|
|
@@ -222,7 +353,8 @@ export class SqliteCacheStore {
|
|
|
222
353
|
}
|
|
223
354
|
|
|
224
355
|
close() {
|
|
225
|
-
stores.delete(this)
|
|
356
|
+
stores.delete(this.#ref)
|
|
357
|
+
registry.unregister(this)
|
|
226
358
|
// Drain the entire batch synchronously before closing. A plain #flush()
|
|
227
359
|
// only commits one time-budget slice and reschedules the rest via
|
|
228
360
|
// setImmediate; that deferred flush would see #closed and discard the
|
|
@@ -294,7 +426,7 @@ export class SqliteCacheStore {
|
|
|
294
426
|
setImmediate(this.#flush)
|
|
295
427
|
}
|
|
296
428
|
|
|
297
|
-
|
|
429
|
+
const entry = {
|
|
298
430
|
// Monotonic per-store sequence used only to break cachedAt ties in
|
|
299
431
|
// #findValue (newest write wins). Not persisted — #flush ignores it.
|
|
300
432
|
seq: this.#insertSeq++,
|
|
@@ -303,6 +435,9 @@ export class SqliteCacheStore {
|
|
|
303
435
|
body,
|
|
304
436
|
start: value.start,
|
|
305
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,
|
|
306
441
|
deleteAt: value.deleteAt,
|
|
307
442
|
statusCode: value.statusCode,
|
|
308
443
|
statusMessage: value.statusMessage,
|
|
@@ -313,7 +448,56 @@ export class SqliteCacheStore {
|
|
|
313
448
|
: null,
|
|
314
449
|
vary: value.vary ? JSON.stringify(value.vary) : null,
|
|
315
450
|
cachedAt: value.cachedAt,
|
|
316
|
-
}
|
|
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)
|
|
317
501
|
}
|
|
318
502
|
|
|
319
503
|
#flush = (final = false) => {
|
|
@@ -335,6 +519,7 @@ export class SqliteCacheStore {
|
|
|
335
519
|
body,
|
|
336
520
|
start,
|
|
337
521
|
end,
|
|
522
|
+
staleAt,
|
|
338
523
|
deleteAt,
|
|
339
524
|
statusCode,
|
|
340
525
|
statusMessage,
|
|
@@ -344,12 +529,21 @@ export class SqliteCacheStore {
|
|
|
344
529
|
vary,
|
|
345
530
|
cachedAt,
|
|
346
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
|
+
}
|
|
347
540
|
this.#insertValueQuery.run(
|
|
348
541
|
url,
|
|
349
542
|
method,
|
|
350
543
|
body,
|
|
351
544
|
start,
|
|
352
545
|
end,
|
|
546
|
+
staleAt,
|
|
353
547
|
deleteAt,
|
|
354
548
|
statusCode,
|
|
355
549
|
statusMessage,
|
|
@@ -457,16 +651,14 @@ export class SqliteCacheStore {
|
|
|
457
651
|
return undefined
|
|
458
652
|
}
|
|
459
653
|
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
// (tagged with a monotonic seq) are
|
|
464
|
-
// and within each source a higher
|
|
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.
|
|
465
660
|
if (values.length > 1) {
|
|
466
661
|
values.sort((a, b) => {
|
|
467
|
-
if (a.cachedAt !== b.cachedAt) {
|
|
468
|
-
return b.cachedAt - a.cachedAt
|
|
469
|
-
}
|
|
470
662
|
const aBatch = a.seq != null
|
|
471
663
|
const bBatch = b.seq != null
|
|
472
664
|
if (aBatch !== bBatch) {
|
|
@@ -580,6 +772,7 @@ function makeResult(value) {
|
|
|
580
772
|
? JSON.parse(value.cacheControlDirectives)
|
|
581
773
|
: undefined,
|
|
582
774
|
cachedAt: value.cachedAt,
|
|
775
|
+
staleAt: value.staleAt,
|
|
583
776
|
deleteAt: value.deleteAt,
|
|
584
777
|
}
|
|
585
778
|
}
|
|
@@ -627,6 +820,16 @@ function assertCacheValue(value) {
|
|
|
627
820
|
}
|
|
628
821
|
}
|
|
629
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
|
+
|
|
630
833
|
if (typeof value.statusMessage !== 'string') {
|
|
631
834
|
throw new TypeError(
|
|
632
835
|
`expected value.statusMessage to be string, got ${printType(value.statusMessage)} [${value.statusMessage}]`,
|