undici 7.16.0 → 7.17.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 (69) hide show
  1. package/README.md +47 -1
  2. package/docs/docs/api/Client.md +1 -0
  3. package/docs/docs/api/DiagnosticsChannel.md +57 -0
  4. package/docs/docs/api/Dispatcher.md +86 -0
  5. package/docs/docs/api/RoundRobinPool.md +145 -0
  6. package/docs/docs/api/WebSocket.md +21 -0
  7. package/docs/docs/best-practices/crawling.md +58 -0
  8. package/index.js +4 -1
  9. package/lib/api/api-upgrade.js +2 -1
  10. package/lib/core/connect.js +4 -1
  11. package/lib/core/diagnostics.js +28 -1
  12. package/lib/core/symbols.js +3 -0
  13. package/lib/core/util.js +29 -31
  14. package/lib/dispatcher/balanced-pool.js +10 -0
  15. package/lib/dispatcher/client-h1.js +0 -16
  16. package/lib/dispatcher/client-h2.js +153 -23
  17. package/lib/dispatcher/client.js +7 -2
  18. package/lib/dispatcher/dispatcher-base.js +11 -12
  19. package/lib/dispatcher/h2c-client.js +7 -78
  20. package/lib/dispatcher/pool-base.js +1 -1
  21. package/lib/dispatcher/proxy-agent.js +13 -2
  22. package/lib/dispatcher/round-robin-pool.js +137 -0
  23. package/lib/encoding/index.js +33 -0
  24. package/lib/handler/cache-handler.js +84 -27
  25. package/lib/handler/deduplication-handler.js +216 -0
  26. package/lib/handler/retry-handler.js +0 -2
  27. package/lib/interceptor/cache.js +35 -17
  28. package/lib/interceptor/decompress.js +2 -1
  29. package/lib/interceptor/deduplicate.js +109 -0
  30. package/lib/interceptor/dns.js +55 -13
  31. package/lib/mock/mock-utils.js +1 -2
  32. package/lib/mock/snapshot-agent.js +11 -5
  33. package/lib/mock/snapshot-recorder.js +12 -4
  34. package/lib/mock/snapshot-utils.js +4 -4
  35. package/lib/util/cache.js +29 -1
  36. package/lib/util/runtime-features.js +124 -0
  37. package/lib/web/cookies/parse.js +1 -1
  38. package/lib/web/fetch/body.js +29 -39
  39. package/lib/web/fetch/data-url.js +12 -160
  40. package/lib/web/fetch/formdata-parser.js +204 -127
  41. package/lib/web/fetch/index.js +9 -6
  42. package/lib/web/fetch/request.js +6 -0
  43. package/lib/web/fetch/response.js +2 -3
  44. package/lib/web/fetch/util.js +2 -65
  45. package/lib/web/infra/index.js +229 -0
  46. package/lib/web/subresource-integrity/subresource-integrity.js +6 -5
  47. package/lib/web/webidl/index.js +4 -2
  48. package/lib/web/websocket/connection.js +31 -21
  49. package/lib/web/websocket/frame.js +9 -15
  50. package/lib/web/websocket/stream/websocketstream.js +1 -1
  51. package/lib/web/websocket/util.js +2 -1
  52. package/package.json +5 -4
  53. package/types/agent.d.ts +1 -1
  54. package/types/api.d.ts +2 -2
  55. package/types/balanced-pool.d.ts +2 -1
  56. package/types/cache-interceptor.d.ts +1 -0
  57. package/types/client.d.ts +1 -1
  58. package/types/connector.d.ts +2 -2
  59. package/types/diagnostics-channel.d.ts +2 -2
  60. package/types/dispatcher.d.ts +12 -12
  61. package/types/fetch.d.ts +4 -4
  62. package/types/formdata.d.ts +1 -1
  63. package/types/h2c-client.d.ts +1 -1
  64. package/types/index.d.ts +9 -1
  65. package/types/interceptors.d.ts +36 -2
  66. package/types/pool.d.ts +1 -1
  67. package/types/readable.d.ts +2 -2
  68. package/types/round-robin-pool.d.ts +41 -0
  69. package/types/websocket.d.ts +9 -9
