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.
- package/docs/docs/api/ProxyAgent.md +13 -6
- package/docs/docs/best-practices/mocking-request.md +1 -1
- package/lib/api/readable.js +4 -0
- package/lib/core/util.js +21 -0
- package/lib/dispatcher/client-h2.js +204 -106
- package/lib/dispatcher/proxy-agent.js +31 -5
- package/lib/web/cookies/parse.js +3 -2
- package/lib/web/cookies/util.js +1 -1
- package/lib/web/eventsource/util.js +1 -1
- package/lib/web/fetch/util.js +4 -1
- package/package.json +1 -1
- package/types/handlers.d.ts +2 -0
- package/types/proxy-agent.d.ts +7 -0
|
@@ -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.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
package/lib/api/readable.js
CHANGED
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
337
|
-
session.unref()
|
|
372
|
+
unrefH2Session(session)
|
|
338
373
|
} else {
|
|
339
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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
|
-
|
|
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
|
-
|
|
894
|
-
|
|
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
|
|
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
|
|
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 =
|
|
1007
|
+
stream = openStream(client, request, session, abort, headers, { endStream: false, signal })
|
|
950
1008
|
if (stream == null) {
|
|
951
|
-
session
|
|
1009
|
+
unrefH2Session(session)
|
|
952
1010
|
return false
|
|
953
1011
|
}
|
|
954
|
-
setupUpgradeStream(stream,
|
|
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 =
|
|
1021
|
+
stream = openStream(client, request, session, abort, headers, { endStream: false, signal })
|
|
964
1022
|
if (stream == null) {
|
|
965
|
-
session
|
|
1023
|
+
unrefH2Session(session)
|
|
966
1024
|
return false
|
|
967
1025
|
}
|
|
968
|
-
setupUpgradeStream(stream,
|
|
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
|
|
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
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
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 =
|
|
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
|
-
|
|
1134
|
+
|
|
1135
|
+
if (headersTimeout) {
|
|
1136
|
+
stream.setTimeout(headersTimeout)
|
|
1137
|
+
}
|
|
1086
1138
|
|
|
1087
1139
|
stream[kHTTP2Session] = session
|
|
1088
|
-
stream.
|
|
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
|
-
|
|
1095
|
-
stream
|
|
1096
|
-
|
|
1097
|
-
stream.
|
|
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
|
-
|
|
1100
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
1181
|
-
//
|
|
1182
|
-
//
|
|
1183
|
-
|
|
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
|
|
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 (!
|
|
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)
|
package/lib/web/cookies/parse.js
CHANGED
|
@@ -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
|
-
|
|
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-
|
package/lib/web/cookies/util.js
CHANGED
package/lib/web/fetch/util.js
CHANGED
|
@@ -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
|
-
|
|
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
package/types/handlers.d.ts
CHANGED
|
@@ -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,
|
package/types/proxy-agent.d.ts
CHANGED
|
@@ -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
|
}
|