undici 7.15.0 → 7.16.0

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 (45) hide show
  1. package/README.md +1 -1
  2. package/docs/docs/api/Agent.md +1 -0
  3. package/docs/docs/api/Errors.md +0 -1
  4. package/index-fetch.js +2 -2
  5. package/index.js +4 -8
  6. package/lib/api/api-request.js +22 -8
  7. package/lib/api/readable.js +7 -5
  8. package/lib/core/errors.js +217 -13
  9. package/lib/core/request.js +5 -1
  10. package/lib/core/util.js +32 -10
  11. package/lib/dispatcher/agent.js +19 -7
  12. package/lib/dispatcher/client-h1.js +20 -9
  13. package/lib/dispatcher/client-h2.js +13 -3
  14. package/lib/dispatcher/client.js +57 -57
  15. package/lib/dispatcher/dispatcher-base.js +12 -7
  16. package/lib/dispatcher/env-http-proxy-agent.js +12 -16
  17. package/lib/dispatcher/fixed-queue.js +15 -39
  18. package/lib/dispatcher/h2c-client.js +6 -6
  19. package/lib/dispatcher/pool-base.js +60 -43
  20. package/lib/dispatcher/pool.js +2 -2
  21. package/lib/dispatcher/proxy-agent.js +14 -9
  22. package/lib/global.js +19 -1
  23. package/lib/interceptor/cache.js +61 -0
  24. package/lib/mock/mock-agent.js +4 -4
  25. package/lib/mock/mock-errors.js +10 -0
  26. package/lib/mock/mock-utils.js +12 -10
  27. package/lib/util/date.js +534 -140
  28. package/lib/web/cookies/index.js +1 -1
  29. package/lib/web/eventsource/eventsource-stream.js +2 -2
  30. package/lib/web/eventsource/eventsource.js +34 -29
  31. package/lib/web/eventsource/util.js +1 -9
  32. package/lib/web/fetch/body.js +16 -22
  33. package/lib/web/fetch/index.js +14 -15
  34. package/lib/web/fetch/response.js +2 -4
  35. package/lib/web/fetch/util.js +8 -14
  36. package/lib/web/webidl/index.js +203 -42
  37. package/lib/web/websocket/connection.js +4 -3
  38. package/lib/web/websocket/events.js +1 -1
  39. package/lib/web/websocket/stream/websocketerror.js +22 -1
  40. package/lib/web/websocket/stream/websocketstream.js +16 -7
  41. package/lib/web/websocket/websocket.js +32 -42
  42. package/package.json +7 -6
  43. package/types/agent.d.ts +1 -0
  44. package/types/errors.d.ts +5 -15
  45. package/types/webidl.d.ts +82 -21
@@ -18,54 +18,55 @@ const kAddClient = Symbol('add client')
18
18
  const kRemoveClient = Symbol('remove client')
19
19
 
