undici 7.15.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 (93) hide show
  1. package/README.md +48 -2
  2. package/docs/docs/api/Agent.md +1 -0
  3. package/docs/docs/api/Client.md +1 -0
  4. package/docs/docs/api/DiagnosticsChannel.md +57 -0
  5. package/docs/docs/api/Dispatcher.md +86 -0
  6. package/docs/docs/api/Errors.md +0 -1
  7. package/docs/docs/api/RoundRobinPool.md +145 -0
  8. package/docs/docs/api/WebSocket.md +21 -0
  9. package/docs/docs/best-practices/crawling.md +58 -0
  10. package/index-fetch.js +2 -2
  11. package/index.js +8 -9
  12. package/lib/api/api-request.js +22 -8
  13. package/lib/api/api-upgrade.js +2 -1
  14. package/lib/api/readable.js +7 -5
  15. package/lib/core/connect.js +4 -1
  16. package/lib/core/diagnostics.js +28 -1
  17. package/lib/core/errors.js +217 -13
  18. package/lib/core/request.js +5 -1
  19. package/lib/core/symbols.js +3 -0
  20. package/lib/core/util.js +61 -41
  21. package/lib/dispatcher/agent.js +19 -7
  22. package/lib/dispatcher/balanced-pool.js +10 -0
  23. package/lib/dispatcher/client-h1.js +18 -23
  24. package/lib/dispatcher/client-h2.js +166 -26
  25. package/lib/dispatcher/client.js +64 -59
  26. package/lib/dispatcher/dispatcher-base.js +20 -16
  27. package/lib/dispatcher/env-http-proxy-agent.js +12 -16
  28. package/lib/dispatcher/fixed-queue.js +15 -39
  29. package/lib/dispatcher/h2c-client.js +7 -78
  30. package/lib/dispatcher/pool-base.js +60 -43
  31. package/lib/dispatcher/pool.js +2 -2
  32. package/lib/dispatcher/proxy-agent.js +27 -11
  33. package/lib/dispatcher/round-robin-pool.js +137 -0
  34. package/lib/encoding/index.js +33 -0
  35. package/lib/global.js +19 -1
  36. package/lib/handler/cache-handler.js +84 -27
  37. package/lib/handler/deduplication-handler.js +216 -0
  38. package/lib/handler/retry-handler.js +0 -2
  39. package/lib/interceptor/cache.js +94 -15
  40. package/lib/interceptor/decompress.js +2 -1
  41. package/lib/interceptor/deduplicate.js +109 -0
  42. package/lib/interceptor/dns.js +55 -13
  43. package/lib/mock/mock-agent.js +4 -4
  44. package/lib/mock/mock-errors.js +10 -0
  45. package/lib/mock/mock-utils.js +13 -12
  46. package/lib/mock/snapshot-agent.js +11 -5
  47. package/lib/mock/snapshot-recorder.js +12 -4
  48. package/lib/mock/snapshot-utils.js +4 -4
  49. package/lib/util/cache.js +29 -1
  50. package/lib/util/date.js +534 -140
  51. package/lib/util/runtime-features.js +124 -0
  52. package/lib/web/cookies/index.js +1 -1
  53. package/lib/web/cookies/parse.js +1 -1
  54. package/lib/web/eventsource/eventsource-stream.js +2 -2
  55. package/lib/web/eventsource/eventsource.js +34 -29
  56. package/lib/web/eventsource/util.js +1 -9
  57. package/lib/web/fetch/body.js +45 -61
  58. package/lib/web/fetch/data-url.js +12 -160
  59. package/lib/web/fetch/formdata-parser.js +204 -127
  60. package/lib/web/fetch/index.js +21 -19
  61. package/lib/web/fetch/request.js +6 -0
  62. package/lib/web/fetch/response.js +4 -7
  63. package/lib/web/fetch/util.js +10 -79
  64. package/lib/web/infra/index.js +229 -0
  65. package/lib/web/subresource-integrity/subresource-integrity.js +6 -5
  66. package/lib/web/webidl/index.js +207 -44
  67. package/lib/web/websocket/connection.js +33 -22
  68. package/lib/web/websocket/events.js +1 -1
  69. package/lib/web/websocket/frame.js +9 -15
  70. package/lib/web/websocket/stream/websocketerror.js +22 -1
  71. package/lib/web/websocket/stream/websocketstream.js +17 -8
  72. package/lib/web/websocket/util.js +2 -1
  73. package/lib/web/websocket/websocket.js +32 -42
  74. package/package.json +9 -7
  75. package/types/agent.d.ts +2 -1
  76. package/types/api.d.ts +2 -2
  77. package/types/balanced-pool.d.ts +2 -1
  78. package/types/cache-interceptor.d.ts +1 -0
  79. package/types/client.d.ts +1 -1
  80. package/types/connector.d.ts +2 -2
  81. package/types/diagnostics-channel.d.ts +2 -2
  82. package/types/dispatcher.d.ts +12 -12
  83. package/types/errors.d.ts +5 -15
  84. package/types/fetch.d.ts +4 -4
  85. package/types/formdata.d.ts +1 -1
  86. package/types/h2c-client.d.ts +1 -1
  87. package/types/index.d.ts +9 -1
  88. package/types/interceptors.d.ts +36 -2
  89. package/types/pool.d.ts +1 -1
  90. package/types/readable.d.ts +2 -2
  91. package/types/round-robin-pool.d.ts +41 -0
  92. package/types/webidl.d.ts +82 -21
  93. package/types/websocket.d.ts +9 -9
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const { InvalidArgumentError } = require('../core/errors')
3
+ const { InvalidArgumentError, MaxOriginsReachedError } = require('../core/errors')
4
4
  const { kClients, kRunning, kClose, kDestroy, kDispatch, kUrl } = require('../core/symbols')
