undici 7.0.0-alpha.3 → 7.0.0-alpha.5

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.
Files changed (47) hide show
  1. package/README.md +2 -1
  2. package/docs/docs/api/Agent.md +14 -14
  3. package/docs/docs/api/BalancedPool.md +16 -16
  4. package/docs/docs/api/CacheStore.md +17 -14
  5. package/docs/docs/api/Client.md +11 -11
  6. package/docs/docs/api/Dispatcher.md +30 -10
  7. package/docs/docs/api/EnvHttpProxyAgent.md +12 -12
  8. package/docs/docs/api/MockAgent.md +3 -3
  9. package/docs/docs/api/MockClient.md +5 -5
  10. package/docs/docs/api/MockPool.md +2 -2
  11. package/docs/docs/api/Pool.md +15 -15
  12. package/docs/docs/api/PoolStats.md +1 -1
  13. package/docs/docs/api/ProxyAgent.md +3 -3
  14. package/docs/docs/api/RetryHandler.md +2 -2
  15. package/docs/docs/api/WebSocket.md +1 -1
  16. package/docs/docs/api/api-lifecycle.md +11 -11
  17. package/docs/docs/best-practices/mocking-request.md +2 -2
  18. package/docs/docs/best-practices/proxy.md +1 -1
  19. package/index.d.ts +1 -1
  20. package/index.js +2 -1
  21. package/lib/api/api-request.js +1 -1
  22. package/lib/cache/memory-cache-store.js +106 -342
  23. package/lib/core/connect.js +5 -0
  24. package/lib/core/request.js +2 -2
  25. package/lib/core/util.js +13 -40
  26. package/lib/dispatcher/client-h2.js +53 -33
  27. package/lib/handler/cache-handler.js +126 -85
  28. package/lib/handler/cache-revalidation-handler.js +45 -13
  29. package/lib/handler/redirect-handler.js +5 -3
  30. package/lib/handler/retry-handler.js +3 -3
  31. package/lib/interceptor/cache.js +213 -92
  32. package/lib/interceptor/dns.js +71 -48
  33. package/lib/util/cache.js +73 -13
  34. package/lib/util/timers.js +19 -1
  35. package/lib/web/cookies/index.js +12 -1
  36. package/lib/web/cookies/parse.js +6 -1
  37. package/lib/web/fetch/body.js +1 -5
  38. package/lib/web/fetch/formdata-parser.js +70 -43
  39. package/lib/web/fetch/headers.js +1 -1
  40. package/lib/web/fetch/index.js +4 -6
  41. package/lib/web/fetch/webidl.js +12 -4
  42. package/package.json +2 -3
  43. package/types/cache-interceptor.d.ts +51 -54
  44. package/types/cookies.d.ts +2 -0
  45. package/types/dispatcher.d.ts +1 -1
  46. package/types/index.d.ts +0 -1
  47. package/types/interceptors.d.ts +0 -1
@@ -1,35 +1,26 @@
1
1
  'use strict'
2
2
 
3
- const { Writable, Readable } = require('node:stream')
3
+ const { Writable } = require('node:stream')
4
+ const { nowAbsolute } = require('../util/timers.js')
4
5
 
5
6
  /**
7
+ * @typedef {import('../../types/cache-interceptor.d.ts').default.CacheKey} CacheKey
8
+ * @typedef {import('../../types/cache-interceptor.d.ts').default.CacheValue} CacheValue
6
9
  * @typedef {import('../../types/cache-interceptor.d.ts').default.CacheStore} CacheStore
10
+ * @typedef {import('../../types/cache-interceptor.d.ts').default.GetResult} GetResult
11
+ */
12
+
13
+ /**
7
14
  * @implements {CacheStore}
8
- *
9
- * @typedef {{
10
- * readers: number
11
- * readLock: boolean
12
- * writeLock: boolean
13
- * opts: import('../../types/cache-interceptor.d.ts').default.CacheStoreValue
14
- * body: Buffer[]
15
- * }} MemoryStoreValue
16
15
  */
