undici 8.6.0 → 8.7.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.
@@ -62,12 +62,19 @@ added: v4.8.2
62
62
  * `origin` {URL} The proxy origin.
63
63
  * `opts` {Object} The resolved options for the dispatcher.
64
64
  * Returns: {Dispatcher}
65
- * `proxyTunnel` {boolean} Forces tunneling through the proxy. When `false`,
66
- requests where both the proxy and the endpoint use the insecure `http:`
67
- protocol are sent directly to the proxy with the absolute request URI rather
68
- than through a `CONNECT` tunnel, matching `curl` behavior. Secure
69
- connections always use a tunnel regardless of this option. **Default:**
70
- `true`.
65
+ * `proxyTunnel` {boolean} Forces tunneling through the proxy. By default,
66
+ Undici detects tunneling based on the request protocol. If the target
67
+ endpoint uses HTTPS, Undici establishes a `CONNECT` tunnel through the proxy
68
+ (after the TLS handshake to the proxy itself when the proxy URL is HTTPS).
69
+ If the target endpoint uses plain HTTP, Undici forwards the request to the
70
+ proxy using an HTTP/1.1 absolute-form request target (over TLS when the
71
+ proxy URL is HTTPS), as required by
72
+ [RFC 9112 §3.2.2](https://www.rfc-editor.org/rfc/rfc9112.html#name-absolute-form).
73
+ This non-tunneled forwarding path does not negotiate HTTP/2 with the proxy.
74
+ Set `proxyTunnel` to `true` to force tunneling for plain HTTP requests as
75
+ well. Currently, there is no way to facilitate HTTP/1.1 IP tunneling as
76
+ described in
77
+ [RFC 9484](https://www.rfc-editor.org/rfc/rfc9484.html#name-http-11-request).
71
78
 
72
79
  Throws an {InvalidArgumentError} when no proxy URI is provided, when
73
80
  `clientFactory` is not a function, or when both `auth` and `token` are supplied.
@@ -102,7 +102,7 @@ setGlobalDispatcher(mockAgent)
102
102
  // this call is made (not intercepted)
103
103
  await fetch(`http://localhost:3000/endpoint?query='hello'`, {
104
104
  method: 'POST',
105
- headers: { 'content-type': 'application/json' }
105
+ headers: { 'content-type': 'application/json' },
106
106
  body: JSON.stringify({ data: '' })
107
107
  })
108
108
 
@@ -587,6 +587,10 @@ function consumeEnd (consume, encoding) {
587
587
  * @returns {void}
588
588
  */
589
589
  function consumePush (consume, chunk) {
590
+ if (consume.body === null) {
591
+ return
592
+ }
593
+
590
594
  consume.length += chunk.length
591
595
  consume.body.push(chunk)
592
596
  }
package/lib/core/util.js CHANGED
@@ -919,11 +919,32 @@ function onConnectTimeout (socket, opts) {
919
919
  destroy(socket, new ConnectTimeoutError(message))
920
920
  }
921
921
 
922
+ let lastUrlString = null
923
+ let lastProtocol = null
924
+
922
925
  /**
923
926
  * @param {string} urlString
924
927
  * @returns {string}
925
928
  */
926
929
  function getProtocolFromUrlString (urlString) {
930
+ // Requests are typically dispatched against the same origin over and over,
931
+ // so cache the last (urlString, protocol) pair to skip re-parsing.
932
+ if (urlString === lastUrlString) {
933
+ return lastProtocol
934
+ }
935
+
936
+ const protocol = getProtocolFromUrlStringSlow(urlString)
937
+ lastUrlString = urlString
938
+ lastProtocol = protocol
939
+
940
+ return protocol
941
+ }
942
+
943
+ /**
944
+ * @param {string} urlString
945
+ * @returns {string}
946
+ */
947
+ function getProtocolFromUrlStringSlow (urlString) {
927
948
  if (
928
949
  urlString[0] === 'h' &&
929
950
  urlString[1] === 't' &&
@@ -154,13 +154,23 @@ function requeueUnsentRequest (client, request) {
154
154
  }
155
155
 
156
156
  function completeRequest (client, request, resetPendingIdx = false) {
157
- const index = client[kQueue].indexOf(request, client[kRunningIdx])
157
+ const queue = client[kQueue]
158
+ const runningIdx = client[kRunningIdx]
159
+
160
+ // In-order completion: advance the running index instead of splicing.
161
+ // The client's resume loop compacts the queue once the index grows.
162
+ if (runningIdx < client[kPendingIdx] && queue[runningIdx] === request) {
163
+ client[kRunningIdx] = runningIdx + 1
164
+ return
165
+ }
166
+
167
+ const index = queue.indexOf(request, runningIdx)
158
168
 
159
169
  if (index === -1 || index >= client[kPendingIdx]) {
160
170
  return
161
171
  }
162
172
 
163
- client[kQueue].splice(index, 1)
173
+ queue.splice(index, 1)
164
174
  client[kPendingIdx]--
165
175
 
166
176
  if (resetPendingIdx && client[kPendingIdx] < client[kRunningIdx]) {
@@ -212,6 +222,11 @@ function connectH2 (client, socket) {
212
222
  session[kSocket] = socket
213
223
  session[kHTTP2SessionState] = {
214
224
  idleTimeout: null,
225
+ // Sockets start out ref'd. Session ref/unref proxies to the socket, so a
226
+ // single cached flag lets us skip redundant uv ref/unref calls, provided
227
+ // every ref/unref of the session or its socket goes through
228
+ // refH2Session/unrefH2Session.
229
+ refed: true,
215
230
  ping: {
216
231
  interval: client[kPingInterval] === 0 ? null : setInterval(onHttp2SendPing, client[kPingInterval], session).unref()
217
232
  }
@@ -238,7 +253,7 @@ function connectH2 (client, socket) {
238
253
  // TODO (@metcoder95): implement SETTINGS support
239
254
  // util.addListener(session, 'localSettings', onHttp2RemoteSettings)
240
255
 
241
- session.unref()
256
+ unrefH2Session(session)
242
257
 
243
258
  client[kHTTP2Session] = session
244
259
  socket[kHTTP2Session] = session
@@ -327,17 +342,36 @@ function connectH2 (client, socket) {
327
342
  }
328
343
  }
329
344
 
345
+ // Session ref/unref proxies to the underlying socket, so refH2Session and
346
+ // unrefH2Session cover both and can skip the call when the cached ref state
347
+ // already matches.
348
+ function refH2Session (session) {
349
+ const state = session[kHTTP2SessionState]
350
+
351
+ if (state.refed === false) {
352
+ state.refed = true
353
+ session.ref()
354
+ }
355
+ }
356
+
357
+ function unrefH2Session (session) {
358
+ const state = session[kHTTP2SessionState]
359
+
360
+ if (state.refed === true) {
361
+ state.refed = false
362
+ session.unref()
363
+ }
364
+ }
365
+
330
366
  function resumeH2 (client) {
331
367
  const socket = client[kSocket]
332
368
  const session = client[kHTTP2Session]
333
369
 
334
370
  if (socket?.destroyed === false) {
335
371
  if (client[kSize] === 0 || client[kMaxConcurrentStreams] === 0) {
336
- socket.unref()
337
- session.unref()
372
+ unrefH2Session(session)
338
373
  } else {
339
- socket.ref()
340
- session.ref()
374
+ refH2Session(session)
341
375
  }
342
376
 
343
377
  if (client[kSize] === 0 && session[kOpenStreams] === 0) {
@@ -639,7 +673,7 @@ function closeStreamSession (stream) {
639
673
  stream[kHTTP2Session] = null
640
674
  session[kOpenStreams] -= 1
641
675
  if (session[kOpenStreams] === 0) {
642
- session.unref()
676
+ unrefH2Session(session)
643
677
  setHttp2IdleTimeout(session)
644
678
  }
645
679
  }
@@ -664,12 +698,10 @@ function onRequestStreamClose () {
664
698
 
665
699
  if (state.pendingEnd && !state.request.aborted && !state.request.completed) {
666
700
  state.request.onResponseEnd(state.trailers || {})
667
- state.finalizeRequest()
701
+ finalizeRequest(state)
668
702
  }
669
703
  }
670
704
 
671
- this.off('data', onData)
672
- this.off('error', noop)
673
705
  closeStreamSession(this)
674
706
  this[kRequestStreamState] = null
675
707
  }
@@ -792,7 +824,7 @@ function onUpgradeResponse (headers, _flags) {
792
824
 
793
825
  removeUpgradeStreamListeners(stream)
794
826
  detachRequestFromStream(request)
795
- state.finalizeRequest()
827
+ finalizeRequest(state)
796
828
  }
797
829
 
798
830
  function setupUpgradeStream (stream, state) {
@@ -815,12 +847,51 @@ function setupUpgradeStream (stream, state) {
815
847
  stream.setTimeout(headersTimeout)
816
848
  }
817
849
 
850
+ function finalizeRequest (state, resetPendingIdx = false) {
851
+ if (state.requestFinalized) {
852
+ return
853
+ }
854
+
855
+ state.requestFinalized = true
856
+ completeRequest(state.client, state.request, resetPendingIdx)
857
+
858
+ state.client[kResume]()
859
+ }
860
+
861
+ function openStream (client, request, session, abort, headers, options) {
862
+ try {
863
+ return session.request(headers, options)
864
+ } catch (err) {
865
+ // A GOAWAY'd session rejects new streams, same as an invalid session:
866
+ // reset and requeue on a fresh connection rather than the destroy + abort
867
+ // below, whose destroy(socket, err) can crash via an unhandled 'error'.
868
+ if (err?.code === 'ERR_HTTP2_INVALID_SESSION' || err?.code === 'ERR_HTTP2_GOAWAY_SESSION') {
869
+ const wrappedErr = new SocketError(err.message, util.getSocketInfo(session[kSocket]))
870
+ wrappedErr.cause = err
871
+ session[kError] = wrappedErr
872
+ resetHttp2Session(session, wrappedErr)
873
+ requeueUnsentRequest(client, request)
874
+
875
+ return null
876
+ }
877
+
878
+ const wrappedErr = new InformationalError(err.message, { cause: err })
879
+ session[kError] = wrappedErr
880
+ session[kSocket][kError] = wrappedErr
881
+
882
+ session.destroy(wrappedErr)
883
+ util.destroy(session[kSocket], wrappedErr)
884
+ abort(wrappedErr)
885
+
886
+ return null
887
+ }
888
+ }
889
+
818
890
  function writeH2 (client, request) {
819
891
  const headersTimeout = request.headersTimeout ?? client[kHeadersTimeout]
820
892
  const bodyTimeout = request.bodyTimeout ?? client[kBodyTimeout]
821
893
  const session = client[kHTTP2Session]
822
894
  const { method, path, host, upgrade, expectContinue, signal, protocol, headers: reqHeaders } = request
823
- let { body } = request
824
895
 
825
896
  if (upgrade != null && upgrade !== 'websocket') {
826
897
  util.errorRequest(client, request, new InvalidArgumentError(`Custom upgrade "${upgrade}" not supported over HTTP/2`))
@@ -829,22 +900,28 @@ function writeH2 (client, request) {
829
900
 
830
901
  const headers = buildRequestHeaders(reqHeaders)
831
902
 
832
- /** @type {import('node:http2').ClientHttp2Stream} */
833
- let stream = null
834
-
835
903
  headers[HTTP2_HEADER_AUTHORITY] = host || client[kHostAuthority]
836
904
  headers[HTTP2_HEADER_METHOD] = method
837
905
 
838
- let requestFinalized = false
839
- const finalizeRequest = (resetPendingIdx = false) => {
840
- if (requestFinalized) {
841
- return
842
- }
843
-
844
- requestFinalized = true
845
- completeRequest(client, request, resetPendingIdx)
846
-
847
- client[kResume]()
906
+ // Single pre-shaped state object shared by all stream event handlers.
907
+ // All fields are declared up-front so the object keeps a stable hidden
908
+ // class for the whole request lifetime.
909
+ const state = {
910
+ abort: null,
911
+ body: request.body,
912
+ client,
913
+ contentLength: null,
914
+ expectsPayload: false,
915
+ request,
916
+ headersTimeout,
917
+ bodyTimeout,
918
+ requestFinalized: false,
919
+ responseReceived: false,
920
+ bodySent: false,
921
+ pendingEnd: false,
922
+ trailers: null,
923
+ session,
924
+ stream: null
848
925
  }
849
926
 
850
927
  const abort = (err, resetPendingIdx = false) => {
@@ -856,47 +933,39 @@ function writeH2 (client, request) {
856
933
 
857
934
  util.errorRequest(client, request, err)
858
935
 
859
- if (stream != null) {
936
+ if (state.stream != null) {
860
937
  clearRequestStream(request)
861
938
 
862
- // On Abort, we close the stream to send RST_STREAM frame
939
+ // On Abort, we close the stream to send RST_STREAM frame.
940
+ const stream = state.stream
863
941
  stream.close()
864
942
 
943
+ // close() alone leaves cleanup waiting on the 'close' event; on a busy,
944
+ // long-lived multiplexed session that event can fail to fire, leaving the
945
+ // native Http2Stream (and the whole request graph it pins) alive for the
946
+ // session's life. Destroy the stream to release the handle
947
+ // deterministically, but defer it by a setImmediate so the RST_STREAM
948
+ // frame queued by close() gets a chance to be written first.
949
+ setImmediate(() => {
950
+ if (!stream.destroyed) {
951
+ util.destroy(stream)
952
+ }
953
+ })
954
+
865
955
  // We move the running index to the next request
866
956
  client[kOnError](err)
867
- finalizeRequest(resetPendingIdx)
957
+ finalizeRequest(state, resetPendingIdx)
868
958
  }
869
959
 
870
960
  // We do not destroy the socket as we can continue using the session
871
961
  // the stream gets destroyed and the session remains to create new streams
872
- util.destroy(body, err)
962
+ util.destroy(state.body, err)
873
963
  }
874
964
 
875
- const requestStream = (headers, options) => {
876
- try {
877
- return session.request(headers, options)
878
- } catch (err) {
879
- if (err?.code === 'ERR_HTTP2_INVALID_SESSION') {
880
- const wrappedErr = new SocketError(err.message, util.getSocketInfo(session[kSocket]))
881
- wrappedErr.cause = err
882
- session[kError] = wrappedErr
883
- resetHttp2Session(session, wrappedErr)
884
- requeueUnsentRequest(client, request)
885
-
886
- return null
887
- }
888
-
889
- const wrappedErr = new InformationalError(err.message, { cause: err })
890
- session[kError] = wrappedErr
891
- session[kSocket][kError] = wrappedErr
965
+ state.abort = abort
892
966
 
893
- session.destroy(wrappedErr)
894
- util.destroy(session[kSocket], wrappedErr)
895
- abort(wrappedErr)
896
-
897
- return null
898
- }
899
- }
967
+ /** @type {import('node:http2').ClientHttp2Stream} */
968
+ let stream = null
900
969
 
901
970
  try {
902
971
  // We are already connected, streams are pending.
@@ -911,24 +980,13 @@ function writeH2 (client, request) {
911
980
  }
912
981
 
913
982
  if (upgrade || method === 'CONNECT') {
914
- session.ref()
915
-
916
- const upgradeState = {
917
- abort,
918
- finalizeRequest,
919
- request,
920
- headersTimeout,
921
- bodyTimeout,
922
- responseReceived: false,
923
- session,
924
- stream: null
925
- }
983
+ refH2Session(session)
926
984
 
927
985
  if (upgrade === 'websocket') {
928
986
  // We cannot upgrade to websocket if extended CONNECT protocol is not supported
929
987
  if (session[kEnableConnectProtocol] === false) {
930
988
  util.errorRequest(client, request, new InformationalError('HTTP/2: Extended CONNECT protocol not supported by server'))
931
- session.unref()
989
+ unrefH2Session(session)
932
990
  return false
933
991
  }
934
992
 
@@ -946,12 +1004,12 @@ function writeH2 (client, request) {
946
1004
  headers[HTTP2_HEADER_SCHEME] = protocol === 'http:' ? 'http' : 'https'
947
1005
  }
948
1006
 
949
- stream = requestStream(headers, { endStream: false, signal })
1007
+ stream = openStream(client, request, session, abort, headers, { endStream: false, signal })
950
1008
  if (stream == null) {
951
- session.unref()
1009
+ unrefH2Session(session)
952
1010
  return false
953
1011
  }
954
- setupUpgradeStream(stream, upgradeState)
1012
+ setupUpgradeStream(stream, state)
955
1013
  return true
956
1014
  }
957
1015
 
@@ -960,12 +1018,12 @@ function writeH2 (client, request) {
960
1018
  // will create a new stream. We trigger a request to create the stream and wait until
961
1019
  // `ready` event is triggered
962
1020
  // We disabled endStream to allow the user to write to the stream
963
- stream = requestStream(headers, { endStream: false, signal })
1021
+ stream = openStream(client, request, session, abort, headers, { endStream: false, signal })
964
1022
  if (stream == null) {
965
- session.unref()
1023
+ unrefH2Session(session)
966
1024
  return false
967
1025
  }
968
- setupUpgradeStream(stream, upgradeState)
1026
+ setupUpgradeStream(stream, state)
969
1027
 
970
1028
  return true
971
1029
  }
@@ -993,6 +1051,8 @@ function writeH2 (client, request) {
993
1051
  method === 'PROPPATCH'
994
1052
  )
995
1053
 
1054
+ let body = state.body
1055
+
996
1056
  if (body && typeof body.read === 'function') {
997
1057
  // Try to read EOF in order to get length.
998
1058
  body.read(0)
@@ -1039,7 +1099,7 @@ function writeH2 (client, request) {
1039
1099
  headers[HTTP2_HEADER_CONTENT_LENGTH] = `${contentLength}`
1040
1100
  }
1041
1101
 
1042
- session.ref()
1102
+ refH2Session(session)
1043
1103
 
1044
1104
  if (channels.sendHeaders.hasSubscribers) {
1045
1105
  let header = ''
@@ -1051,27 +1111,16 @@ function writeH2 (client, request) {
1051
1111
 
1052
1112
  // TODO(metcoder95): add support for sending trailers
1053
1113
  const shouldEndStream = body === null || contentLength === 0
1054
- const state = {
1055
- abort,
1056
- body,
1057
- client,
1058
- contentLength,
1059
- expectsPayload,
1060
- finalizeRequest,
1061
- request,
1062
- headersTimeout,
1063
- bodyTimeout,
1064
- responseReceived: false,
1065
- bodySent: false,
1066
- session,
1067
- stream: null
1068
- }
1114
+
1115
+ state.body = body
1116
+ state.contentLength = contentLength
1117
+ state.expectsPayload = expectsPayload
1069
1118
 
1070
1119
  if (expectContinue) {
1071
1120
  headers[HTTP2_HEADER_EXPECT] = '100-continue'
1072
1121
  }
1073
1122
 
1074
- stream = requestStream(headers, { endStream: shouldEndStream, signal })
1123
+ stream = openStream(client, request, session, abort, headers, { endStream: shouldEndStream, signal })
1075
1124
  if (stream == null) {
1076
1125
  return false
1077
1126
  }
@@ -1082,22 +1131,30 @@ function writeH2 (client, request) {
1082
1131
  // Increment counter as we have new streams open
1083
1132
  clearHttp2IdleTimeout(session)
1084
1133
  ++session[kOpenStreams]
1085
- stream.setTimeout(headersTimeout)
1134
+
1135
+ if (headersTimeout) {
1136
+ stream.setTimeout(headersTimeout)
1137
+ }
1086
1138
 
1087
1139
  stream[kHTTP2Session] = session
1088
- stream.once('close', onRequestStreamClose)
1140
+ stream.on('close', onRequestStreamClose)
1089
1141
 
1090
1142
  bindRequestToStream(request, stream, releaseRequestStream)
1091
1143
  if (expectContinue) {
1092
1144
  stream.once('continue', writeBodyH2)
1093
1145
  }
1094
- stream.once('response', onResponse)
1095
- stream.once('end', onEnd)
1096
- stream.once('error', onError)
1097
- stream.once('frameError', onFrameError)
1146
+ // The handlers below either remove themselves on first invocation or
1147
+ // become unreachable once the stream closes, so plain `on` avoids the
1148
+ // per-listener `once` wrapper allocation.
1149
+ stream.on('response', onResponse)
1150
+ stream.on('end', onEnd)
1151
+ stream.on('error', onError)
1152
+ stream.on('frameError', onFrameError)
1098
1153
  stream.on('aborted', onAborted)
1099
- stream.on('timeout', onTimeout)
1100
- stream.once('trailers', onTrailers)
1154
+ if (headersTimeout || bodyTimeout) {
1155
+ stream.on('timeout', onTimeout)
1156
+ }
1157
+ stream.on('trailers', onTrailers)
1101
1158
 
1102
1159
  if (!expectContinue) {
1103
1160
  writeBodyH2.call(stream)
@@ -1135,16 +1192,24 @@ function releaseRequestStream (stream) {
1135
1192
  detachRequestFromStream(request)
1136
1193
  }
1137
1194
 
1138
- removeRequestStreamListeners(stream)
1139
-
1195
+ // A closed or destroyed stream cannot emit further events; leaving the
1196
+ // listeners in place saves the removal scans (they are collected with
1197
+ // the stream). All handlers bail out when the stream state is gone.
1140
1198
  if (!stream.destroyed && !stream.closed) {
1199
+ removeRequestStreamListeners(stream)
1141
1200
  stream.once('error', noop)
1142
1201
  }
1143
1202
  }
1144
1203
 
1145
1204
  function onData (chunk) {
1146
1205
  const stream = this
1147
- const { request } = stream[kRequestStreamState]
1206
+ const state = stream[kRequestStreamState]
1207
+
1208
+ if (state == null) {
1209
+ return
1210
+ }
1211
+
1212
+ const { request } = state
1148
1213
 
1149
1214
  if (request.aborted || request.completed) {
1150
1215
  return
@@ -1158,6 +1223,11 @@ function onData (chunk) {
1158
1223
  function onResponse (headers) {
1159
1224
  const stream = this
1160
1225
  const state = stream[kRequestStreamState]
1226
+
1227
+ if (state == null) {
1228
+ return
1229
+ }
1230
+
1161
1231
  const { request } = state
1162
1232
 
1163
1233
  stream.off('response', onResponse)
@@ -1173,14 +1243,20 @@ function onResponse (headers) {
1173
1243
  delete headers[HTTP2_HEADER_STATUS]
1174
1244
  request.onResponseStarted()
1175
1245
  state.responseReceived = true
1176
- stream.setTimeout(state.bodyTimeout)
1246
+
1247
+ if (state.headersTimeout || state.bodyTimeout) {
1248
+ stream.setTimeout(state.bodyTimeout)
1249
+ }
1177
1250
 
1178
1251
  // Due to the stream nature, it is possible we face a race condition
1179
1252
  // where the stream has been assigned, but the request has been aborted
1180
- // the request remains in-flight and headers hasn't been received yet
1181
- // for those scenarios, best effort is to destroy the stream immediately
1182
- // as there's no value to keep it open.
1183
- if (request.aborted) {
1253
+ // or already completed and headers hasn't been received yet. A late
1254
+ // 'response' delivered after completion would call request.onResponseStart
1255
+ // post-completion, tripping its `assert(!this.completed)` (an uncatchable
1256
+ // throw on the http2 event tick). Guard `completed` here as onEnd/onTrailers
1257
+ // already do; best effort is to release the stream immediately as there's
1258
+ // no value to keep it open.
1259
+ if (request.aborted || request.completed) {
1184
1260
  releaseRequestStream(stream)
1185
1261
  return
1186
1262
  }
@@ -1195,6 +1271,11 @@ function onResponse (headers) {
1195
1271
  function onEnd () {
1196
1272
  const stream = this
1197
1273
  const state = stream[kRequestStreamState]
1274
+
1275
+ if (state == null) {
1276
+ return
1277
+ }
1278
+
1198
1279
  const { request } = state
1199
1280
 
1200
1281
  stream.off('end', onEnd)
@@ -1218,6 +1299,10 @@ function onError (err) {
1218
1299
  const stream = this
1219
1300
  const state = stream[kRequestStreamState]
1220
1301
 
1302
+ if (state == null) {
1303
+ return
1304
+ }
1305
+
1221
1306
  stream.off('error', onError)
1222
1307
  state.abort(err)
1223
1308
  }
@@ -1226,6 +1311,10 @@ function onFrameError (type, code) {
1226
1311
  const stream = this
1227
1312
  const state = stream[kRequestStreamState]
1228
1313
 
1314
+ if (state == null) {
1315
+ return
1316
+ }
1317
+
1229
1318
  stream.off('frameError', onFrameError)
1230
1319
  state.abort(new InformationalError(`HTTP/2: "frameError" received - type ${type}, code ${code}`))
1231
1320
  }
@@ -1238,6 +1327,10 @@ function onTimeout () {
1238
1327
  const stream = this
1239
1328
  const state = stream[kRequestStreamState]
1240
1329
 
1330
+ if (state == null) {
1331
+ return
1332
+ }
1333
+
1241
1334
  // Remove self so timeout doesn't fire again after we handle it
1242
1335
  stream.off('timeout', onTimeout)
1243
1336
 
@@ -1250,6 +1343,11 @@ function onTimeout () {
1250
1343
  function onTrailers (trailers) {
1251
1344
  const stream = this
1252
1345
  const state = stream[kRequestStreamState]
1346
+
1347
+ if (state == null) {
1348
+ return
1349
+ }
1350
+
1253
1351
  const { request } = state
1254
1352
 
1255
1353
  stream.off('trailers', onTrailers)
@@ -37,10 +37,15 @@ function defaultAgentFactory (origin, opts) {
37
37
  return new Pool(origin, opts)
38
38
  }
39
39
 
40
+ function shouldProxyTunnel (requestProtocol, proxyTunnel) {
41
+ return proxyTunnel === true || requestProtocol !== 'http:'
42
+ }
43
+
40
44
  class Http1ProxyWrapper extends DispatcherBase {
41
45
  #client
46
+ #proxyServername
42
47
 
43
- constructor (proxyUrl, { headers = {}, connect, factory }) {
48
+ constructor (proxyUrl, { headers = {}, connect, factory, proxyServername }) {
44
49
  if (!proxyUrl) {
45
50
  throw new InvalidArgumentError('Proxy URL is mandatory')
46
51
  }
@@ -48,6 +53,7 @@ class Http1ProxyWrapper extends DispatcherBase {
48
53
  super()
49
54
 
50
55
  this[kProxyHeaders] = headers
56
+ this.#proxyServername = proxyServername
51
57
  if (factory) {
52
58
  this.#client = factory(proxyUrl, { connect })
53
59
  } else {
@@ -82,6 +88,13 @@ class Http1ProxyWrapper extends DispatcherBase {
82
88
  }
83
89
  opts.headers = { ...this[kProxyHeaders], ...headers }
84
90
 
91
+ // Pin the SNI/cert hostname to the proxy. Without this the underlying
92
+ // Client would derive it from the (rewritten) Host header, which points
93
+ // at the target — wrong for the TLS handshake to the proxy itself.
94
+ if (this.#proxyServername != null) {
95
+ opts.servername = this.#proxyServername
96
+ }
97
+
85
98
  return this.#client[kDispatch](opts, handler)
86
99
  }
87
100
 
@@ -105,7 +118,7 @@ class ProxyAgent extends DispatcherBase {
105
118
  throw new InvalidArgumentError('Proxy opts.clientFactory must be a function.')
106
119
  }
107
120
 
108
- const { proxyTunnel = true, connectTimeout } = opts
121
+ const { proxyTunnel, connectTimeout } = opts
109
122
 
110
123
  super()
111
124
 
@@ -132,6 +145,7 @@ class ProxyAgent extends DispatcherBase {
132
145
  }
133
146
 
134
147
  const connect = buildConnector({ timeout: connectTimeout, ...opts.proxyTls })
148
+ const connectHTTP1 = buildConnector({ timeout: connectTimeout, ...opts.proxyTls, allowH2: false })
135
149
  this[kConnectEndpoint] = buildConnector({ timeout: connectTimeout, ...opts.requestTls })
136
150
  this[kConnectEndpointHTTP1] = buildConnector({ timeout: connectTimeout, ...opts.requestTls, allowH2: false })
137
151
 
@@ -152,11 +166,23 @@ class ProxyAgent extends DispatcherBase {
152
166
  })
153
167
  }
154
168
 
155
- if (!this[kTunnelProxy] && protocol === 'http:' && this[kProxy].protocol === 'http:') {
169
+ if (!shouldProxyTunnel(protocol, this[kTunnelProxy])) {
170
+ const forwardConnect = this[kProxy].protocol === 'https:'
171
+ ? (opts, cb) => connectHTTP1(opts, (err, socket) => {
172
+ if (err && err.code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
173
+ cb(new SecureProxyConnectionError(err))
174
+ } else {
175
+ cb(err, socket)
176
+ }
177
+ })
178
+ : connectHTTP1
156
179
  return new Http1ProxyWrapper(this[kProxy].uri, {
157
180
  headers: this[kProxyHeaders],
158
- connect,
159
- factory: agentFactory
181
+ connect: forwardConnect,
182
+ factory: agentFactory,
183
+ proxyServername: this[kProxy].protocol === 'https:'
184
+ ? (this[kProxyTls]?.servername || proxyHostname)
185
+ : undefined
160
186
  })
161
187
  }
162
188
  return agentFactory(origin, options)
@@ -178,8 +178,9 @@ function parseUnparsedAttributes (unparsedAttributes, cookieAttributeList = {})
178
178
 
179
179
  // 2. If the attribute-value failed to parse as a cookie date, ignore
180
180
  // the cookie-av.
181
-
182
- cookieAttributeList.expires = expiryTime
181
+ if (!Number.isNaN(expiryTime.getTime())) {
182
+ cookieAttributeList.expires = expiryTime
183
+ }
183
184
  } else if (attributeNameLowercase === 'max-age') {
184
185
  // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.2
185
186
  // If the attribute-name case-insensitively matches the string "Max-
@@ -105,7 +105,7 @@ function validateCookiePath (path) {
105
105
 
106
106
  if (
107
107
  code < 0x20 || // exclude CTLs (0-31)
108
- code === 0x7F || // DEL
108
+ code > 0x7E || // exclude non-ascii and DEL
109
109
  code === 0x3B // ;
110
110
  ) {
111
111
  throw new Error('Invalid cookie path')
@@ -49,7 +49,7 @@ function createPotentialCORSRequest (url, destination, corsAttributeState, sameO
49
49
  destination,
50
50
  mode,
51
51
  credentials: credentialsMode,
52
- useCredentials: true
52
+ useURLCredentials: true
53
53
  })
54
54
  }
55
55
 
@@ -1195,7 +1195,10 @@ function simpleRangeHeaderValue (value, allowWhitespace) {
1195
1195
  // 18. If rangeStartValue and rangeEndValue are numbers, and rangeStartValue is
1196
1196
  // greater than rangeEndValue, then return failure.
1197
1197
  // Note: ... when can they not be numbers?
1198
- if (rangeStartValue > rangeEndValue) {
1198
+ // Note: rangeStartValue or rangeEndValue may be null for open-ended ranges
1199
+ // such as `bytes=5-` or `bytes=-5`. A null value must not be coerced to 0
1200
+ // in the comparison, so this check only applies when both are numbers.
1201
+ if (rangeStartValue !== null && rangeEndValue !== null && rangeStartValue > rangeEndValue) {
1199
1202
  return 'failure'
1200
1203
  }
1201
1204
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "undici",
3
- "version": "8.6.0",
3
+ "version": "8.7.0",
4
4
  "description": "An HTTP/1.1 client, written from scratch for Node.js",
5
5
  "homepage": "https://undici.nodejs.org",
6
6
  "bugs": {
@@ -1,6 +1,8 @@
1
1
  import Dispatcher from './dispatcher'
2
2
 
3
3
  export declare class RedirectHandler implements Dispatcher.DispatchHandler {
4
+ static buildDispatch (dispatcher: Dispatcher, maxRedirections: number): Dispatcher.Dispatch
5
+
4
6
  constructor (
5
7
  dispatch: Dispatcher.Dispatch,
6
8
  maxRedirections: number,
@@ -24,6 +24,13 @@ declare namespace ProxyAgent {
24
24
  requestTls?: buildConnector.BuildOptions;
25
25
  proxyTls?: buildConnector.BuildOptions;
26
26
  clientFactory?(origin: URL, opts: object): Dispatcher;
27
+ /**
28
+ * Undici tunnels via CONNECT when the target endpoint uses HTTPS, and
29
+ * forwards (HTTP/1.1 absolute-form request-target, per RFC 9112 §3.2.2)
30
+ * otherwise, regardless of whether the proxy URL is HTTP or HTTPS. The
31
+ * non-tunneled forwarding path does not negotiate HTTP/2 with the proxy.
32
+ * Set to true to force tunneling for plain HTTP requests as well.
33
+ */
27
34
  proxyTunnel?: boolean;
28
35
  }
29
36
  }