20
20
  class PoolBase extends DispatcherBase {
21
- constructor () {
22
- super()
21
+ [kQueue] = new FixedQueue();
23
22
 
24
- this[kQueue] = new FixedQueue()
25
- this[kClients] = []
26
- this[kQueued] = 0
23
+ [kQueued] = 0;
27
24
 
28
- const pool = this
25
+ [kClients] = [];
29
26
 
30
- this[kOnDrain] = function onDrain (origin, targets) {
31
- const queue = pool[kQueue]
27
+ [kNeedDrain] = false;
32
28
 
33
- let needDrain = false
29
+ [kOnDrain] (client, origin, targets) {
30
+ const queue = this[kQueue]
34
31
 
35
- while (!needDrain) {
36
- const item = queue.shift()
37
- if (!item) {
38
- break
39
- }
40
- pool[kQueued]--
41
- needDrain = !this.dispatch(item.opts, item.handler)
32
+ let needDrain = false
33
+
34
+ while (!needDrain) {
35
+ const item = queue.shift()
36
+ if (!item) {
37
+ break
42
38
  }
39
+ this[kQueued]--
40
+ needDrain = !client.dispatch(item.opts, item.handler)
41
+ }
43
42
 
44
- this[kNeedDrain] = needDrain
43
+ client[kNeedDrain] = needDrain
45
44
 
46
- if (!this[kNeedDrain] && pool[kNeedDrain]) {
47
- pool[kNeedDrain] = false
48
- pool.emit('drain', origin, [pool, ...targets])
49
- }
45
+ if (!needDrain && this[kNeedDrain]) {
46
+ this[kNeedDrain] = false
47
+ this.emit('drain', origin, [this, ...targets])
48
+ }
50
49
 
51
- if (pool[kClosedResolve] && queue.isEmpty()) {
52
- Promise
53
- .all(pool[kClients].map(c => c.close()))
54
- .then(pool[kClosedResolve])
50
+ if (this[kClosedResolve] && queue.isEmpty()) {
51
+ const closeAll = new Array(this[kClients].length)
52
+ for (let i = 0; i < this[kClients].length; i++) {
53
+ closeAll[i] = this[kClients][i].close()
55
54
  }
55
+ Promise.all(closeAll)
56
+ .then(this[kClosedResolve])
56
57
  }
58
+ }
57
59
 
58
- this[kOnConnect] = (origin, targets) => {
59
- pool.emit('connect', origin, [pool, ...targets])
60
- }
60
+ [kOnConnect] = (origin, targets) => {
61
+ this.emit('connect', origin, [this, ...targets])
62
+ };
61
63
 
62
- this[kOnDisconnect] = (origin, targets, err) => {
63
- pool.emit('disconnect', origin, [pool, ...targets], err)
64
- }
64
+ [kOnDisconnect] = (origin, targets, err) => {
65
+ this.emit('disconnect', origin, [this, ...targets], err)
66
+ };
65
67
 
66
- this[kOnConnectionError] = (origin, targets, err) => {
67
- pool.emit('connectionError', origin, [pool, ...targets], err)
68
- }
68
+ [kOnConnectionError] = (origin, targets, err) => {
69
+ this.emit('connectionError', origin, [this, ...targets], err)
69
70
  }
70
71
 
71
72
  get [kBusy] () {
@@ -73,11 +74,19 @@ class PoolBase extends DispatcherBase {
73
74
  }
74
75
 
75
76
  get [kConnected] () {
76
- return this[kClients].filter(client => client[kConnected]).length
77
+ let ret = 0
78
+ for (const { [kConnected]: connected } of this[kClients]) {
79
+ ret += connected
80
+ }
81
+ return ret
77
82
  }
78
83
 
79
84
  get [kFree] () {
80
- return this[kClients].filter(client => client[kConnected] && !client[kNeedDrain]).length
85
+ let ret = 0
86
+ for (const { [kConnected]: connected, [kNeedDrain]: needDrain } of this[kClients]) {
87
+ ret += connected && !needDrain
88
+ }
89
+ return ret
81
90
  }
82
91
 
83
92
  get [kPending] () {
@@ -108,17 +117,21 @@ class PoolBase extends DispatcherBase {
108
117
  return new PoolStats(this)
109
118
  }
110
119
 
111
- async [kClose] () {
120
+ [kClose] () {
112
121
  if (this[kQueue].isEmpty()) {
113
- await Promise.all(this[kClients].map(c => c.close()))
122
+ const closeAll = new Array(this[kClients].length)
123
+ for (let i = 0; i < this[kClients].length; i++) {
124
+ closeAll[i] = this[kClients][i].close()
125
+ }
126
+ return Promise.all(closeAll)
114
127
  } else {
115
- await new Promise((resolve) => {
128
+ return new Promise((resolve) => {
116
129
  this[kClosedResolve] = resolve
117
130
  })
118
131
  }
119
132
  }
120
133
 
121
- async [kDestroy] (err) {
134
+ [kDestroy] (err) {
122
135
  while (true) {
123
136
  const item = this[kQueue].shift()
124
137
  if (!item) {
@@ -127,7 +140,11 @@ class PoolBase extends DispatcherBase {
127
140
  item.handler.onError(err)
128
141
  }
129
142
 
130
- await Promise.all(this[kClients].map(c => c.destroy(err)))
143
+ const destroyAll = new Array(this[kClients].length)
144
+ for (let i = 0; i < this[kClients].length; i++) {
145
+ destroyAll[i] = this[kClients][i].destroy(err)
146
+ }
147
+ return Promise.all(destroyAll)
131
148
  }
132
149
 
133
150
  [kDispatch] (opts, handler) {
@@ -147,7 +164,7 @@ class PoolBase extends DispatcherBase {
147
164
 
148
165
  [kAddClient] (client) {
149
166
  client
150
- .on('drain', this[kOnDrain])
167
+ .on('drain', this[kOnDrain].bind(this, client))
151
168
  .on('connect', this[kOnConnect])
152
169
  .on('disconnect', this[kOnDisconnect])
153
170
  .on('connectionError', this[kOnConnectionError])
@@ -157,7 +174,7 @@ class PoolBase extends DispatcherBase {
157
174
  if (this[kNeedDrain]) {
158
175
  queueMicrotask(() => {
159
176
  if (this[kNeedDrain]) {
160
- this[kOnDrain](client[kUrl], [this, client])
177
+ this[kOnDrain](client, client[kUrl], [client, this])
161
178
  }
162
179
  })
163
180
  }
@@ -51,8 +51,6 @@ class Pool extends PoolBase {
51
51
  throw new InvalidArgumentError('connect must be a function or an object')
52
52
  }
53
53
 
54
- super()
55
-
56
54
  if (typeof connect !== 'function') {
57
55
  connect = buildConnector({
58
56
  ...tls,
@@ -65,6 +63,8 @@ class Pool extends PoolBase {
65
63
  })
66
64
  }
67
65
 
66
+ super()
67
+
68
68
  this[kConnections] = connections || null
69
69
  this[kUrl] = util.parseOrigin(origin)
70
70
  this[kOptions] = { ...util.deepClone(options), connect, allowH2, clientTtl }
@@ -37,11 +37,12 @@ class Http1ProxyWrapper extends DispatcherBase {
37
37
  #client
38
38
 
39
39
  constructor (proxyUrl, { headers = {}, connect, factory }) {
40
- super()
41
40
  if (!proxyUrl) {
42
41
  throw new InvalidArgumentError('Proxy URL is mandatory')
43
42
  }
44
43
 
44
+ super()
45
+
45
46
  this[kProxyHeaders] = headers
46
47
  if (factory) {
47
48
  this.#client = factory(proxyUrl, { connect })
@@ -80,11 +81,11 @@ class Http1ProxyWrapper extends DispatcherBase {
80
81
  return this.#client[kDispatch](opts, handler)
81
82
  }
82
83
 
83
- async [kClose] () {
84
+ [kClose] () {
84
85
  return this.#client.close()
85
86
  }
86
87
 
87
- async [kDestroy] (err) {
88
+ [kDestroy] (err) {
88
89
  return this.#client.destroy(err)
89
90
  }
90
91
  }
@@ -220,14 +221,18 @@ class ProxyAgent extends DispatcherBase {
220
221
  }
221
222
  }
222
223
 
223
- async [kClose] () {
224
- await this[kAgent].close()
225
- await this[kClient].close()
224
+ [kClose] () {
225
+ return Promise.all([
226
+ this[kAgent].close(),
227
+ this[kClient].close()
228
+ ])
226
229
  }
227
230
 
228
- async [kDestroy] () {
229
- await this[kAgent].destroy()
230
- await this[kClient].destroy()
231
+ [kDestroy] () {
232
+ return Promise.all([
233
+ this[kAgent].destroy(),
234
+ this[kClient].destroy()
235
+ ])
231
236
  }
232
237
  }
233
238
 
package/lib/global.js CHANGED
@@ -26,7 +26,25 @@ function getGlobalDispatcher () {
26
26
  return globalThis[globalDispatcher]
27
27
  }
28
28
 
29
+ // These are the globals that can be installed by undici.install().
30
+ // Not exported by index.js to avoid use outside of this module.
31
+ const installedExports = /** @type {const} */ (
32
+ [
33
+ 'fetch',
34
+ 'Headers',
35
+ 'Response',
36
+ 'Request',
37
+ 'FormData',
38
+ 'WebSocket',
39
+ 'CloseEvent',
40
+ 'ErrorEvent',
41
+ 'MessageEvent',
42
+ 'EventSource'
43
+ ]
44
+ )
45
+
29
46
  module.exports = {
30
47
  setGlobalDispatcher,
31
- getGlobalDispatcher
48
+ getGlobalDispatcher,
49
+ installedExports
32
50
  }
@@ -56,6 +56,22 @@ function needsRevalidation (result, cacheControlDirectives) {
56
56
  return false
57
57
  }
58
58
 
59
+ /**
60
+ * Check if we're within the stale-while-revalidate window for a stale response
61
+ * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result
62
+ * @returns {boolean}
63
+ */
64
+ function withinStaleWhileRevalidateWindow (result) {
65
+ const staleWhileRevalidate = result.cacheControlDirectives?.['stale-while-revalidate']
66
+ if (!staleWhileRevalidate) {
67
+ return false
68
+ }
69
+
70
+ const now = Date.now()
71
+ const staleWhileRevalidateExpiry = result.staleAt + (staleWhileRevalidate * 1000)
72
+ return now <= staleWhileRevalidateExpiry
73
+ }
74
+
59
75
  /**
60
76
  * @param {DispatchFn} dispatch
61
77
  * @param {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions} globalOpts
@@ -231,6 +247,51 @@ function handleResult (
231
247
  return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler))
232
248
  }
233
249
 
250
+ // RFC 5861: If we're within stale-while-revalidate window, serve stale immediately
251
+ // and revalidate in background
252
+ if (withinStaleWhileRevalidateWindow(result)) {
253
+ // Serve stale response immediately
254
+ sendCachedValue(handler, opts, result, age, null, true)
255
+
256
+ // Start background revalidation (fire-and-forget)
257
+ queueMicrotask(() => {
258
+ let headers = {
259
+ ...opts.headers,
260
+ 'if-modified-since': new Date(result.cachedAt).toUTCString()
261
+ }
262
+
263
+ if (result.etag) {
264
+ headers['if-none-match'] = result.etag
265
+ }
266
+
267
+ if (result.vary) {
268
+ headers = {
269
+ ...headers,
270
+ ...result.vary
271
+ }
272
+ }
273
+
274
+ // Background revalidation - update cache if we get new data
275
+ dispatch(
276
+ {
277
+ ...opts,
278
+ headers
279
+ },
280
+ new CacheHandler(globalOpts, cacheKey, {
281
+ // Silent handler that just updates the cache
282
+ onRequestStart () {},
283
+ onRequestUpgrade () {},
284
+ onResponseStart () {},
285
+ onResponseData () {},
286
+ onResponseEnd () {},
287
+ onResponseError () {}
288
+ })
289
+ )
290
+ })
291
+
292
+ return true
293
+ }
294
+
234
295
  let withinStaleIfErrorThreshold = false
235
296
  const staleIfErrorExpiry = result.cacheControlDirectives['stale-if-error'] ?? reqCacheControl?.['stale-if-error']
236
297
  if (staleIfErrorExpiry) {
@@ -29,16 +29,16 @@ const PendingInterceptorsFormatter = require('./pending-interceptors-formatter')
29
29
  const { MockCallHistory } = require('./mock-call-history')
30
30
 
31
31
  class MockAgent extends Dispatcher {
32
- constructor (opts) {
32
+ constructor (opts = {}) {
33
33
  super(opts)
34
34
 
35
35
  const mockOptions = buildAndValidateMockOptions(opts)
36
36
 
37
37
  this[kNetConnect] = true
38
38
  this[kIsMockActive] = true
39
- this[kMockAgentIsCallHistoryEnabled] = mockOptions?.enableCallHistory ?? false
40
- this[kMockAgentAcceptsNonStandardSearchParameters] = mockOptions?.acceptNonStandardSearchParameters ?? false
41
- this[kIgnoreTrailingSlash] = mockOptions?.ignoreTrailingSlash ?? false
39
+ this[kMockAgentIsCallHistoryEnabled] = mockOptions.enableCallHistory ?? false
40
+ this[kMockAgentAcceptsNonStandardSearchParameters] = mockOptions.acceptNonStandardSearchParameters ?? false
41
+ this[kIgnoreTrailingSlash] = mockOptions.ignoreTrailingSlash ?? false
42
42
 
43
43
  // Instantiate Agent and encapsulate
44
44
  if (opts?.agent && typeof opts.agent.dispatch !== 'function') {
@@ -2,6 +2,8 @@
2
2
 
3
3
  const { UndiciError } = require('../core/errors')
4
4
 
5
+ const kMockNotMatchedError = Symbol.for('undici.error.UND_MOCK_ERR_MOCK_NOT_MATCHED')
6
+
5
7
  /**
6
8
  * The request does not match any registered mock dispatches.
7
9
  */
@@ -12,6 +14,14 @@ class MockNotMatchedError extends UndiciError {
12
14
  this.message = message || 'The request does not match any registered mock dispatches'
13
15
  this.code = 'UND_MOCK_ERR_MOCK_NOT_MATCHED'
14
16
  }
17
+
18
+ static [Symbol.hasInstance] (instance) {
19
+ return instance && instance[kMockNotMatchedError] === true
20
+ }
21
+
22
+ get [kMockNotMatchedError] () {
23
+ return true
24
+ }
15
25
  }
16
26
 
17
27
  module.exports = {
@@ -367,7 +367,7 @@ function buildMockDispatch () {
367
367
  try {
368
368
  mockDispatch.call(this, opts, handler)
369
369
  } catch (error) {
370
- if (error instanceof MockNotMatchedError) {
370
+ if (error.code === 'UND_MOCK_ERR_MOCK_NOT_MATCHED') {
371
371
  const netConnect = agent[kGetNetConnect]()
372
372
  if (netConnect === false) {
373
373
  throw new MockNotMatchedError(`${error.message}: subsequent request to origin ${origin} was not allowed (net.connect disabled)`)
@@ -398,19 +398,21 @@ function checkNetConnect (netConnect, origin) {
398
398
  }
399
399
 
400
400
  function buildAndValidateMockOptions (opts) {
401
- if (opts) {
402
- const { agent, ...mockOptions } = opts
401
+ const { agent, ...mockOptions } = opts
403
402
 
404
- if ('enableCallHistory' in mockOptions && typeof mockOptions.enableCallHistory !== 'boolean') {
405
- throw new InvalidArgumentError('options.enableCallHistory must to be a boolean')
406
- }
403
+ if ('enableCallHistory' in mockOptions && typeof mockOptions.enableCallHistory !== 'boolean') {
404
+ throw new InvalidArgumentError('options.enableCallHistory must to be a boolean')
405
+ }
407
406
 
408
- if ('acceptNonStandardSearchParameters' in mockOptions && typeof mockOptions.acceptNonStandardSearchParameters !== 'boolean') {
409
- throw new InvalidArgumentError('options.acceptNonStandardSearchParameters must to be a boolean')
410
- }
407
+ if ('acceptNonStandardSearchParameters' in mockOptions && typeof mockOptions.acceptNonStandardSearchParameters !== 'boolean') {
408
+ throw new InvalidArgumentError('options.acceptNonStandardSearchParameters must to be a boolean')
409
+ }
411
410
 
412
- return mockOptions
411
+ if ('ignoreTrailingSlash' in mockOptions && typeof mockOptions.ignoreTrailingSlash !== 'boolean') {
412
+ throw new InvalidArgumentError('options.ignoreTrailingSlash must to be a boolean')
413
413
  }
414
+
415
+ return mockOptions
414
416
  }
415
417
 
416
418
  module.exports = {