17
16
  class MemoryCacheStore {
18
- #maxEntries = Infinity
19
-
17
+ #maxCount = Infinity
18
+ #maxSize = Infinity
20
19
  #maxEntrySize = Infinity
21
20
 
22
- /**
23
- * @type {((err) => void) | undefined}
24
- */
25
- #errorCallback = undefined
26
-
27
- #entryCount = 0
28
-
29
- /**
30
- * @type {Map<string, Map<string, MemoryStoreValue>>}
31
- */
32
- #data = new Map()
21
+ #size = 0
22
+ #count = 0
23
+ #entries = new Map()
33
24
 
34
25
  /**
35
26
  * @param {import('../../types/cache-interceptor.d.ts').default.MemoryCacheStoreOpts | undefined} [opts]
@@ -40,15 +31,26 @@ class MemoryCacheStore {
40
31
  throw new TypeError('MemoryCacheStore options must be an object')
41
32
  }
42
33
 
43
- if (opts.maxEntries !== undefined) {
34
+ if (opts.maxCount !== undefined) {
35
+ if (
36
+ typeof opts.maxCount !== 'number' ||
37
+ !Number.isInteger(opts.maxCount) ||
38
+ opts.maxCount < 0
39
+ ) {
40
+ throw new TypeError('MemoryCacheStore options.maxCount must be a non-negative integer')
41
+ }
42
+ this.#maxCount = opts.maxCount
43
+ }
44
+
45
+ if (opts.maxSize !== undefined) {
44
46
  if (
45
- typeof opts.maxEntries !== 'number' ||
46
- !Number.isInteger(opts.maxEntries) ||
47
- opts.maxEntries < 0
47
+ typeof opts.maxSize !== 'number' ||
48
+ !Number.isInteger(opts.maxSize) ||
49
+ opts.maxSize < 0
48
50
  ) {
49
- throw new TypeError('MemoryCacheStore options.maxEntries must be a non-negative integer')
51
+ throw new TypeError('MemoryCacheStore options.maxSize must be a non-negative integer')
50
52
  }
51
- this.#maxEntries = opts.maxEntries
53
+ this.#maxSize = opts.maxSize
52
54
  }
53
55
 
54
56
  if (opts.maxEntrySize !== undefined) {
@@ -61,356 +63,118 @@ class MemoryCacheStore {
61
63
  }
62
64
  this.#maxEntrySize = opts.maxEntrySize
63
65
  }
64
-
65
- if (opts.errorCallback !== undefined) {
66
- if (typeof opts.errorCallback !== 'function') {
67
- throw new TypeError('MemoryCacheStore options.errorCallback must be a function')
68
- }
69
- this.#errorCallback = opts.errorCallback
70
- }
71
66
  }
72
67
  }
73
68
 
74
- get isFull () {
75
- return this.#entryCount >= this.#maxEntries
76
- }
77
-
78
69
  /**
79
- * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} req
80
- * @returns {import('../../types/cache-interceptor.d.ts').default.CacheStoreReadable | undefined}
70
+ * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} req
71
+ * @returns {import('../../types/cache-interceptor.d.ts').default.GetResult | undefined}
81
72
  */
82
- createReadStream (req) {
83
- if (typeof req !== 'object') {
84
- throw new TypeError(`expected req to be object, got ${typeof req}`)
85
- }
86
-
87
- const values = this.#getValuesForRequest(req, false)
88
- if (!values) {
89
- return undefined
73
+ get (key) {
74
+ if (typeof key !== 'object') {
75
+ throw new TypeError(`expected key to be object, got ${typeof key}`)
90
76
  }
91
77
 
92
- const value = this.#findValue(req, values)
78
+ const topLevelKey = `${key.origin}:${key.path}`
93
79
 
94
- if (!value || value.readLock) {
95
- return undefined
96
- }
80
+ const now = nowAbsolute()
81
+ const entry = this.#entries.get(topLevelKey)?.find((entry) => (
82
+ entry.deleteAt > now &&
83
+ entry.method === key.method &&
84
+ (entry.vary == null || Object.keys(entry.vary).every(headerName => entry.vary[headerName] === key.headers?.[headerName]))
85
+ ))
97
86
 
98
- return new MemoryStoreReadableStream(value)
87
+ return entry == null
88
+ ? undefined
89
+ : {
90
+ statusMessage: entry.statusMessage,
91
+ statusCode: entry.statusCode,
92
+ rawHeaders: entry.rawHeaders,
93
+ body: entry.body,
94
+ etag: entry.etag,
95
+ cachedAt: entry.cachedAt,
96
+ staleAt: entry.staleAt,
97
+ deleteAt: entry.deleteAt
98
+ }
99
99
  }
100
100
 
101
101
  /**
102
- * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} req
103
- * @param {import('../../types/cache-interceptor.d.ts').default.CacheStoreValue} opts
104
- * @returns {import('../../types/cache-interceptor.d.ts').default.CacheStoreWriteable | undefined}
102
+ * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
103
+ * @param {import('../../types/cache-interceptor.d.ts').default.CacheValue} val
104
+ * @returns {Writable | undefined}
105
105
  */
106
- createWriteStream (req, opts) {
107
- if (typeof req !== 'object') {
108
- throw new TypeError(`expected req to be object, got ${typeof req}`)
109
- }
110
- if (typeof opts !== 'object') {
111
- throw new TypeError(`expected value to be object, got ${typeof opts}`)
106
+ createWriteStream (key, val) {
107
+ if (typeof key !== 'object') {
108
+ throw new TypeError(`expected key to be object, got ${typeof key}`)
112
109
  }
113
-
114
- if (this.isFull) {
115
- return undefined
110
+ if (typeof val !== 'object') {
111
+ throw new TypeError(`expected value to be object, got ${typeof val}`)
116
112
  }
117
113
 
118
- const values = this.#getValuesForRequest(req, true)
114
+ const topLevelKey = `${key.origin}:${key.path}`
119
115
 
120
- let value = this.#findValue(req, values)
121
- if (!value) {
122
- // The value doesn't already exist, meaning we haven't cached this
123
- // response before. Let's assign it a value and insert it into our data
124
- // property.
116
+ const store = this
117
+ const entry = { ...key, ...val, body: [], size: 0 }
125
118
 
126
- if (this.isFull) {
127
- // Or not, we don't have space to add another response
128
- return undefined
129
- }
130
-
131
- this.#entryCount++
132
-
133
- value = {
134
- readers: 0,
135
- readLock: false,
136
- writeLock: false,
137
- opts,
138
- body: []
139
- }
140
-
141
- // We want to sort our responses in decending order by their deleteAt
142
- // timestamps so that deleting expired responses is faster
143
- if (
144
- values.length === 0 ||
145
- opts.deleteAt < values[values.length - 1].deleteAt
146
- ) {
147
- // Our value is either the only response for this path or our deleteAt
148
- // time is sooner than all the other responses
149
- values.push(value)
150
- } else if (opts.deleteAt >= values[0].deleteAt) {
151
- // Our deleteAt is later than everyone elses
152
- values.unshift(value)
153
- } else {
154
- // We're neither in the front or the end, let's just binary search to
155
- // find our stop we need to be in
156
- let startIndex = 0
157
- let endIndex = values.length
158
- while (true) {
159
- if (startIndex === endIndex) {
160
- values.splice(startIndex, 0, value)
161
- break
162
- }
163
-
164
- const middleIndex = Math.floor((startIndex + endIndex) / 2)
165
- const middleValue = values[middleIndex]
166
- if (opts.deleteAt === middleIndex) {
167
- values.splice(middleIndex, 0, value)
168
- break
169
- } else if (opts.deleteAt > middleValue.opts.deleteAt) {
170
- endIndex = middleIndex
171
- continue
172
- } else {
173
- startIndex = middleIndex
174
- continue
175
- }
119
+ return new Writable({
120
+ write (chunk, encoding, callback) {
121
+ if (typeof chunk === 'string') {
122
+ chunk = Buffer.from(chunk, encoding)
176
123
  }
177
- }
178
- } else {
179
- // Check if there's already another request writing to the value or
180
- // a request reading from it
181
- if (value.writeLock || value.readLock) {
182
- return undefined
183
- }
184
-
185
- // Empty it so we can overwrite it
186
- value.body = []
187
- }
188
-
189
- const writable = new MemoryStoreWritableStream(
190
- value,
191
- this.#maxEntrySize
192
- )
193
-
194
- // Remove the value if there was some error
195
- writable.on('error', (err) => {
196
- values.filter(current => value !== current)
197
- if (this.#errorCallback) {
198
- this.#errorCallback(err)
199
- }
200
- })
201
-
202
- writable.on('bodyOversized', () => {
203
- values.filter(current => value !== current)
204
- })
205
-
206
- return writable
207
- }
208
-
209
- /**
210
- * @param {string} origin
211
- */
212
- deleteByOrigin (origin) {
213
- this.#data.delete(origin)
214
- }
215
-
216
- /**
217
- * Gets all of the requests of the same origin, path, and method. Does not
218
- * take the `vary` property into account.
219
- * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} req
220
- * @param {boolean} [makeIfDoesntExist=false]
221
- */
222
- #getValuesForRequest (req, makeIfDoesntExist) {
223
- // https://www.rfc-editor.org/rfc/rfc9111.html#section-2-3
224
- let cachedPaths = this.#data.get(req.origin)
225
- if (!cachedPaths) {
226
- if (!makeIfDoesntExist) {
227
- return undefined
228
- }
229
-
230
- cachedPaths = new Map()
231
- this.#data.set(req.origin, cachedPaths)
232
- }
233
-
234
- let values = cachedPaths.get(`${req.path}:${req.method}`)
235
- if (!values && makeIfDoesntExist) {
236
- values = []
237
- cachedPaths.set(`${req.path}:${req.method}`, values)
238
- }
239
-
240
- return values
241
- }
242
-
243
- /**
244
- * Given a list of values of a certain request, this decides the best value
245
- * to respond with.
246
- * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} req
247
- * @param {MemoryStoreValue[]} values
248
- * @returns {MemoryStoreValue | undefined}
249
- */
250
- #findValue (req, values) {
251
- /**
252
- * @type {MemoryStoreValue}
253
- */
254
- let value
255
- const now = Date.now()
256
- for (let i = values.length - 1; i >= 0; i--) {
257
- const current = values[i]
258
- const currentCacheValue = current.opts
259
- if (now >= currentCacheValue.deleteAt) {
260
- // We've reached expired values, let's delete them
261
- this.#entryCount -= values.length - i
262
- values.length = i
263
- break
264
- }
265
124
 
266
- let matches = true
125
+ entry.size += chunk.byteLength
267
126
 
268
- if (currentCacheValue.vary) {
269
- if (!req.headers) {
270
- matches = false
271
- break
127
+ if (entry.size >= store.#maxEntrySize) {
128
+ this.destroy()
129
+ } else {
130
+ entry.body.push(chunk)
272
131
  }
273
132
 
274
- for (const key in currentCacheValue.vary) {
275
- if (currentCacheValue.vary[key] !== req.headers[key]) {
276
- matches = false
277
- break
133
+ callback(null)
134
+ },
135
+ final (callback) {
136
+ let entries = store.#entries.get(topLevelKey)
137
+ if (!entries) {
138
+ entries = []
139
+ store.#entries.set(topLevelKey, entries)
140
+ }
141
+ entries.push(entry)
142
+
143
+ store.#size += entry.size
144
+ store.#count += 1
145
+
146
+ if (store.#size > store.#maxSize || store.#count > store.#maxCount) {
147
+ for (const [key, entries] of store.#entries) {
148
+ for (const entry of entries.splice(0, entries.length / 2)) {
149
+ store.#size -= entry.size
150
+ store.#count -= 1
151
+ }
152
+ if (entries.length === 0) {
153
+ store.#entries.delete(key)
154
+ }
278
155
  }
279
156
  }
280
- }
281
-
282
- if (matches) {
283
- value = current
284
- break
285
- }
286
- }
287
-
288
- return value
289
- }
290
- }
291
-
292
- class MemoryStoreReadableStream extends Readable {
293
- /**
294
- * @type {MemoryStoreValue}
295
- */
296
- #value
297
- /**
298
- * @type {Buffer[]}
299
- */
300
- #chunksToSend = []
301
-
302
- /**
303
- * @param {MemoryStoreValue} value
304
- */
305
- constructor (value) {
306
- super()
307
-
308
- if (value.readLock) {
309
- throw new Error('can\'t read a locked value')
310
- }
311
157
 
312
- this.#value = value
313
- this.#chunksToSend = value?.body ? [...value.body, null] : [null]
314
-
315
- this.#value.readers++
316
- this.#value.writeLock = true
317
-
318
- this.on('close', () => {
319
- this.#value.readers--
320
-
321
- if (this.#value.readers === 0) {
322
- this.#value.writeLock = false
158
+ callback(null)
323
159
  }
324
160
  })
325
161
  }
326
162
 
327
- get value () {
328
- return this.#value.opts
329
- }
330
-
331
163
  /**
332
- * @param {number} size
164
+ * @param {CacheKey} key
333
165
  */
334
- _read (size) {
335
- if (this.#chunksToSend.length === 0) {
336
- throw new Error('no chunks left to read, stream should have closed')
166
+ delete (key) {
167
+ if (typeof key !== 'object') {
168
+ throw new TypeError(`expected key to be object, got ${typeof key}`)
337
169
  }
338
170
 
339
- if (size > this.#chunksToSend.length) {
340
- size = this.#chunksToSend.length
341
- }
171
+ const topLevelKey = `${key.origin}:${key.path}`
342
172
 
343
- for (let i = 0; i < size; i++) {
344
- this.push(this.#chunksToSend.shift())
173
+ for (const entry of this.#entries.get(topLevelKey) ?? []) {
174
+ this.#size -= entry.size
175
+ this.#count -= 1
345
176
  }
346
- }
347
- }
348
-
349
- class MemoryStoreWritableStream extends Writable {
350
- /**
351
- * @type {MemoryStoreValue}
352
- */
353
- #value
354
- #currentSize = 0
355
- #maxEntrySize = 0
356
- /**
357
- * @type {Buffer[]|null}
358
- */
359
- #body = []
360
-
361
- /**
362
- * @param {MemoryStoreValue} value
363
- * @param {number} maxEntrySize
364
- */
365
- constructor (value, maxEntrySize) {
366
- super()
367
- this.#value = value
368
- this.#value.readLock = true
369
- this.#maxEntrySize = maxEntrySize ?? Infinity
370
- }
371
-
372
- get rawTrailers () {
373
- return this.#value.opts.rawTrailers
374
- }
375
-
376
- /**
377
- * @param {string[] | undefined} trailers
378
- */
379
- set rawTrailers (trailers) {
380
- this.#value.opts.rawTrailers = trailers
381
- }
382
-
383
- /**
384
- * @param {Buffer} chunk
385
- * @param {string} encoding
386
- * @param {BufferEncoding} encoding
387
- */
388
- _write (chunk, encoding, callback) {
389
- if (typeof chunk === 'string') {
390
- chunk = Buffer.from(chunk, encoding)
391
- }
392
-
393
- this.#currentSize += chunk.byteLength
394
- if (this.#currentSize < this.#maxEntrySize) {
395
- this.#body.push(chunk)
396
- } else {
397
- this.#body = null // release memory as early as possible
398
- this.emit('bodyOversized')
399
- }
400
-
401
- callback()
402
- }
403
-
404
- /**
405
- * @param {() => void} callback
406
- */
407
- _final (callback) {
408
- if (this.#currentSize < this.#maxEntrySize) {
409
- this.#value.readLock = false
410
- this.#value.body = this.#body
411
- }
412
-
413
- callback()
177
+ this.#entries.delete(topLevelKey)
414
178
  }
415
179
  }
416
180
 
@@ -220,6 +220,11 @@ const setupConnectTimeout = process.platform === 'win32'
220
220
  * @param {number} opts.port
221
221
  */
222
222
  function onConnectTimeout (socket, opts) {
223
+ // The socket could be already garbage collected
224
+ if (socket == null) {
225
+ return
226
+ }
227
+
223
228
  let message = 'Connect Timeout Error'
224
229
  if (Array.isArray(socket.autoSelectFamilyAttemptedAddresses)) {
225
230
  message += ` (attempted addresses: ${socket.autoSelectFamilyAttemptedAddresses.join(', ')},`
@@ -130,7 +130,6 @@ class Request {
130
130
  }
131
131
 
132
132
  this.completed = false
133
-
134
133
  this.aborted = false
135
134
 
136
135
  this.upgrade = upgrade || null
@@ -143,7 +142,7 @@ class Request {
143
142
  ? method === 'HEAD' || method === 'GET'
144
143
  : idempotent
145
144
 
146
- this.blocking = blocking == null ? false : blocking
145
+ this.blocking = blocking ?? this.method !== 'HEAD'
147
146
 
148
147
  this.reset = reset == null ? null : reset
149
148
 
@@ -272,6 +271,7 @@ class Request {
272
271
  this.onFinally()
273
272
 
274
273
  assert(!this.aborted)
274
+ assert(!this.completed)
275
275
 
276
276
  this.completed = true
277
277
  if (channels.trailers.hasSubscribers) {
package/lib/core/util.js CHANGED
@@ -10,7 +10,7 @@ const nodeUtil = require('node:util')
10
10
  const { stringify } = require('node:querystring')
11
11
  const { EventEmitter: EE } = require('node:events')
12
12
  const { InvalidArgumentError } = require('./errors')
13
- const { headerNameLowerCasedRecord, getHeaderNameAsBuffer } = require('./constants')
13
+ const { headerNameLowerCasedRecord } = require('./constants')
14
14
  const { tree } = require('./tree')
15
15
 
16
16
  const [nodeMajor, nodeMinor] = process.versions.node.split('.').map(v => Number(v))
@@ -436,44 +436,6 @@ function parseHeaders (headers, obj) {
436
436
  return obj
437
437
  }
438
438
 
439
- /**
440
- * @param {Record<string, string | string[]>} headers
441
- * @returns {(Buffer | Buffer[])[]}
442
- */
443
- function encodeHeaders (headers) {
444
- const headerNames = Object.keys(headers)
445
-
446
- /**
447
- * @type {Buffer[]|Buffer[][]}
448
- */
449
- const rawHeaders = new Array(headerNames.length * 2)
450
-
451
- let rawHeadersIndex = 0
452
- for (const header of headerNames) {
453
- let rawValue
454
- const value = headers[header]
455
- if (Array.isArray(value)) {
456
- rawValue = new Array(value.length)
457
-
458
- for (let i = 0; i < value.length; i++) {
459
- rawValue[i] = Buffer.from(value[i])
460
- }
461
- } else {
462
- rawValue = Buffer.from(value)
463
- }
464
-
465
- const headerBuffer = getHeaderNameAsBuffer(header)
466
-
467
- rawHeaders[rawHeadersIndex] = headerBuffer
468
- rawHeadersIndex++
469
-
470
- rawHeaders[rawHeadersIndex] = rawValue
471
- rawHeadersIndex++
472
- }
473
-
474
- return rawHeaders
475
- }
476
-
477
439
  /**
478
440
  * @param {Buffer[]} headers
479
441
  * @returns {string[]}
@@ -516,6 +478,17 @@ function parseRawHeaders (headers) {
516
478
  return ret
517
479
  }
518
480
 
481
+ /**
482
+ * @param {string[]} headers
483
+ * @param {Buffer[]} headers
484
+ */
485
+ function encodeRawHeaders (headers) {
486
+ if (!Array.isArray(headers)) {
487
+ throw new TypeError('expected headers to be an array')
488
+ }
489
+ return headers.map(x => Buffer.from(x))
490
+ }
491
+
519
492
  /**
520
493
  * @param {*} buffer
521
494
  * @returns {buffer is Buffer}
@@ -901,8 +874,8 @@ module.exports = {
901
874
  removeAllListeners,
902
875
  errorRequest,
903
876
  parseRawHeaders,
877
+ encodeRawHeaders,
904
878
  parseHeaders,
905
- encodeHeaders,
906
879
  parseKeepAliveTimeout,
907
880
  destroy,
908
881
  bodyLength,