@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/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
- path = url.search ? `${url.pathname}${url.search}` : url.pathname
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
- /** @typedef {{ gc: () => void, clear: () => void } } */
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
- for (const store of stores) {
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
- for (const store of stores) {
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
- stores.add(this)
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
- return str ? cacheControlParser.parse(str) : null
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
- let origin = url.origin != null ? url.origin : `${url.protocol}//${url.hostname}:${port}`
137
- let path = url.path != null ? url.path : `${url.pathname || ''}${url.search || ''}`
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
- if (path && !path.startsWith('/')) {
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxtedition/nxt-undici",
3
- "version": "7.4.0",
3
+ "version": "7.4.2",
4
4
  "license": "MIT",
5
5
  "author": "Robert Nagy <robert.nagy@boffins.se>",
6
6
  "main": "lib/index.js",