undici 8.1.0 → 8.2.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 (47) hide show
  1. package/docs/docs/api/Dispatcher.md +2 -2
  2. package/lib/api/api-connect.js +1 -1
  3. package/lib/api/api-pipeline.js +2 -2
  4. package/lib/api/api-request.js +2 -2
  5. package/lib/api/api-stream.js +1 -1
  6. package/lib/api/api-upgrade.js +8 -2
  7. package/lib/api/readable.js +3 -2
  8. package/lib/cache/memory-cache-store.js +1 -1
  9. package/lib/cache/sqlite-cache-store.js +6 -4
  10. package/lib/core/connect.js +16 -0
  11. package/lib/core/constants.js +1 -24
  12. package/lib/core/errors.js +2 -2
  13. package/lib/core/request.js +17 -2
  14. package/lib/core/socks5-client.js +24 -9
  15. package/lib/core/socks5-utils.js +32 -23
  16. package/lib/core/util.js +28 -3
  17. package/lib/dispatcher/agent.js +37 -39
  18. package/lib/dispatcher/balanced-pool.js +21 -23
  19. package/lib/dispatcher/client-h1.js +34 -16
  20. package/lib/dispatcher/client-h2.js +400 -147
  21. package/lib/dispatcher/h2c-client.js +4 -4
  22. package/lib/dispatcher/pool-base.js +6 -6
  23. package/lib/dispatcher/pool.js +7 -2
  24. package/lib/dispatcher/proxy-agent.js +2 -0
  25. package/lib/dispatcher/round-robin-pool.js +5 -6
  26. package/lib/dispatcher/socks5-proxy-agent.js +23 -14
  27. package/lib/handler/cache-handler.js +1 -1
  28. package/lib/handler/redirect-handler.js +4 -0
  29. package/lib/interceptor/redirect.js +3 -3
  30. package/lib/llhttp/llhttp-wasm.js +1 -1
  31. package/lib/llhttp/llhttp_simd-wasm.js +1 -1
  32. package/lib/mock/mock-agent.js +8 -8
  33. package/lib/mock/mock-call-history.js +15 -15
  34. package/lib/util/cache.js +1 -1
  35. package/lib/web/eventsource/eventsource-stream.js +245 -150
  36. package/lib/web/fetch/formdata-parser.js +17 -6
  37. package/lib/web/fetch/index.js +38 -28
  38. package/lib/web/webidl/index.js +5 -5
  39. package/lib/web/websocket/frame.js +1 -7
  40. package/lib/web/websocket/stream/websocketstream.js +6 -5
  41. package/package.json +1 -1
  42. package/types/dispatcher.d.ts +4 -4
  43. package/types/header.d.ts +5 -0
  44. package/types/interceptors.d.ts +1 -1
  45. package/types/proxy-agent.d.ts +2 -2
  46. package/types/socks5-proxy-agent.d.ts +2 -2
  47. package/lib/llhttp/.gitkeep +0 -0
@@ -1354,10 +1354,10 @@ Emitted when dispatcher is no longer busy.
1354
1354
 
1355
1355
  ## Parameter: `UndiciHeaders`
1356
1356
 
1357
- * `Record<string, string | string[] | undefined> | string[] | Iterable<[string, string | string[] | undefined]> | null`
1357
+ * `Record<string, number | string | string[] | undefined> | string[] | Iterable<[string, string | string[] | undefined]> | null`
1358
1358
 