5
5
  const DispatcherBase = require('./dispatcher-base')
6
6
  const Pool = require('./pool')
@@ -13,6 +13,7 @@ const kOnConnectionError = Symbol('onConnectionError')
13
13
  const kOnDrain = Symbol('onDrain')
14
14
  const kFactory = Symbol('factory')
15
15
  const kOptions = Symbol('options')
16
+ const kOrigins = Symbol('origins')
16
17
 
17
18
  function defaultFactory (origin, opts) {
18
19
  return opts && opts.connections === 1
@@ -21,7 +22,7 @@ function defaultFactory (origin, opts) {
21
22
  }
22
23
 
23
24
  class Agent extends DispatcherBase {
24
- constructor ({ factory = defaultFactory, connect, ...options } = {}) {
25
+ constructor ({ factory = defaultFactory, maxOrigins = Infinity, connect, ...options } = {}) {
25
26
  if (typeof factory !== 'function') {
26
27
  throw new InvalidArgumentError('factory must be a function.')
27
28
  }
@@ -30,15 +31,20 @@ class Agent extends DispatcherBase {
30
31
  throw new InvalidArgumentError('connect must be a function or an object')
31
32
  }
32
33
 
34
+ if (typeof maxOrigins !== 'number' || Number.isNaN(maxOrigins) || maxOrigins <= 0) {
35
+ throw new InvalidArgumentError('maxOrigins must be a number greater than 0')
36
+ }
37
+
33
38
  super()
34
39
 
35
40
  if (connect && typeof connect !== 'function') {
36
41
  connect = { ...connect }
37
42
  }
38
43
 
39
- this[kOptions] = { ...util.deepClone(options), connect }
44
+ this[kOptions] = { ...util.deepClone(options), maxOrigins, connect }
40
45
  this[kFactory] = factory
41
46
  this[kClients] = new Map()
47
+ this[kOrigins] = new Set()
42
48
 
43
49
  this[kOnDrain] = (origin, targets) => {
44
50
  this.emit('drain', origin, [this, ...targets])
@@ -73,6 +79,10 @@ class Agent extends DispatcherBase {
73
79
  throw new InvalidArgumentError('opts.origin must be a non-empty string or URL.')
74
80
  }
75
81
 
82
+ if (this[kOrigins].size >= this[kOptions].maxOrigins && !this[kOrigins].has(key)) {
83
+ throw new MaxOriginsReachedError()
84
+ }
85
+
76
86
  const result = this[kClients].get(key)
77
87
  let dispatcher = result && result.dispatcher
78
88
  if (!dispatcher) {
@@ -84,6 +94,7 @@ class Agent extends DispatcherBase {
84
94
  this[kClients].delete(key)
85
95
  result.dispatcher.close()
86
96
  }
97
+ this[kOrigins].delete(key)
87
98
  }
88
99
  }
89
100
  dispatcher = this[kFactory](opts.origin, this[kOptions])
@@ -105,29 +116,30 @@ class Agent extends DispatcherBase {
105
116
  })
106
117
 
107
118
  this[kClients].set(key, { count: 0, dispatcher })
119
+ this[kOrigins].add(key)
108
120
  }
109
121
 
110
122
  return dispatcher.dispatch(opts, handler)
111
123
  }
112
124
 
113
- async [kClose] () {
125
+ [kClose] () {
114
126
  const closePromises = []
115
127
  for (const { dispatcher } of this[kClients].values()) {
116
128
  closePromises.push(dispatcher.close())
117
129
  }
118
130
  this[kClients].clear()
119
131
 
120
- await Promise.all(closePromises)
132
+ return Promise.all(closePromises)
121
133
  }
122
134
 
123
- async [kDestroy] (err) {
135
+ [kDestroy] (err) {
124
136
  const destroyPromises = []
125
137
  for (const { dispatcher } of this[kClients].values()) {
126
138
  destroyPromises.push(dispatcher.destroy(err))
127
139
  }
128
140
  this[kClients].clear()
129
141
 
130
- await Promise.all(destroyPromises)
142
+ return Promise.all(destroyPromises)
131
143
  }
132
144
 
133
145
  get stats () {
@@ -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)
@@ -64,11 +64,24 @@ function lazyllhttp () {
64
64
  const llhttpWasmData = process.env.JEST_WORKER_ID ? require('../llhttp/llhttp-wasm.js') : undefined
65
65
 
66
66
  let mod
67
- try {
68
- mod = new WebAssembly.Module(require('../llhttp/llhttp_simd-wasm.js'))
69
- } catch {
70
- /* istanbul ignore next */
71
67
 
68
+ // We disable wasm SIMD on ppc64 as it seems to be broken on Power 9 architectures.
69
+ let useWasmSIMD = process.arch !== 'ppc64'
70
+ // The Env Variable UNDICI_NO_WASM_SIMD allows explicitly overriding the default behavior
71
+ if (process.env.UNDICI_NO_WASM_SIMD === '1') {
72
+ useWasmSIMD = true
73
+ } else if (process.env.UNDICI_NO_WASM_SIMD === '0') {
74
+ useWasmSIMD = false
75
+ }
76
+
77
+ if (useWasmSIMD) {
78
+ try {
79
+ mod = new WebAssembly.Module(require('../llhttp/llhttp_simd-wasm.js'))
80
+ } catch {
81
+ }
82
+ }
83
+
84
+ if (!mod) {
72
85
  // We could check if the error was caused by the simd option not
73
86
  // being enabled, but the occurring of this other error
74
87
  // * https://github.com/emscripten-core/emscripten/issues/11495
@@ -85,7 +98,6 @@ function lazyllhttp () {
85
98
  * @returns {number}
86
99
  */
87
100
  wasm_on_url: (p, at, len) => {
88
- /* istanbul ignore next */
89
101
  return 0
90
102
  },
91
103
  /**
@@ -250,7 +262,6 @@ class Parser {
250
262
 
251
263
  this.timeoutValue = delay
252
264
  } else if (this.timeout) {
253
- // istanbul ignore else: only for jest
254
265
  if (this.timeout.refresh) {
255
266
  this.timeout.refresh()
256
267
  }
@@ -271,7 +282,6 @@ class Parser {
271
282
 
272
283
  assert(this.timeoutType === TIMEOUT_BODY)
273
284
  if (this.timeout) {
274
- // istanbul ignore else: only for jest
275
285
  if (this.timeout.refresh) {
276
286
  this.timeout.refresh()
277
287
  }
@@ -325,10 +335,6 @@ class Parser {
325
335
  currentBufferRef = chunk
326
336
  currentParser = this
327
337
  ret = llhttp.llhttp_execute(this.ptr, currentBufferPtr, chunk.length)
328
- /* eslint-disable-next-line no-useless-catch */
329
- } catch (err) {
330
- /* istanbul ignore next: difficult to make a test case for */
331
- throw err
332
338
  } finally {
333
339
  currentParser = null
334
340
  currentBufferRef = null
@@ -345,7 +351,6 @@ class Parser {
345
351
  } else {
346
352
  const ptr = llhttp.llhttp_get_error_reason(this.ptr)
347
353
  let message = ''
348
- /* istanbul ignore else: difficult to make a test case for */
349
354
  if (ptr) {
350
355
  const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0)
351
356
  message =
@@ -391,7 +396,6 @@ class Parser {
391
396
  onMessageBegin () {
392
397
  const { socket, client } = this
393
398
 
394
- /* istanbul ignore next: difficult to make a test case for */
395
399
  if (socket.destroyed) {
396
400
  return -1
397
401
  }
@@ -520,14 +524,12 @@ class Parser {
520
524
  onHeadersComplete (statusCode, upgrade, shouldKeepAlive) {
521
525
  const { client, socket, headers, statusText } = this
522
526
 
523
- /* istanbul ignore next: difficult to make a test case for */
524
527
  if (socket.destroyed) {
525
528
  return -1
526
529
  }
527
530
 
528
531
  const request = client[kQueue][client[kRunningIdx]]
529
532
 
530
- /* istanbul ignore next: difficult to make a test case for */
531
533
  if (!request) {
532
534
  return -1
533
535
  }
@@ -561,7 +563,6 @@ class Parser {
561
563
  : client[kBodyTimeout]
562
564
  this.setTimeout(bodyTimeout, TIMEOUT_BODY)
563
565
  } else if (this.timeout) {
564
- // istanbul ignore else: only for jest
565
566
  if (this.timeout.refresh) {
566
567
  this.timeout.refresh()
567
568
  }
@@ -642,7 +643,6 @@ class Parser {
642
643
 
643
644
  assert(this.timeoutType === TIMEOUT_BODY)
644
645
  if (this.timeout) {
645
- // istanbul ignore else: only for jest
646
646
  if (this.timeout.refresh) {
647
647
  this.timeout.refresh()
648
648
  }
@@ -698,7 +698,6 @@ class Parser {
698
698
  return 0
699
699
  }
700
700
 
701
- /* istanbul ignore next: should be handled by llhttp? */
702
701
  if (request.method !== 'HEAD' && contentLength && bytesRead !== parseInt(contentLength, 10)) {
703
702
  util.destroy(socket, new ResponseContentLengthMismatchError())
704
703
  return -1
@@ -739,7 +738,6 @@ class Parser {
739
738
  function onParserTimeout (parser) {
740
739
  const { socket, timeoutType, client, paused } = parser.deref()
741
740
 
742
- /* istanbul ignore else */
743
741
  if (timeoutType === TIMEOUT_HEADERS) {
744
742
  if (!socket[kWriting] || socket.writableNeedDrain || client[kRunning] > 1) {
745
743
  assert(!paused, 'cannot be paused while waiting for headers')
@@ -760,7 +758,7 @@ function onParserTimeout (parser) {
760
758
  * @param {import('net').Socket} socket
761
759
  * @returns
762
760
  */
763
- async function connectH1 (client, socket) {
761
+ function connectH1 (client, socket) {
764
762
  client[kSocket] = socket
765
763
 
766
764
  if (!llhttpInstance) {
@@ -1146,7 +1144,6 @@ function writeH1 (client, request) {
1146
1144
  channels.sendHeaders.publish({ request, headers: header, socket })
1147
1145
  }
1148
1146
 
1149
- /* istanbul ignore else: assertion */
1150
1147
  if (!body || bodyLength === 0) {
1151
1148
  writeBuffer(abort, null, client, request, socket, contentLength, header, expectsPayload)
1152
1149
  } else if (util.isBuffer(body)) {
@@ -1527,7 +1524,6 @@ class AsyncWriter {
1527
1524
 
1528
1525
  if (!ret) {
1529
1526
  if (socket[kParser].timeout && socket[kParser].timeoutType === TIMEOUT_HEADERS) {
1530
- // istanbul ignore else: only for jest
1531
1527
  if (socket[kParser].timeout.refresh) {
1532
1528
  socket[kParser].timeout.refresh()
1533
1529
  }
@@ -1578,7 +1574,6 @@ class AsyncWriter {
1578
1574
  }
1579
1575
 
1580
1576
  if (socket[kParser].timeout && socket[kParser].timeoutType === TIMEOUT_HEADERS) {
1581
- // istanbul ignore else: only for jest
1582
1577
  if (socket[kParser].timeout.refresh) {
1583
1578
  socket[kParser].timeout.refresh()
1584
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
 
@@ -77,7 +84,7 @@ function parseH2Headers (headers) {
77
84
  return result
78
85
  }
79
86
 
80
- async function connectH2 (client, socket) {
87
+ function connectH2 (client, socket) {
81
88
  client[kSocket] = socket
82
89
 
83
90
  const session = http2.connect(client[kUrl], {
@@ -93,12 +100,21 @@ async 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 @@ async 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 @@ async 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
 
@@ -279,11 +360,11 @@ function shouldSendContentLength (method) {
279
360
  function writeH2 (client, request) {
280
361
  const requestTimeout = request.bodyTimeout ?? client[kBodyTimeout]
281
362
  const session = client[kHTTP2Session]
282
- const { method, path, host, upgrade, expectContinue, signal, headers: reqHeaders } = request
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
 
@@ -292,6 +373,16 @@ function writeH2 (client, request) {
292
373
  const key = reqHeaders[n + 0]
293
374
  const val = reqHeaders[n + 1]
294
375
 
376
+ if (key === 'cookie') {
377
+ if (headers[key] != null) {
378
+ headers[key] = Array.isArray(headers[key]) ? (headers[key].push(val), headers[key]) : [headers[key], val]
379
+ } else {
380
+ headers[key] = val
381
+ }
382
+
383
+ continue
384
+ }
385
+
295
386
  if (Array.isArray(val)) {
296
387
  for (let i = 0; i < val.length; i++) {
297
388
  if (headers[key]) {
@@ -354,26 +445,75 @@ function writeH2 (client, request) {
354
445
  return false
355
446
  }
356
447
 
357
- if (method === 'CONNECT') {
448
+ if (upgrade || method === 'CONNECT') {
358
449
  session.ref()
359
- // 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
360
505
  // will create a new stream. We trigger a request to create the stream and wait until
361
506
  // `ready` event is triggered
362
507
  // We disabled endStream to allow the user to write to the stream
363
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
364
512
 
365
- if (!stream.pending) {
366
- request.onUpgrade(null, null, stream)
513
+ request.onUpgrade(statusCode, parseH2Headers(realHeaders), stream)
367
514
  ++session[kOpenStreams]
368
515
  client[kQueue][client[kRunningIdx]++] = null
369
- } else {
370
- stream.once('ready', () => {
371
- request.onUpgrade(null, null, stream)
372
- ++session[kOpenStreams]
373
- client[kQueue][client[kRunningIdx]++] = null
374
- })
375
- }
376
-
516
+ })
377
517
  stream.once('close', () => {
378
518
  session[kOpenStreams] -= 1
379
519
  if (session[kOpenStreams] === 0) session.unref()
@@ -385,9 +525,8 @@ function writeH2 (client, request) {
385
525
 
386
526
  // https://tools.ietf.org/html/rfc7540#section-8.3
387
527
  // :path and :scheme headers must be omitted when sending CONNECT
388
-
389
528
  headers[HTTP2_HEADER_PATH] = path
390
- headers[HTTP2_HEADER_SCHEME] = 'https'
529
+ headers[HTTP2_HEADER_SCHEME] = protocol === 'http:' ? 'http' : 'https'
391
530
 
392
531
  // https://tools.ietf.org/html/rfc7231#section-4.3.1
393
532
  // https://tools.ietf.org/html/rfc7231#section-4.3.2
@@ -425,12 +564,12 @@ function writeH2 (client, request) {
425
564
  contentLength = request.contentLength
426
565
  }
427
566
 
428
- if (contentLength === 0 || !expectsPayload) {
567
+ if (!expectsPayload) {
429
568
  // https://tools.ietf.org/html/rfc7230#section-3.3.2
430
569
  // A user agent SHOULD NOT send a Content-Length header field when
431
570
  // the request message does not contain a payload body and the method
432
571
  // semantics do not anticipate such a body.
433
-
572
+ // And for methods that don't expect a payload, omit Content-Length.
434
573
  contentLength = null
435
574
  }
436
575
 
@@ -446,7 +585,7 @@ function writeH2 (client, request) {
446
585
  }
447
586
 
448
587
  if (contentLength != null) {
449
- assert(body, 'no body must not have content length')
588
+ assert(body || contentLength === 0, 'no body must not have content length')
450
589
  headers[HTTP2_HEADER_CONTENT_LENGTH] = `${contentLength}`
451
590
  }
452
591
 
@@ -465,6 +604,7 @@ function writeH2 (client, request) {
465
604
  if (expectContinue) {
466
605
  headers[HTTP2_HEADER_EXPECT] = '100-continue'
467
606
  stream = session.request(headers, { endStream: shouldEndStream, signal })
607
+ stream[kHTTP2Stream] = true
468
608
 
469
609
  stream.once('continue', writeBodyH2)
470
610
  } else {
@@ -472,6 +612,7 @@ function writeH2 (client, request) {
472
612
  endStream: shouldEndStream,
473
613
  signal
474
614
  })
615
+ stream[kHTTP2Stream] = true
475
616
 
476
617
  writeBodyH2()
477
618
  }
@@ -580,7 +721,6 @@ function writeH2 (client, request) {
580
721
  return true
581
722
 
582
723
  function writeBodyH2 () {
583
- /* istanbul ignore else: assertion */
584
724
  if (!body || contentLength === 0) {
585
725
  writeBuffer(
586
726
  abort,