package/lib/core/util.js CHANGED
@@ -615,14 +615,14 @@ function ReadableStreamFrom (iterable) {
615
615
  pull (controller) {
616
616
  return iterator.next().then(({ done, value }) => {
617
617
  if (done) {
618
- queueMicrotask(() => {
618
+ return queueMicrotask(() => {
619
619
  controller.close()
620
620
  controller.byobRequest?.respond(0)
621
621
  })
622
622
  } else {
623
623
  const buf = Buffer.isBuffer(value) ? value : Buffer.from(value)
624
624
  if (buf.byteLength) {
625
- controller.enqueue(new Uint8Array(buf))
625
+ return controller.enqueue(new Uint8Array(buf))
626
626
  } else {
627
627
  return this.pull(controller)
628
628
  }
@@ -666,48 +666,46 @@ function addAbortListener (signal, listener) {
666
666
  return () => signal.removeListener('abort', listener)
667
667
  }
668
668
 
669
+ const validTokenChars = new Uint8Array([
670
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0-15
671
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16-31
672
+ 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 0, // 32-47 (!"#$%&'()*+,-./)
673
+ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, // 48-63 (0-9:;<=>?)
674
+ 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 64-79 (@A-O)
675
+ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, // 80-95 (P-Z[\]^_)
676
+ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 96-111 (`a-o)
677
+ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0, // 112-127 (p-z{|}~)
678
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 128-143
679
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 144-159
680
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 160-175
681
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 176-191
682
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 192-207
683
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 208-223
684
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 224-239
685
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 // 240-255
686
+ ])
687
+
669
688
  /**
670
689
  * @see https://tools.ietf.org/html/rfc7230#section-3.2.6
671
690
  * @param {number} c
672
691
  * @returns {boolean}
673
692
  */
674
693
  function isTokenCharCode (c) {
675
- switch (c) {
676
- case 0x22:
677
- case 0x28:
678
- case 0x29:
679
- case 0x2c:
680
- case 0x2f:
681
- case 0x3a:
682
- case 0x3b:
683
- case 0x3c:
684
- case 0x3d:
685
- case 0x3e:
686
- case 0x3f:
687
- case 0x40:
688
- case 0x5b:
689
- case 0x5c:
690
- case 0x5d:
691
- case 0x7b:
692
- case 0x7d:
693
- // DQUOTE and "(),/:;<=>?@[\]{}"
694
- return false
695
- default:
696
- // VCHAR %x21-7E
697
- return c >= 0x21 && c <= 0x7e
698
- }
694
+ return (validTokenChars[c] === 1)
699
695
  }
700
696
 
697
+ const tokenRegExp = /^[\^_`a-zA-Z\-0-9!#$%&'*+.|~]+$/
698
+
701
699
  /**
702
700
  * @param {string} characters
703
701
  * @returns {boolean}
704
702
  */
705
703
  function isValidHTTPToken (characters) {
706
- if (characters.length === 0) {
707
- return false
708
- }
709
- for (let i = 0; i < characters.length; ++i) {
710
- if (!isTokenCharCode(characters.charCodeAt(i))) {
704
+ if (characters.length >= 12) return tokenRegExp.test(characters)
705
+ if (characters.length === 0) return false
706
+
707
+ for (let i = 0; i < characters.length; i++) {
708
+ if (validTokenChars[characters.charCodeAt(i)] !== 1) {
711
709
  return false
712
710
  }
713
711
  }
@@ -140,6 +140,16 @@ class BalancedPool extends PoolBase {
140
140
  return this
141
141
  }
142
142
 
143
+ getUpstream (upstream) {
144
+ const upstreamOrigin = parseOrigin(upstream).origin
145
+
146
+ return this[kClients].find((pool) => (
147
+ pool[kUrl].origin === upstreamOrigin &&
148
+ pool.closed !== true &&
149
+ pool.destroyed !== true
150
+ ))
151
+ }
152
+
143
153
  get upstreams () {
144
154
  return this[kClients]
145
155
  .filter(dispatcher => dispatcher.closed !== true && dispatcher.destroyed !== true)
@@ -77,12 +77,10 @@ function lazyllhttp () {
77
77
  if (useWasmSIMD) {
78
78
  try {
79
79
  mod = new WebAssembly.Module(require('../llhttp/llhttp_simd-wasm.js'))
80
- /* istanbul ignore next */
81
80
  } catch {
82
81
  }
83
82
  }
84
83
 
85
- /* istanbul ignore next */
86
84
  if (!mod) {
87
85
  // We could check if the error was caused by the simd option not
88
86
  // being enabled, but the occurring of this other error
@@ -100,7 +98,6 @@ function lazyllhttp () {
100
98
  * @returns {number}
101
99
  */
102
100
  wasm_on_url: (p, at, len) => {
103
- /* istanbul ignore next */
104
101
  return 0
105
102
  },
106
103
  /**
@@ -265,7 +262,6 @@ class Parser {
265
262
 
266
263
  this.timeoutValue = delay
267
264
  } else if (this.timeout) {
268
- // istanbul ignore else: only for jest
269
265
  if (this.timeout.refresh) {
270
266
  this.timeout.refresh()
271
267
  }
@@ -286,7 +282,6 @@ class Parser {
286
282
 
287
283
  assert(this.timeoutType === TIMEOUT_BODY)
288
284
  if (this.timeout) {
289
- // istanbul ignore else: only for jest
290
285
  if (this.timeout.refresh) {
291
286
  this.timeout.refresh()
292
287
  }
@@ -356,7 +351,6 @@ class Parser {
356
351
  } else {
357
352
  const ptr = llhttp.llhttp_get_error_reason(this.ptr)
358
353
  let message = ''
359
- /* istanbul ignore else: difficult to make a test case for */
360
354
  if (ptr) {
361
355
  const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0)
362
356
  message =
@@ -402,7 +396,6 @@ class Parser {
402
396
  onMessageBegin () {
403
397
  const { socket, client } = this
404
398
 
405
- /* istanbul ignore next: difficult to make a test case for */
406
399
  if (socket.destroyed) {
407
400
  return -1
408
401
  }
@@ -531,14 +524,12 @@ class Parser {
531
524
  onHeadersComplete (statusCode, upgrade, shouldKeepAlive) {
532
525
  const { client, socket, headers, statusText } = this
533
526
 
534
- /* istanbul ignore next: difficult to make a test case for */
535
527
  if (socket.destroyed) {
536
528
  return -1
537
529
  }
538
530
 
539
531
  const request = client[kQueue][client[kRunningIdx]]
540
532
 
541
- /* istanbul ignore next: difficult to make a test case for */
542
533
  if (!request) {
543
534
  return -1
544
535
  }
@@ -572,7 +563,6 @@ class Parser {
572
563
  : client[kBodyTimeout]
573
564
  this.setTimeout(bodyTimeout, TIMEOUT_BODY)
574
565
  } else if (this.timeout) {
575
- // istanbul ignore else: only for jest
576
566
  if (this.timeout.refresh) {
577
567
  this.timeout.refresh()
578
568
  }
@@ -653,7 +643,6 @@ class Parser {
653
643
 
654
644
  assert(this.timeoutType === TIMEOUT_BODY)
655
645
  if (this.timeout) {
656
- // istanbul ignore else: only for jest
657
646
  if (this.timeout.refresh) {
658
647
  this.timeout.refresh()
659
648
  }
@@ -709,7 +698,6 @@ class Parser {
709
698
  return 0
710
699
  }
711
700
 
712
- /* istanbul ignore next: should be handled by llhttp? */
713
701
  if (request.method !== 'HEAD' && contentLength && bytesRead !== parseInt(contentLength, 10)) {
714
702
  util.destroy(socket, new ResponseContentLengthMismatchError())
715
703
  return -1
@@ -750,7 +738,6 @@ class Parser {
750
738
  function onParserTimeout (parser) {
751
739
  const { socket, timeoutType, client, paused } = parser.deref()
752
740
 
753
- /* istanbul ignore else */
754
741
  if (timeoutType === TIMEOUT_HEADERS) {
755
742
  if (!socket[kWriting] || socket.writableNeedDrain || client[kRunning] > 1) {
756
743
  assert(!paused, 'cannot be paused while waiting for headers')
@@ -1157,7 +1144,6 @@ function writeH1 (client, request) {
1157
1144
  channels.sendHeaders.publish({ request, headers: header, socket })
1158
1145
  }
1159
1146
 
1160
- /* istanbul ignore else: assertion */
1161
1147
  if (!body || bodyLength === 0) {
1162
1148
  writeBuffer(abort, null, client, request, socket, contentLength, header, expectsPayload)
1163
1149
  } else if (util.isBuffer(body)) {
@@ -1538,7 +1524,6 @@ class AsyncWriter {
1538
1524
 
1539
1525
  if (!ret) {
1540
1526
  if (socket[kParser].timeout && socket[kParser].timeoutType === TIMEOUT_HEADERS) {
1541
- // istanbul ignore else: only for jest
1542
1527
  if (socket[kParser].timeout.refresh) {
1543
1528
  socket[kParser].timeout.refresh()
1544
1529
  }
@@ -1589,7 +1574,6 @@ class AsyncWriter {
1589
1574
  }
1590
1575
 
1591
1576
  if (socket[kParser].timeout && socket[kParser].timeoutType === TIMEOUT_HEADERS) {
1592
- // istanbul ignore else: only for jest
1593
1577
  if (socket[kParser].timeout.refresh) {
1594
1578
  socket[kParser].timeout.refresh()
1595
1579
  }
@@ -7,7 +7,8 @@ const {
7
7
  RequestContentLengthMismatchError,
8
8
  RequestAbortedError,
9
9
  SocketError,
10
- InformationalError
10
+ InformationalError,
11
+ InvalidArgumentError
11
12
  } = require('../core/errors.js')
12
13
  const {
13
14
  kUrl,
@@ -28,7 +29,10 @@ const {
28
29
  kSize,
29
30
  kHTTPContext,
30
31
  kClosed,
31
- kBodyTimeout
32
+ kBodyTimeout,
33
+ kEnableConnectProtocol,
34
+ kRemoteSettings,
35
+ kHTTP2Stream
32
36
  } = require('../core/symbols.js')
33
37
  const { channels } = require('../core/diagnostics.js')
34
38
 
@@ -53,7 +57,10 @@ const {
53
57
  HTTP2_HEADER_SCHEME,
54
58
  HTTP2_HEADER_CONTENT_LENGTH,
55
59
  HTTP2_HEADER_EXPECT,
56
- HTTP2_HEADER_STATUS
60
+ HTTP2_HEADER_STATUS,
61
+ HTTP2_HEADER_PROTOCOL,
62
+ NGHTTP2_REFUSED_STREAM,
63
+ NGHTTP2_CANCEL
57
64
  }
58
65
  } = http2
59
66
 
@@ -93,12 +100,21 @@ function connectH2 (client, socket) {
93
100
  session[kClient] = client
94
101
  session[kSocket] = socket
95
102
  session[kHTTP2Session] = null
103
+ // We set it to true by default in a best-effort; however once connected to an H2 server
104
+ // we will check if extended CONNECT protocol is supported or not
105
+ // and set this value accordingly.
106
+ session[kEnableConnectProtocol] = false
107
+ // States whether or not we have received the remote settings from the server
108
+ session[kRemoteSettings] = false
96
109
 
97
110
  util.addListener(session, 'error', onHttp2SessionError)
98
111
  util.addListener(session, 'frameError', onHttp2FrameError)
99
112
  util.addListener(session, 'end', onHttp2SessionEnd)
100
113
  util.addListener(session, 'goaway', onHttp2SessionGoAway)
101
114
  util.addListener(session, 'close', onHttp2SessionClose)
115
+ util.addListener(session, 'remoteSettings', onHttp2RemoteSettings)
116
+ // TODO (@metcoder95): implement SETTINGS support
117
+ // util.addListener(session, 'localSettings', onHttp2RemoteSettings)
102
118
 
103
119
  session.unref()
104
120
 
@@ -115,12 +131,23 @@ function connectH2 (client, socket) {
115
131
  return {
116
132
  version: 'h2',
117
133
  defaultPipelining: Infinity,
134
+ /**
135
+ * @param {import('../core/request.js')} request
136
+ * @returns {boolean}
137
+ */
118
138
  write (request) {
119
139
  return writeH2(client, request)
120
140
  },
141
+ /**
142
+ * @returns {void}
143
+ */
121
144
  resume () {
122
145
  resumeH2(client)
123
146
  },
147
+ /**
148
+ * @param {Error | null} err
149
+ * @param {() => void} callback
150
+ */
124
151
  destroy (err, callback) {
125
152
  if (socket[kClosed]) {
126
153
  queueMicrotask(callback)
@@ -128,10 +155,43 @@ function connectH2 (client, socket) {
128
155
  socket.destroy(err).on('close', callback)
129
156
  }
130
157
  },
158
+ /**
159
+ * @type {boolean}
160
+ */
131
161
  get destroyed () {
132
162
  return socket.destroyed
133
163
  },
134
- busy () {
164
+ /**
165
+ * @param {import('../core/request.js')} request
166
+ * @returns {boolean}
167
+ */
168
+ busy (request) {
169
+ if (request != null) {
170
+ if (client[kRunning] > 0) {
171
+ // We are already processing requests
172
+
173
+ // Non-idempotent request cannot be retried.
174
+ // Ensure that no other requests are inflight and
175
+ // could cause failure.
176
+ if (request.idempotent === false) return true
177
+ // Don't dispatch an upgrade until all preceding requests have completed.
178
+ // Possibly, we do not have remote settings confirmed yet.
179
+ if ((request.upgrade === 'websocket' || request.method === 'CONNECT') && session[kRemoteSettings] === false) return true
180
+ // Request with stream or iterator body can error while other requests
181
+ // are inflight and indirectly error those as well.
182
+ // Ensure this doesn't happen by waiting for inflight
183
+ // to complete before dispatching.
184
+
185
+ // Request with stream or iterator body cannot be retried.
186
+ // Ensure that no other requests are inflight and
187
+ // could cause failure.
188
+ if (util.bodyLength(request.body) !== 0 &&
189
+ (util.isStream(request.body) || util.isAsyncIterable(request.body) || util.isFormDataLike(request.body))) return true
190
+ } else {
191
+ return (request.upgrade === 'websocket' || request.method === 'CONNECT') && session[kRemoteSettings] === false
192
+ }
193
+ }
194
+
135
195
  return false
136
196
  }
137
197
  }
@@ -151,6 +211,27 @@ function resumeH2 (client) {
151
211
  }
152
212
  }
153
213
 
214
+ function onHttp2RemoteSettings (settings) {
215
+ // Fallbacks are a safe bet, remote setting will always override
216
+ this[kClient][kMaxConcurrentStreams] = settings.maxConcurrentStreams ?? this[kClient][kMaxConcurrentStreams]
217
+ /**
218
+ * From RFC-8441
219
+ * A sender MUST NOT send a SETTINGS_ENABLE_CONNECT_PROTOCOL parameter
220
+ * with the value of 0 after previously sending a value of 1.
221
+ */
222
+ // Note: Cannot be tested in Node, it does not supports disabling the extended CONNECT protocol once enabled
223
+ if (this[kRemoteSettings] === true && this[kEnableConnectProtocol] === true && settings.enableConnectProtocol === false) {
224
+ const err = new InformationalError('HTTP/2: Server disabled extended CONNECT protocol against RFC-8441')
225
+ this[kSocket][kError] = err
226
+ this[kClient][kOnError](err)
227
+ return
228
+ }
229
+
230
+ this[kEnableConnectProtocol] = settings.enableConnectProtocol ?? this[kEnableConnectProtocol]
231
+ this[kRemoteSettings] = true
232
+ this[kClient][kResume]()
233
+ }
234
+
154
235
  function onHttp2SessionError (err) {
155
236
  assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID')
156
237
 
@@ -282,8 +363,8 @@ function writeH2 (client, request) {
282
363
  const { method, path, host, upgrade, expectContinue, signal, protocol, headers: reqHeaders } = request
283
364
  let { body } = request
284
365
 
285
- if (upgrade) {
286
- util.errorRequest(client, request, new Error('Upgrade not supported for H2'))
366
+ if (upgrade != null && upgrade !== 'websocket') {
367
+ util.errorRequest(client, request, new InvalidArgumentError(`Custom upgrade "${upgrade}" not supported over HTTP/2`))
287
368
  return false
288
369
  }
289
370
 
@@ -364,26 +445,75 @@ function writeH2 (client, request) {
364
445
  return false
365
446
  }
366
447
 
367
- if (method === 'CONNECT') {
448
+ if (upgrade || method === 'CONNECT') {
368
449
  session.ref()
369
- // We are already connected, streams are pending, first request
450
+
451
+ if (upgrade === 'websocket') {
452
+ // We cannot upgrade to websocket if extended CONNECT protocol is not supported
453
+ if (session[kEnableConnectProtocol] === false) {
454
+ util.errorRequest(client, request, new InformationalError('HTTP/2: Extended CONNECT protocol not supported by server'))
455
+ session.unref()
456
+ return false
457
+ }
458
+
459
+ // We force the method to CONNECT
460
+ // as per RFC-8441
461
+ // https://datatracker.ietf.org/doc/html/rfc8441#section-4
462
+ headers[HTTP2_HEADER_METHOD] = 'CONNECT'
463
+ headers[HTTP2_HEADER_PROTOCOL] = 'websocket'
464
+ // :path and :scheme headers must be omitted when sending CONNECT but set if extended-CONNECT
465
+ headers[HTTP2_HEADER_PATH] = path
466
+
467
+ if (protocol === 'ws:' || protocol === 'wss:') {
468
+ headers[HTTP2_HEADER_SCHEME] = protocol === 'ws:' ? 'http' : 'https'
469
+ } else {
470
+ headers[HTTP2_HEADER_SCHEME] = protocol === 'http:' ? 'http' : 'https'
471
+ }
472
+
473
+ stream = session.request(headers, { endStream: false, signal })
474
+ stream[kHTTP2Stream] = true
475
+
476
+ stream.once('response', (headers, _flags) => {
477
+ const { [HTTP2_HEADER_STATUS]: statusCode, ...realHeaders } = headers
478
+
479
+ request.onUpgrade(statusCode, parseH2Headers(realHeaders), stream)
480
+
481
+ ++session[kOpenStreams]
482
+ client[kQueue][client[kRunningIdx]++] = null
483
+ })
484
+
485
+ stream.on('error', () => {
486
+ if (stream.rstCode === NGHTTP2_REFUSED_STREAM || stream.rstCode === NGHTTP2_CANCEL) {
487
+ // NGHTTP2_REFUSED_STREAM (7) or NGHTTP2_CANCEL (8)
488
+ // We do not treat those as errors as the server might
489
+ // not support websockets and refuse the stream
490
+ abort(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`))
491
+ }
492
+ })
493
+
494
+ stream.once('close', () => {
495
+ session[kOpenStreams] -= 1
496
+ if (session[kOpenStreams] === 0) session.unref()
497
+ })
498
+
499
+ stream.setTimeout(requestTimeout)
500
+ return true
501
+ }
502
+
503
+ // TODO: consolidate once we support CONNECT properly
504
+ // NOTE: We are already connected, streams are pending, first request
370
505
  // will create a new stream. We trigger a request to create the stream and wait until
371
506
  // `ready` event is triggered
372
507
  // We disabled endStream to allow the user to write to the stream
373
508
  stream = session.request(headers, { endStream: false, signal })
509
+ stream[kHTTP2Stream] = true
510
+ stream.on('response', headers => {
511
+ const { [HTTP2_HEADER_STATUS]: statusCode, ...realHeaders } = headers
374
512
 
375
- if (!stream.pending) {
376
- request.onUpgrade(null, null, stream)
513
+ request.onUpgrade(statusCode, parseH2Headers(realHeaders), stream)
377
514
  ++session[kOpenStreams]
378
515
  client[kQueue][client[kRunningIdx]++] = null
379
- } else {
380
- stream.once('ready', () => {
381
- request.onUpgrade(null, null, stream)
382
- ++session[kOpenStreams]
383
- client[kQueue][client[kRunningIdx]++] = null
384
- })
385
- }
386
-
516
+ })
387
517
  stream.once('close', () => {
388
518
  session[kOpenStreams] -= 1
389
519
  if (session[kOpenStreams] === 0) session.unref()
@@ -395,7 +525,6 @@ function writeH2 (client, request) {
395
525
 
396
526
  // https://tools.ietf.org/html/rfc7540#section-8.3
397
527
  // :path and :scheme headers must be omitted when sending CONNECT
398
-
399
528
  headers[HTTP2_HEADER_PATH] = path
400
529
  headers[HTTP2_HEADER_SCHEME] = protocol === 'http:' ? 'http' : 'https'
401
530
 
@@ -435,12 +564,12 @@ function writeH2 (client, request) {
435
564
  contentLength = request.contentLength
436
565
  }
437
566
 
438
- if (contentLength === 0 || !expectsPayload) {
567
+ if (!expectsPayload) {
439
568
  // https://tools.ietf.org/html/rfc7230#section-3.3.2
440
569
  // A user agent SHOULD NOT send a Content-Length header field when
441
570
  // the request message does not contain a payload body and the method
442
571
  // semantics do not anticipate such a body.
443
-
572
+ // And for methods that don't expect a payload, omit Content-Length.
444
573
  contentLength = null
445
574
  }
446
575
 
@@ -456,7 +585,7 @@ function writeH2 (client, request) {
456
585
  }
457
586
 
458
587
  if (contentLength != null) {
459
- assert(body, 'no body must not have content length')
588
+ assert(body || contentLength === 0, 'no body must not have content length')
460
589
  headers[HTTP2_HEADER_CONTENT_LENGTH] = `${contentLength}`
461
590
  }
462
591
 
@@ -475,6 +604,7 @@ function writeH2 (client, request) {
475
604
  if (expectContinue) {
476
605
  headers[HTTP2_HEADER_EXPECT] = '100-continue'
477
606
  stream = session.request(headers, { endStream: shouldEndStream, signal })
607
+ stream[kHTTP2Stream] = true
478
608
 
479
609
  stream.once('continue', writeBodyH2)
480
610
  } else {
@@ -482,6 +612,7 @@ function writeH2 (client, request) {
482
612
  endStream: shouldEndStream,
483
613
  signal
484
614
  })
615
+ stream[kHTTP2Stream] = true
485
616
 
486
617
  writeBodyH2()
487
618
  }
@@ -590,7 +721,6 @@ function writeH2 (client, request) {
590
721
  return true
591
722
 
592
723
  function writeBodyH2 () {
593
- /* istanbul ignore else: assertion */
594
724
  if (!body || contentLength === 0) {
595
725
  writeBuffer(
596
726
  abort,
@@ -107,7 +107,8 @@ class Client extends DispatcherBase {
107
107
  autoSelectFamilyAttemptTimeout,
108
108
  // h2
109
109
  maxConcurrentStreams,
110
- allowH2
110
+ allowH2,
111
+ useH2c
111
112
  } = {}) {
112
113
  if (keepAlive !== undefined) {
113
114
  throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead')
@@ -199,6 +200,10 @@ class Client extends DispatcherBase {
199
200
  throw new InvalidArgumentError('maxConcurrentStreams must be a positive integer, greater than 0')
200
201
  }
201
202
 
203
+ if (useH2c != null && typeof useH2c !== 'boolean') {
204
+ throw new InvalidArgumentError('useH2c must be a valid boolean value')
205
+ }
206
+
202
207
  super()
203
208
 
204
209
  if (typeof connect !== 'function') {
@@ -206,6 +211,7 @@ class Client extends DispatcherBase {
206
211
  ...tls,
207
212
  maxCachedSessions,
208
213
  allowH2,
214
+ useH2c,
209
215
  socketPath,
210
216
  timeout: connectTimeout,
211
217
  ...(typeof autoSelectFamily === 'boolean' ? { autoSelectFamily, autoSelectFamilyAttemptTimeout } : undefined),
@@ -289,7 +295,6 @@ class Client extends DispatcherBase {
289
295
  )
290
296
  }
291
297
 
292
- /* istanbul ignore: only used for test */
293
298
  [kConnect] (cb) {
294
299
  connect(this)
295
300
  this.once('connect', cb)
@@ -16,14 +16,14 @@ class DispatcherBase extends Dispatcher {
16
16
  /** @type {boolean} */
17
17
  [kDestroyed] = false;
18
18
 
19
- /** @type {Array|null} */
19
+ /** @type {Array<Function|null} */
20
20
  [kOnDestroyed] = null;
21
21
 
22
22
  /** @type {boolean} */
23
23
  [kClosed] = false;
24
24
 
25
- /** @type {Array} */
26
- [kOnClosed] = []
25
+ /** @type {Array<Function>|null} */
26
+ [kOnClosed] = null
27
27
 
28
28
  /** @returns {boolean} */
29
29
  get destroyed () {
@@ -49,7 +49,8 @@ class DispatcherBase extends Dispatcher {
49
49
  }
50
50
 
51
51
  if (this[kDestroyed]) {
52
- queueMicrotask(() => callback(new ClientDestroyedError(), null))
52
+ const err = new ClientDestroyedError()
53
+ queueMicrotask(() => callback(err, null))
53
54
  return
54
55
  }
55
56
 
@@ -63,6 +64,7 @@ class DispatcherBase extends Dispatcher {
63
64
  }
64
65
 
65
66
  this[kClosed] = true
67
+ this[kOnClosed] ??= []
66
68
  this[kOnClosed].push(callback)
67
69
 
68
70
  const onClosed = () => {
@@ -76,9 +78,7 @@ class DispatcherBase extends Dispatcher {
76
78
  // Should not error.
77
79
  this[kClose]()
78
80
  .then(() => this.destroy())
79
- .then(() => {
80
- queueMicrotask(onClosed)
81
- })
81
+ .then(() => queueMicrotask(onClosed))
82
82
  }
83
83
 
84
84
  destroy (err, callback) {
@@ -90,7 +90,7 @@ class DispatcherBase extends Dispatcher {
90
90
  if (callback === undefined) {
91
91
  return new Promise((resolve, reject) => {
92
92
  this.destroy(err, (err, data) => {
93
- return err ? /* istanbul ignore next: should never error */ reject(err) : resolve(data)
93
+ return err ? reject(err) : resolve(data)
94
94
  })
95
95
  })
96
96
  }
@@ -113,7 +113,7 @@ class DispatcherBase extends Dispatcher {
113
113
  }
114
114
 
115
115
  this[kDestroyed] = true
116
- this[kOnDestroyed] = this[kOnDestroyed] || []
116
+ this[kOnDestroyed] ??= []
117
117
  this[kOnDestroyed].push(callback)
118
118
 
119
119
  const onDestroyed = () => {
@@ -125,9 +125,8 @@ class DispatcherBase extends Dispatcher {
125
125
  }
126
126
 
127
127
  // Should not error.
128
- this[kDestroy](err).then(() => {
129
- queueMicrotask(onDestroyed)
130
- })
128
+ this[kDestroy](err)
129
+ .then(() => queueMicrotask(onDestroyed))
131
130
  }
132
131
 
133
132
  dispatch (opts, handler) {