1359
1359
  Header arguments such as `options.headers` in [`Client.dispatch`](/docs/docs/api/Client.md#clientdispatchoptions-handlers) can be specified in three forms:
1360
- * As an object specified by the `Record<string, string | string[] | undefined>` (`IncomingHttpHeaders`) type.
1360
+ * As an object specified by the `Record<string, number | string | string[] | undefined>` (`OutgoingHttpHeaders`) type.
1361
1361
  * As an array of strings. An array representation of a header list must have an even length, or an `InvalidArgumentError` will be thrown.
1362
1362
  * As an iterable that can encompass `Headers`, `Map`, or a custom iterator returning key-value pairs.
1363
1363
  Keys are lowercase and values are not modified.
@@ -60,7 +60,7 @@ class ConnectHandler extends AsyncResource {
60
60
  // Indicates is an HTTP2Session
61
61
  if (responseHeaders != null) {
62
62
  responseHeaders = this.responseHeaders === 'raw'
63
- ? (Array.isArray(rawHeaders) ? util.parseRawHeaders(rawHeaders) : [])
63
+ ? util.parseRawHeaders(rawHeaders)
64
64
  : headers
65
65
  }
66
66
 
@@ -167,7 +167,7 @@ class PipelineHandler extends AsyncResource {
167
167
  if (this.onInfo) {
168
168
  const rawHeaders = controller?.rawHeaders
169
169
  const responseHeaders = this.responseHeaders === 'raw'
170
- ? (Array.isArray(rawHeaders) ? util.parseRawHeaders(rawHeaders) : [])
170
+ ? util.parseRawHeaders(rawHeaders)
171
171
  : headers
172
172
  this.onInfo({ statusCode, headers: responseHeaders })
173
173
  }
@@ -181,7 +181,7 @@ class PipelineHandler extends AsyncResource {
181
181
  this.handler = null
182
182
  const rawHeaders = controller?.rawHeaders
183
183
  const responseHeaders = this.responseHeaders === 'raw'
184
- ? (Array.isArray(rawHeaders) ? util.parseRawHeaders(rawHeaders) : [])
184
+ ? util.parseRawHeaders(rawHeaders)
185
185
  : headers
186
186
  body = this.runInAsyncScope(handler, null, {
187
187
  statusCode,
@@ -21,7 +21,7 @@ class RequestHandler extends AsyncResource {
21
21
  throw new InvalidArgumentError('invalid callback')
22
22
  }
23
23
 
24
- if (highWaterMark && (typeof highWaterMark !== 'number' || highWaterMark < 0)) {
24
+ if (highWaterMark != null && (!Number.isFinite(highWaterMark) || highWaterMark < 0)) {
25
25
  throw new InvalidArgumentError('invalid highWaterMark')
26
26
  }
27
27
 
@@ -92,7 +92,7 @@ class RequestHandler extends AsyncResource {
92
92
 
93
93
  const rawHeaders = controller?.rawHeaders
94
94
  const responseHeaderData = responseHeaders === 'raw'
95
- ? (Array.isArray(rawHeaders) ? util.parseRawHeaders(rawHeaders) : [])
95
+ ? util.parseRawHeaders(rawHeaders)
96
96
  : headers
97
97
 
98
98
  if (statusCode < 200) {
@@ -85,7 +85,7 @@ class StreamHandler extends AsyncResource {
85
85
 
86
86
  const rawHeaders = controller?.rawHeaders
87
87
  const responseHeaderData = responseHeaders === 'raw'
88
- ? (Array.isArray(rawHeaders) ? util.parseRawHeaders(rawHeaders) : [])
88
+ ? util.parseRawHeaders(rawHeaders)
89
89
  : headers
90
90
 
91
91
  if (statusCode < 200) {
@@ -51,7 +51,13 @@ class UpgradeHandler extends AsyncResource {
51
51
  }
52
52
 
53
53
  onRequestUpgrade (controller, statusCode, headers, socket) {
54
- assert(socket[kHTTP2Stream] === true ? statusCode === 200 : statusCode === 101)
54
+ const expectedStatusCode = socket[kHTTP2Stream] === true ? 200 : 101
55
+
56
+ if (statusCode !== expectedStatusCode) {
57
+ const socketInfo = socket[kHTTP2Stream] === true ? null : util.getSocketInfo(socket)
58
+ controller.abort(new SocketError('bad upgrade', socketInfo))
59
+ return
60
+ }
55
61
 
56
62
  const { callback, opaque, context } = this
57
63
 
@@ -61,7 +67,7 @@ class UpgradeHandler extends AsyncResource {
61
67
 
62
68
  const rawHeaders = controller?.rawHeaders
63
69
  const responseHeaders = this.responseHeaders === 'raw'
64
- ? (Array.isArray(rawHeaders) ? util.parseRawHeaders(rawHeaders) : [])
70
+ ? util.parseRawHeaders(rawHeaders)
65
71
  : headers
66
72
 
67
73
  this.runInAsyncScope(callback, null, null, {
@@ -1,6 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const assert = require('node:assert')
4
+ const { addAbortListener } = require('node:events')
4
5
  const { Readable } = require('node:stream')
5
6
  const { RequestAbortedError, NotSupportedError, InvalidArgumentError, AbortError } = require('../core/errors')
6
7
  const util = require('../core/util')
@@ -293,10 +294,10 @@ class BodyReadable extends Readable {
293
294
  const onAbort = () => {
294
295
  this.destroy(signal.reason ?? new AbortError())
295
296
  }
296
- signal.addEventListener('abort', onAbort)
297
+ const abortListener = addAbortListener(signal, onAbort)
297
298
  this
298
299
  .on('close', function () {
299
- signal.removeEventListener('abort', onAbort)
300
+ abortListener[Symbol.dispose]()
300
301
  if (signal.aborted) {
301
302
  reject(signal.reason ?? new AbortError())
302
303
  } else {
@@ -138,7 +138,7 @@ class MemoryCacheStore extends EventEmitter {
138
138
 
139
139
  entry.size += chunk.byteLength
140
140
 
141
- if (entry.size >= store.#maxEntrySize) {
141
+ if (entry.size > store.#maxEntrySize) {
142
142
  this.destroy()
143
143
  } else {
144
144
  entry.body.push(chunk)
@@ -173,6 +173,7 @@ module.exports = class SqliteCacheStore {
173
173
  headers = ?,
174
174
  etag = ?,
175
175
  cacheControlDirectives = ?,
176
+ vary = ?,
176
177
  cachedAt = ?,
177
178
  staleAt = ?
178
179
  WHERE
@@ -216,7 +217,7 @@ module.exports = class SqliteCacheStore {
216
217
  SELECT
217
218
  id
218
219
  FROM cacheInterceptorV${VERSION}
219
- ORDER BY cachedAt DESC
220
+ ORDER BY cachedAt ASC
220
221
  LIMIT ?
221
222
  )
222
223
  `)
@@ -278,12 +279,12 @@ module.exports = class SqliteCacheStore {
278
279
  value.headers ? JSON.stringify(value.headers) : null,
279
280
  value.etag ? value.etag : null,
280
281
  value.cacheControlDirectives ? JSON.stringify(value.cacheControlDirectives) : null,
282
+ value.vary ? JSON.stringify(value.vary) : null,
281
283
  value.cachedAt,
282
284
  value.staleAt,
283
285
  existingValue.id
284
286
  )
285
287
  } else {
286
- this.#prune()
287
288
  // New response, let's insert it
288
289
  this.#insertValueQuery.run(
289
290
  url,
@@ -299,6 +300,7 @@ module.exports = class SqliteCacheStore {
299
300
  value.cachedAt,
300
301
  value.staleAt
301
302
  )
303
+ this.#prune()
302
304
  }
303
305
  }
304
306
 
@@ -323,7 +325,7 @@ module.exports = class SqliteCacheStore {
323
325
  write (chunk, encoding, callback) {
324
326
  size += chunk.byteLength
325
327
 
326
- if (size < store.#maxEntrySize) {
328
+ if (size <= store.#maxEntrySize) {
327
329
  body.push(chunk)
328
330
  } else {
329
331
  this.destroy()
@@ -409,7 +411,7 @@ module.exports = class SqliteCacheStore {
409
411
  const now = Date.now()
410
412
  for (const value of values) {
411
413
  if (now >= value.deleteAt && !canBeExpired) {
412
- return undefined
414
+ continue
413
415
  }
414
416
 
415
417
  let matches = true
@@ -38,6 +38,22 @@ const SessionCache = class WeakSessionCache {
38
38
  return
39
39
  }
40
40
 
41
+ if (this._sessionCache.has(sessionKey)) {
42
+ this._sessionCache.delete(sessionKey)
43
+ } else if (this._sessionCache.size >= this._maxCachedSessions) {
44
+ for (const [key, ref] of this._sessionCache) {
45
+ if (ref.deref() === undefined) {
46
+ this._sessionCache.delete(key)
47
+ return
48
+ }
49
+ }
50
+
51
+ const oldest = this._sessionCache.keys().next()
52
+ if (!oldest.done) {
53
+ this._sessionCache.delete(oldest.value)
54
+ }
55
+ }
56
+
41
57
  this._sessionCache.set(sessionKey, new WeakRef(session))
42
58
  this._sessionRegistry.register(session, sessionKey)
43
59
  }
@@ -107,28 +107,6 @@ const headerNameLowerCasedRecord = {}
107
107
  // Note: object prototypes should not be able to be referenced. e.g. `Object#hasOwnProperty`.
108
108
  Object.setPrototypeOf(headerNameLowerCasedRecord, null)
109
109
 
110
- /**
111
- * @type {Record<Lowercase<typeof wellknownHeaderNames[number]>, Buffer>}
112
- */
113
- const wellknownHeaderNameBuffers = {}
114
-
115
- // Note: object prototypes should not be able to be referenced. e.g. `Object#hasOwnProperty`.
116
- Object.setPrototypeOf(wellknownHeaderNameBuffers, null)
117
-
118
- /**
119
- * @param {string} header Lowercased header
120
- * @returns {Buffer}
121
- */
122
- function getHeaderNameAsBuffer (header) {
123
- let buffer = wellknownHeaderNameBuffers[header]
124
-
125
- if (buffer === undefined) {
126
- buffer = Buffer.from(header)
127
- }
128
-
129
- return buffer
130
- }
131
-
132
110
  for (let i = 0; i < wellknownHeaderNames.length; ++i) {
133
111
  const key = wellknownHeaderNames[i]
134
112
  const lowerCasedKey = key.toLowerCase()
@@ -138,6 +116,5 @@ for (let i = 0; i < wellknownHeaderNames.length; ++i) {
138
116
 
139
117
  module.exports = {
140
118
  wellknownHeaderNames,
141
- headerNameLowerCasedRecord,
142
- getHeaderNameAsBuffer
119
+ headerNameLowerCasedRecord
143
120
  }
@@ -163,8 +163,8 @@ class RequestAbortedError extends AbortError {
163
163
 
164
164
  const kInformationalError = Symbol.for('undici.error.UND_ERR_INFO')
165
165
  class InformationalError extends UndiciError {
166
- constructor (message) {
167
- super(message)
166
+ constructor (message, options) {
167
+ super(message, options)
168
168
  this.name = 'InformationalError'
169
169
  this.message = message || 'Request information'
170
170
  this.code = 'UND_ERR_INFO'
@@ -28,6 +28,21 @@ const { headerNameLowerCasedRecord } = require('./constants')
28
28
  // Verifies that a given path is valid does not contain control chars \x00 to \x20
29
29
  const invalidPathRegex = /[^\u0021-\u00ff]/
30
30
 
31
+ function isValidContentLengthHeaderValue (val) {
32
+ if (typeof val !== 'string' || val.length === 0) {
33
+ return false
34
+ }
35
+
36
+ for (let i = 0; i < val.length; i++) {
37
+ const charCode = val.charCodeAt(i)
38
+ if (charCode < 48 || charCode > 57) {
39
+ return false
40
+ }
41
+ }
42
+
43
+ return true
44
+ }
45
+
31
46
  const kHandler = Symbol('handler')
32
47
  const kController = Symbol('controller')
33
48
  const kResume = Symbol('resume')
@@ -484,10 +499,10 @@ function processHeader (request, key, val) {
484
499
  if (request.contentLength !== null) {
485
500
  throw new InvalidArgumentError('duplicate content-length header')
486
501
  }
487
- request.contentLength = parseInt(val, 10)
488
- if (!Number.isFinite(request.contentLength)) {
502
+ if (!isValidContentLengthHeaderValue(val)) {
489
503
  throw new InvalidArgumentError('invalid content-length header')
490
504
  }
505
+ request.contentLength = parseInt(val, 10)
491
506
  } else if (request.contentType === null && headerName === 'content-type') {
492
507
  request.contentType = val
493
508
  request.headers.push(key, val)
@@ -7,6 +7,7 @@ const { debuglog } = require('node:util')
7
7
  const { parseAddress } = require('./socks5-utils')
8
8
 
9
9
  const debug = debuglog('undici:socks5')
10
+ const EMPTY_BUFFER = Buffer.alloc(0)
10
11
 
11
12
  // SOCKS5 constants
12
13
  const SOCKS_VERSION = 0x05
@@ -51,6 +52,7 @@ const STATES = {
51
52
  INITIAL: 'initial',
52
53
  HANDSHAKING: 'handshaking',
53
54
  AUTHENTICATING: 'authenticating',
55
+ AUTHENTICATED: 'authenticated',
54
56
  CONNECTING: 'connecting',
55
57
  CONNECTED: 'connected',
56
58
  ERROR: 'error',
@@ -72,7 +74,10 @@ class Socks5Client extends EventEmitter {
72
74
  this.socket = socket
73
75
  this.options = options
74
76
  this.state = STATES.INITIAL
75
- this.buffer = Buffer.alloc(0)
77
+ this.buffer = EMPTY_BUFFER
78
+ this.onSocketData = this.onData.bind(this)
79
+ this.onSocketError = this.onError.bind(this)
80
+ this.onSocketClose = this.onClose.bind(this)
76
81
 
77
82
  // Authentication settings
78
83
  this.authMethods = []
@@ -82,9 +87,9 @@ class Socks5Client extends EventEmitter {
82
87
  this.authMethods.push(AUTH_METHODS.NO_AUTH)
83
88
 
84
89
  // Socket event handlers
85
- this.socket.on('data', this.onData.bind(this))
86
- this.socket.on('error', this.onError.bind(this))
87
- this.socket.on('close', this.onClose.bind(this))
90
+ this.socket.on('data', this.onSocketData)
91
+ this.socket.on('error', this.onSocketError)
92
+ this.socket.on('close', this.onSocketClose)
88
93
  }
89
94
 
90
95
  /**
@@ -139,6 +144,11 @@ class Socks5Client extends EventEmitter {
139
144
  }
140
145
  }
141
146
 
147
+ markAuthenticated () {
148
+ this.state = STATES.AUTHENTICATED
149
+ this.emit('authenticated')
150
+ }
151
+
142
152
  /**
143
153
  * Start the SOCKS5 handshake
144
154
  */
@@ -189,7 +199,7 @@ class Socks5Client extends EventEmitter {
189
199
  debug('server selected auth method', method)
190
200
 
191
201
  if (method === AUTH_METHODS.NO_AUTH) {
192
- this.emit('authenticated')
202
+ this.markAuthenticated()
193
203
  } else if (method === AUTH_METHODS.USERNAME_PASSWORD) {
194
204
  this.state = STATES.AUTHENTICATING
195
205
  this.sendAuthRequest()
@@ -254,7 +264,7 @@ class Socks5Client extends EventEmitter {
254
264
 
255
265
  this.buffer = this.buffer.subarray(2)
256
266
  debug('authentication successful')
257
- this.emit('authenticated')
267
+ this.markAuthenticated()
258
268
  }
259
269
 
260
270
  /**
@@ -263,8 +273,12 @@ class Socks5Client extends EventEmitter {
263
273
  * @param {number} port - Target port
264
274
  */
265
275
  connect (address, port) {
266
- if (this.state === STATES.CONNECTED) {
267
- throw new InvalidArgumentError('Already connected')
276
+ if (this.state === STATES.CONNECTING || this.state === STATES.CONNECTED) {
277
+ throw new InvalidArgumentError('Connection already in progress')
278
+ }
279
+
280
+ if (this.state !== STATES.AUTHENTICATED) {
281
+ throw new InvalidArgumentError('Client must be authenticated before CONNECT')
268
282
  }
269
283
 
270
284
  debug('connecting to', address, port)
@@ -363,8 +377,9 @@ class Socks5Client extends EventEmitter {
363
377
 
364
378
  const boundPort = this.buffer.readUInt16BE(offset)
365
379
 
366
- this.buffer = this.buffer.subarray(responseLength)
380
+ this.buffer = EMPTY_BUFFER
367
381
  this.state = STATES.CONNECTED
382
+ this.socket.removeListener('data', this.onSocketData)
368
383
 
369
384
  debug('connected, bound address:', boundAddress, 'port:', boundPort)
370
385
  this.emit('connected', { address: boundAddress, port: boundPort })
@@ -46,34 +46,43 @@ function parseAddress (address) {
46
46
  */
47
47
  function parseIPv6 (address) {
48
48
  const buffer = Buffer.alloc(16)
49
- const parts = address.split(':')
50
- let partIndex = 0
51
- let bufferIndex = 0
49
+ let normalizedAddress = address
50
+
51
+ // Expand an embedded IPv4 tail into the last two IPv6 groups.
52
+ if (address.includes('.')) {
53
+ const lastColonIndex = address.lastIndexOf(':')
54
+ const ipv4Part = address.slice(lastColonIndex + 1)
55
+
56
+ if (net.isIPv4(ipv4Part)) {
57
+ const octets = ipv4Part.split('.').map(Number)
58
+ const high = ((octets[0] << 8) | octets[1]).toString(16)
59
+ const low = ((octets[2] << 8) | octets[3]).toString(16)
60
+ normalizedAddress = `${address.slice(0, lastColonIndex)}:${high}:${low}`
61
+ }
62
+ }
52
63
 
53
64
  // Handle compressed notation (::)
54
- const doubleColonIndex = address.indexOf('::')
65
+ const doubleColonIndex = normalizedAddress.indexOf('::')
55
66
  if (doubleColonIndex !== -1) {
56
- // Count non-empty parts
57
- const nonEmptyParts = parts.filter(p => p.length > 0).length
58
- const skipParts = 8 - nonEmptyParts
59
-
60
- for (let i = 0; i < parts.length; i++) {
61
- if (parts[i] === '' && i === doubleColonIndex / 3) {
62
- // Skip empty parts for ::
63
- bufferIndex += skipParts * 2
64
- } else if (parts[i] !== '') {
65
- const value = parseInt(parts[i], 16)
66
- buffer.writeUInt16BE(value, bufferIndex)
67
- bufferIndex += 2
68
- }
67
+ const before = normalizedAddress.slice(0, doubleColonIndex)
68
+ const after = normalizedAddress.slice(doubleColonIndex + 2)
69
+ const beforeParts = before === '' ? [] : before.split(':')
70
+ const afterParts = after === '' ? [] : after.split(':')
71
+
72
+ let bufferIndex = 0
73
+ for (const part of beforeParts) {
74
+ buffer.writeUInt16BE(parseInt(part, 16), bufferIndex)
75
+ bufferIndex += 2
76
+ }
77
+ bufferIndex = 16 - afterParts.length * 2
78
+ for (const part of afterParts) {
79
+ buffer.writeUInt16BE(parseInt(part, 16), bufferIndex)
80
+ bufferIndex += 2
69
81
  }
70
82
  } else {
71
- // No compression, parse normally
72
- for (const part of parts) {
73
- if (part === '') continue
74
- const value = parseInt(part, 16)
75
- buffer.writeUInt16BE(value, partIndex * 2)
76
- partIndex++
83
+ const parts = normalizedAddress.split(':')
84
+ for (let i = 0; i < parts.length; i++) {
85
+ buffer.writeUInt16BE(parseInt(parts[i], 16), i * 2)
77
86
  }
78
87
  }
79
88
 
package/lib/core/util.js CHANGED
@@ -6,7 +6,7 @@ const { IncomingMessage } = require('node:http')
6
6
  const stream = require('node:stream')
7
7
  const net = require('node:net')
8
8
  const { stringify } = require('node:querystring')
9
- const { EventEmitter: EE } = require('node:events')
9
+ const { EventEmitter: EE, addAbortListener: addAbortListenerNative } = require('node:events')
10
10
  const timers = require('../util/timers')
11
11
  const { InvalidArgumentError, ConnectTimeoutError } = require('./errors')
12
12
  const { headerNameLowerCasedRecord } = require('./constants')
@@ -464,10 +464,30 @@ function parseHeaders (headers, obj) {
464
464
  }
465
465
 
466
466
  /**
467
- * @param {Buffer[]} headers
467
+ * @param {Buffer[] | string[] | Record<string, string | string[]> | null | undefined} headers
468
468
  * @returns {string[]}
469
469
  */
470
470
  function parseRawHeaders (headers) {
471
+ if (headers == null) {
472
+ return []
473
+ }
474
+
475
+ if (!Array.isArray(headers)) {
476
+ const rawHeaders = []
477
+
478
+ for (const [name, value] of Object.entries(headers)) {
479
+ if (Array.isArray(value)) {
480
+ for (const entry of value) {
481
+ rawHeaders.push(name, `${entry}`)
482
+ }
483
+ } else {
484
+ rawHeaders.push(name, `${value}`)
485
+ }
486
+ }
487
+
488
+ return rawHeaders
489
+ }
490
+
471
491
  const headersLength = headers.length
472
492
  /**
473
493
  * @type {string[]}
@@ -678,7 +698,12 @@ function isFormDataLike (object) {
678
698
  }
679
699
 
680
700
  function addAbortListener (signal, listener) {
681
- if ('addEventListener' in signal) {
701
+ if (signal instanceof AbortSignal) {
702
+ const disposable = addAbortListenerNative(signal, listener)
703
+ return () => disposable[Symbol.dispose]()
704
+ }
705
+
706
+ if (typeof signal.addEventListener === 'function') {
682
707
  signal.addEventListener('abort', listener, { once: true })
683
708
  return () => signal.removeEventListener('abort', listener)
684
709
  }
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const { InvalidArgumentError, MaxOriginsReachedError } = require('../core/errors')
4
- const { kClients, kRunning, kClose, kDestroy, kDispatch, kUrl } = require('../core/symbols')
4
+ const { kBusy, kClients, kConnected, kRunning, kClose, kDestroy, kDispatch, kUrl } = require('../core/symbols')
5
5
  const DispatcherBase = require('./dispatcher-base')
6
6
  const Pool = require('./pool')
7
7
  const Client = require('./client')
@@ -65,7 +65,7 @@ class Agent extends DispatcherBase {
65
65
 
66
66
  get [kRunning] () {
67
67
  let ret = 0
68
- for (const { dispatcher } of this[kClients].values()) {
68
+ for (const dispatcher of this[kClients].values()) {
69
69
  ret += dispatcher[kRunning]
70
70
  }
71
71
  return ret
@@ -86,54 +86,52 @@ class Agent extends DispatcherBase {
86
86
  throw new MaxOriginsReachedError()
87
87
  }
88
88
 
89
- const result = this[kClients].get(key)
90
- let dispatcher = result && result.dispatcher
89
+ let dispatcher = this[kClients].get(key)
91
90
  if (!dispatcher) {
92
- const closeClientIfUnused = (connected) => {
93
- const result = this[kClients].get(key)
94
- if (result) {
95
- if (connected) result.count -= 1
96
- if (result.count <= 0) {
97
- this[kClients].delete(key)
98
- if (!result.dispatcher.destroyed) {
99
- result.dispatcher.close()
100
- }
101
- }
91
+ dispatcher = this[kFactory](opts.origin, allowH2 === false
92
+ ? { ...this[kOptions], allowH2: false }
93
+ : this[kOptions])
102
94
 
103
- let hasOrigin = false
104
- for (const entry of this[kClients].values()) {
105
- if (entry.origin === origin) {
106
- hasOrigin = true
107
- break
108
- }
109
- }
95
+ const closeClientIfUnused = () => {
96
+ if (this[kClients].get(key) !== dispatcher) {
97
+ return
98
+ }
99
+
100
+ if (dispatcher[kConnected] > 0 || dispatcher[kBusy]) {
101
+ return
102
+ }
103
+
104
+ this[kClients].delete(key)
105
+ if (!dispatcher.destroyed) {
106
+ dispatcher.close()
107
+ }
110
108
 
111
- if (!hasOrigin) {
112
- this[kOrigins].delete(origin)
109
+ let hasOrigin = false
110
+ for (const client of this[kClients].values()) {
111
+ if (client[kUrl].origin === dispatcher[kUrl].origin) {
112
+ hasOrigin = true
113
+ break
113
114
  }
114
115
  }
116
+
117
+ if (!hasOrigin) {
118
+ this[kOrigins].delete(dispatcher[kUrl].origin)
119
+ }
115
120
  }
116
- dispatcher = this[kFactory](opts.origin, allowH2 === false
117
- ? { ...this[kOptions], allowH2: false }
118
- : this[kOptions])
121
+
122
+ dispatcher
119
123
  .on('drain', this[kOnDrain])
120
- .on('connect', (origin, targets) => {
121
- const result = this[kClients].get(key)
122
- if (result) {
123
- result.count += 1
124
- }
125
- this[kOnConnect](origin, targets)
126
- })
124
+ .on('connect', this[kOnConnect])
127
125
  .on('disconnect', (origin, targets, err) => {
128
- closeClientIfUnused(true)
126
+ closeClientIfUnused()
129
127
  this[kOnDisconnect](origin, targets, err)
130
128
  })
131
129
  .on('connectionError', (origin, targets, err) => {
132
- closeClientIfUnused(false)
130
+ closeClientIfUnused()
133
131
  this[kOnConnectionError](origin, targets, err)
134
132
  })
135
133
 
136
- this[kClients].set(key, { count: 0, dispatcher, origin })
134
+ this[kClients].set(key, dispatcher)
137
135
  this[kOrigins].add(origin)
138
136
  }
139
137
 
@@ -142,7 +140,7 @@ class Agent extends DispatcherBase {
142
140
 
143
141
  [kClose] () {
144
142
  const closePromises = []
145
- for (const { dispatcher } of this[kClients].values()) {
143
+ for (const dispatcher of this[kClients].values()) {
146
144
  closePromises.push(dispatcher.close())
147
145
  }
148
146
  this[kClients].clear()
@@ -152,7 +150,7 @@ class Agent extends DispatcherBase {
152
150
 
153
151
  [kDestroy] (err) {
154
152
  const destroyPromises = []
155
- for (const { dispatcher } of this[kClients].values()) {
153
+ for (const dispatcher of this[kClients].values()) {
156
154
  destroyPromises.push(dispatcher.destroy(err))
157
155
  }
158
156
  this[kClients].clear()
@@ -162,7 +160,7 @@ class Agent extends DispatcherBase {
162
160
 
163
161
  get stats () {
164
162
  const allClientStats = {}
165
- for (const { dispatcher } of this[kClients].values()) {
163
+ for (const dispatcher of this[kClients].values()) {
166
164
  if (dispatcher.stats) {
167
165
  allClientStats[dispatcher[kUrl].origin] = dispatcher.stats
168
166
  }