@nxtedition/nxt-undici 7.4.0 → 7.4.2
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 +47 -8
- package/lib/index.js +14 -2
- package/lib/interceptor/cache.js +93 -43
- package/lib/interceptor/dns.js +114 -7
- package/lib/interceptor/log.js +184 -9
- package/lib/interceptor/pressure.js +103 -10
- package/lib/interceptor/proxy.js +48 -15
- package/lib/interceptor/redirect.js +48 -5
- package/lib/interceptor/request-body-factory.js +62 -9
- package/lib/interceptor/response-error.js +19 -2
- package/lib/interceptor/response-retry.js +273 -21
- package/lib/interceptor/response-verify.js +10 -0
- package/lib/request.js +5 -1
- package/lib/sqlite-cache-store.js +86 -9
- package/lib/utils.js +38 -15
- package/package.json +1 -1
package/lib/request.js
CHANGED
|
@@ -201,7 +201,11 @@ export function request(dispatch, urlOrOpts, optsOrNully) {
|
|
|
201
201
|
|
|
202
202
|
let path = url.path
|
|
203
203
|
if (!path) {
|
|
204
|
-
|
|
204
|
+
// URLObject marks every field optional; default the path so e.g.
|
|
205
|
+
// request({ origin }) works instead of undici rejecting with
|
|
206
|
+
// "path must be a string".
|
|
207
|
+
const pathname = url.pathname || '/'
|
|
208
|
+
path = url.search ? `${pathname}${url.search}` : pathname
|
|
205
209
|
}
|
|
206
210
|
|
|
207
211
|
opts = {
|
|
@@ -4,16 +4,41 @@ import { parseRangeHeader, getFastNow } from './utils.js'
|
|
|
4
4
|
// Bump version when the URL key format or schema changes to invalidate old caches.
|
|
5
5
|
const VERSION = 10
|
|
6
6
|
|
|
7
|
-
|
|
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,9 +46,7 @@ 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
|
|
|
@@ -81,6 +104,11 @@ export class SqliteCacheStore {
|
|
|
81
104
|
#insertSeq = 0
|
|
82
105
|
#closed = false
|
|
83
106
|
|
|
107
|
+
/**
|
|
108
|
+
* @type {WeakRef<SqliteCacheStore>}
|
|
109
|
+
*/
|
|
110
|
+
#ref
|
|
111
|
+
|
|
84
112
|
/**
|
|
85
113
|
* @param {import('undici-types/cache-interceptor.d.ts').default.SqliteCacheStoreOpts & { maxSize?: number } | undefined} opts
|
|
86
114
|
*/
|
|
@@ -122,6 +150,50 @@ export class SqliteCacheStore {
|
|
|
122
150
|
CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_deleteExpiredValuesQuery ON cacheInterceptorV${VERSION}(deleteAt);
|
|
123
151
|
`)
|
|
124
152
|
|
|
153
|
+
// Drop tables left behind by previous schema versions. gc(), clear() and
|
|
154
|
+
// the SQLITE_FULL eviction only ever touch the current version's table, so
|
|
155
|
+
// after a VERSION bump the old table's pages would otherwise stay allocated
|
|
156
|
+
// to its b-tree forever while max_page_count caps the whole file — new
|
|
157
|
+
// inserts hit SQLITE_FULL almost immediately and eviction frees nothing.
|
|
158
|
+
// Dropping returns the pages to SQLite's freelist, which subsequent inserts
|
|
159
|
+
// reuse, so no VACUUM is needed. SQLite drops the table's indexes and its
|
|
160
|
+
// sqlite_sequence row along with it.
|
|
161
|
+
try {
|
|
162
|
+
// LIKE is only a coarse pre-filter; the regexp restricts matches to
|
|
163
|
+
// digit-only version suffixes so user tables sharing the prefix (e.g.
|
|
164
|
+
// "cacheInterceptorVBackup") in a shared database file are never dropped.
|
|
165
|
+
const staleTables = this.#db
|
|
166
|
+
.prepare(
|
|
167
|
+
`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'cacheInterceptorV%'`,
|
|
168
|
+
)
|
|
169
|
+
.all()
|
|
170
|
+
.filter(
|
|
171
|
+
({ name }) =>
|
|
172
|
+
/^cacheInterceptorV\d+$/.test(name) && name !== `cacheInterceptorV${VERSION}`,
|
|
173
|
+
)
|
|
174
|
+
if (staleTables.length > 0) {
|
|
175
|
+
this.#db.exec('BEGIN')
|
|
176
|
+
try {
|
|
177
|
+
for (const { name } of staleTables) {
|
|
178
|
+
// name comes from sqlite_master; quote it defensively anyway.
|
|
179
|
+
this.#db.exec(`DROP TABLE IF EXISTS "${String(name).replaceAll('"', '""')}"`)
|
|
180
|
+
}
|
|
181
|
+
this.#db.exec('COMMIT')
|
|
182
|
+
} catch (err) {
|
|
183
|
+
try {
|
|
184
|
+
this.#db.exec('ROLLBACK')
|
|
185
|
+
} catch {
|
|
186
|
+
// already rolled back automatically
|
|
187
|
+
}
|
|
188
|
+
throw err
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
} catch (err) {
|
|
192
|
+
// A failed cleanup must not brick construction — the current version's
|
|
193
|
+
// table still works, the stale one just keeps occupying pages.
|
|
194
|
+
process.emitWarning(err)
|
|
195
|
+
}
|
|
196
|
+
|
|
125
197
|
this.#getValuesQuery = this.#db.prepare(`
|
|
126
198
|
SELECT
|
|
127
199
|
id,
|
|
@@ -174,7 +246,11 @@ export class SqliteCacheStore {
|
|
|
174
246
|
`DELETE FROM cacheInterceptorV${VERSION} WHERE id IN (SELECT id FROM cacheInterceptorV${VERSION} ORDER BY deleteAt ASC LIMIT ?)`,
|
|
175
247
|
)
|
|
176
248
|
|
|
177
|
-
|
|
249
|
+
this.#ref = new WeakRef(this)
|
|
250
|
+
stores.add(this.#ref)
|
|
251
|
+
// The store itself doubles as the unregister token so close() can drop
|
|
252
|
+
// the registry entry deterministically.
|
|
253
|
+
registry.register(this, this.#ref, this)
|
|
178
254
|
}
|
|
179
255
|
|
|
180
256
|
gc() {
|
|
@@ -222,7 +298,8 @@ export class SqliteCacheStore {
|
|
|
222
298
|
}
|
|
223
299
|
|
|
224
300
|
close() {
|
|
225
|
-
stores.delete(this)
|
|
301
|
+
stores.delete(this.#ref)
|
|
302
|
+
registry.unregister(this)
|
|
226
303
|
// Drain the entire batch synchronously before closing. A plain #flush()
|
|
227
304
|
// only commits one time-budget slice and reschedules the rest via
|
|
228
305
|
// setImmediate; that deferred flush would see #closed and discard the
|
package/lib/utils.js
CHANGED
|
@@ -15,7 +15,13 @@ export function getFastNow() {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export function parseCacheControl(str) {
|
|
18
|
-
|
|
18
|
+
if (Array.isArray(str)) {
|
|
19
|
+
// Cache-Control is a list-typed field, so duplicated field lines are legal
|
|
20
|
+
// (e.g. CDN + origin each adding one) and combine into a single
|
|
21
|
+
// comma-separated value per RFC 9110 §5.2.
|
|
22
|
+
str = str.join(', ')
|
|
23
|
+
}
|
|
24
|
+
return str && typeof str === 'string' ? cacheControlParser.parse(str) : null
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
export function isDisturbed(body) {
|
|
@@ -133,26 +139,43 @@ export function parseURL(url) {
|
|
|
133
139
|
|
|
134
140
|
if (!(url instanceof URL)) {
|
|
135
141
|
const port = url.port != null ? url.port : url.protocol === 'https:' ? 443 : 80
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (origin.endsWith('/')) {
|
|
140
|
-
origin = origin.substring(0, origin.length - 1)
|
|
141
|
-
}
|
|
142
|
+
const origin = url.origin != null ? url.origin : `${url.protocol}//${url.hostname}:${port}`
|
|
143
|
+
const path = url.path != null ? url.path : `${url.pathname || ''}${url.search || ''}`
|
|
142
144
|
|
|
143
|
-
|
|
144
|
-
path = `/${path}`
|
|
145
|
-
}
|
|
146
|
-
// new URL(path, origin) is unsafe when `path` contains an absolute URL
|
|
147
|
-
// From https://developer.mozilla.org/en-US/docs/Web/API/URL/URL:
|
|
148
|
-
// If first parameter is a relative URL, second param is required, and will be used as the base URL.
|
|
149
|
-
// If first parameter is an absolute URL, a given second param will be ignored.
|
|
150
|
-
url = new URL(origin + path)
|
|
145
|
+
url = buildURL(origin, path)
|
|
151
146
|
}
|
|
152
147
|
|
|
153
148
|
return url
|
|
154
149
|
}
|
|
155
150
|
|
|
151
|
+
// Build an absolute URL from an origin and a path by concatenation.
|
|
152
|
+
//
|
|
153
|
+
// `new URL(path, origin)` is unsafe when `path` is itself absolute or
|
|
154
|
+
// protocol-relative (e.g. starts with `//host`): per the WHATWG URL spec, a
|
|
155
|
+
// protocol-relative first argument inherits only the scheme from the base and
|
|
156
|
+
// resolves its authority from `path`, so the origin's host is silently
|
|
157
|
+
// discarded. Concatenating `origin + path` and parsing the whole thing as a
|
|
158
|
+
// single absolute URL keeps the origin authoritative — which matters for
|
|
159
|
+
// redirect resolution, where a leaked host is an SSRF/misrouting vector.
|
|
160
|
+
//
|
|
161
|
+
// From https://developer.mozilla.org/en-US/docs/Web/API/URL/URL:
|
|
162
|
+
// If first parameter is a relative URL, second param is required, and will be used as the base URL.
|
|
163
|
+
// If first parameter is an absolute URL, a given second param will be ignored.
|
|
164
|
+
export function buildURL(origin, path) {
|
|
165
|
+
origin = String(origin)
|
|
166
|
+
path = path == null ? '' : String(path)
|
|
167
|
+
|
|
168
|
+
if (origin.endsWith('/')) {
|
|
169
|
+
origin = origin.substring(0, origin.length - 1)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (path && !path.startsWith('/')) {
|
|
173
|
+
path = `/${path}`
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return new URL(origin + path)
|
|
177
|
+
}
|
|
178
|
+
|
|
156
179
|
export function parseOrigin(url) {
|
|
157
180
|
url = parseURL(url)
|
|
158
181
|
|