undici 8.8.0 → 8.10.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/Client.md +21 -8
- package/lib/api/readable.js +33 -43
- package/lib/cache/memory-cache-store.js +54 -9
- package/lib/cache/sqlite-cache-store.js +7 -1
- package/lib/core/connect.js +16 -2
- package/lib/core/request.js +12 -1
- package/lib/core/symbols.js +1 -0
- package/lib/dispatcher/client-h1.js +14 -5
- package/lib/dispatcher/client-h2.js +179 -10
- package/lib/dispatcher/client.js +74 -29
- package/lib/dispatcher/env-http-proxy-agent.js +28 -6
- package/lib/dispatcher/socks5-proxy-agent.js +15 -1
- package/lib/global.js +36 -16
- package/lib/handler/cache-handler.js +236 -116
- package/lib/handler/cache-revalidation-handler.js +3 -3
- package/lib/handler/retry-handler.js +31 -2
- package/lib/interceptor/cache.js +156 -63
- package/lib/interceptor/decompress.js +5 -2
- package/lib/interceptor/deduplicate.js +1 -1
- package/lib/mock/mock-utils.js +213 -24
- package/lib/util/cache.js +330 -70
- package/lib/util/date.js +23 -6
- package/lib/web/cookies/util.js +78 -8
- package/lib/web/websocket/websocket.js +22 -0
- package/package.json +1 -1
- package/types/client.d.ts +40 -0
package/docs/docs/api/Client.md
CHANGED
|
@@ -111,22 +111,35 @@ added: v1.0.0
|
|
|
111
111
|
`autoSelectFamily` is enabled. **Default:** `250`.
|
|
112
112
|
* `allowH2` {boolean} Enables HTTP/2 support when the server assigns it a
|
|
113
113
|
higher priority through ALPN negotiation. **Default:** `true`.
|
|
114
|
-
* `useH2c` {boolean} Enforces h2c (HTTP/2 cleartext) for non-HTTPS
|
|
115
|
-
|
|
116
|
-
* `maxConcurrentStreams` {number} The maximum number of concurrent HTTP/2
|
|
114
|
+
* `useH2c` {boolean} _Deprecated: use h2Options.useH2c instead_ Enforces h2c (HTTP/2 cleartext) for non-HTTPS
|
|
115
|
+
connections. **Default:** `false`.
|
|
116
|
+
* `maxConcurrentStreams` {number} _Deprecated: use h2Options.useH2c instead_ The maximum number of concurrent HTTP/2
|
|
117
117
|
streams for a single session. Once h2 is negotiated this — not `pipelining`,
|
|
118
118
|
which is HTTP/1.1 only — is the ceiling used to dispatch in-flight requests.
|
|
119
119
|
It may be overridden by the server's `SETTINGS_MAX_CONCURRENT_STREAMS`
|
|
120
120
|
frame. **Default:** `100`.
|
|
121
|
-
* `
|
|
122
|
-
size (`SETTINGS_INITIAL_WINDOW_SIZE`). Must be a positive integer.
|
|
123
|
-
**Default:** `262144`.
|
|
124
|
-
* `connectionWindowSize` {number} The HTTP/2 connection-level flow-control
|
|
121
|
+
* `connectionWindowSize` {number} _Deprecated: use h2Options.connectionWindowSize instead_ The HTTP/2 connection-level flow-control
|
|
125
122
|
window size set via `ClientHttp2Session.setLocalWindowSize()`. Must be a
|
|
126
123
|
positive integer. **Default:** `524288`.
|
|
127
|
-
* `pingInterval` {number} The time interval, in milliseconds, between HTTP/2
|
|
124
|
+
* `pingInterval` {number} _Deprecated: use h2Options.pingInterval instead_ The time interval, in milliseconds, between HTTP/2
|
|
128
125
|
PING frames. Set to `0` to disable PING frames. Applies only to HTTP/2
|
|
129
126
|
connections and emits a `ping` event on the client. **Default:** `60e3`.
|
|
127
|
+
* `h2Options` {object} Set of options for HTTP/2 sessions
|
|
128
|
+
* `useH2c` {boolean} Enforces h2c (HTTP/2 cleartext) for non-HTTPS
|
|
129
|
+
connections. **Default:** `false`.
|
|
130
|
+
* `maxConcurrentStreams` {number} The maximum number of concurrent HTTP/2
|
|
131
|
+
streams for a single session. Once h2 is negotiated this — not `pipelining`,
|
|
132
|
+
which is HTTP/1.1 only — is the ceiling used to dispatch in-flight requests.
|
|
133
|
+
It may be overridden by the server's `SETTINGS_MAX_CONCURRENT_STREAMS`
|
|
134
|
+
frame. **Default:** `100`.
|
|
135
|
+
* `connectionWindowSize` {number} The HTTP/2 connection-level flow-control
|
|
136
|
+
window size set via `ClientHttp2Session.setLocalWindowSize()`. Must be a
|
|
137
|
+
positive integer. **Default:** `524288`.
|
|
138
|
+
* `pingInterval` {number} The time interval, in milliseconds, between HTTP/2
|
|
139
|
+
PING frames. Set to `0` to disable PING frames. Applies only to HTTP/2
|
|
140
|
+
connections and emits a `ping` event on the client. **Default:** `60e3`.
|
|
141
|
+
* `settings` {object} `SETTINGS` frame options. For full reference, take a
|
|
142
|
+
look to [HTTP/2#Settings Object](https://nodejs.org/api/http2.html#settings-object)
|
|
130
143
|
* `webSocket` {Object} (optional) WebSocket-specific configuration.
|
|
131
144
|
* `maxFragments` {number} The maximum number of fragments in a message. Set
|
|
132
145
|
to `0` to disable the limit. **Default:** `131072`.
|
package/lib/api/readable.js
CHANGED
|
@@ -15,7 +15,6 @@ const kContentType = Symbol('kContentType')
|
|
|
15
15
|
const kContentLength = Symbol('kContentLength')
|
|
16
16
|
const kUsed = Symbol('kUsed')
|
|
17
17
|
const kBytesRead = Symbol('kBytesRead')
|
|
18
|
-
const kPreservedBuffer = Symbol('kPreservedBuffer')
|
|
19
18
|
|
|
20
19
|
const noop = () => {}
|
|
21
20
|
|
|
@@ -326,36 +325,14 @@ class BodyReadable extends Readable {
|
|
|
326
325
|
*/
|
|
327
326
|
setEncoding (encoding) {
|
|
328
327
|
if (Buffer.isEncoding(encoding)) {
|
|
329
|
-
// Preserve raw Buffer chunks for the consume path (body.text(),
|
|
330
|
-
// body.json(), etc.) before super.setEncoding() replaces them
|
|
331
|
-
// with decoded strings. Without this, the consume path would
|
|
332
|
-
// lose access to the original bytes — some of which may be held
|
|
333
|
-
// by the decoder for incomplete multi-byte sequences, and the
|
|
334
|
-
// rest converted to strings that can't be safely concatenated
|
|
335
|
-
// byte-wise.
|
|
336
|
-
const state = this._readableState
|
|
337
|
-
const buffer = state.buffer
|
|
338
|
-
if (buffer && state.length > 0) {
|
|
339
|
-
const bufferIndex = state.bufferIndex ?? 0
|
|
340
|
-
const preserved = []
|
|
341
|
-
const source = typeof buffer.slice === 'function'
|
|
342
|
-
? buffer.slice(bufferIndex)
|
|
343
|
-
: buffer
|
|
344
|
-
for (const data of source) {
|
|
345
|
-
if (Buffer.isBuffer(data)) {
|
|
346
|
-
preserved.push(data)
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
if (preserved.length > 0) {
|
|
350
|
-
this[kPreservedBuffer] = (this[kPreservedBuffer] || []).concat(preserved)
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
328
|
// Delegate to Node.js Readable.setEncoding() which initializes a
|
|
355
329
|
// StringDecoder and re-encodes already-buffered chunks. This properly
|
|
356
330
|
// handles multi-byte sequences split at chunk boundaries for the
|
|
357
331
|
// for-await / on('data') paths. Without this, Node.js uses
|
|
358
332
|
// buf.toString(encoding) on each chunk, producing U+FFFD for split chars.
|
|
333
|
+
//
|
|
334
|
+
// The consume path (body.text(), body.json(), ...) copes with the
|
|
335
|
+
// decoded strings this leaves in state.buffer, see consumeStart().
|
|
359
336
|
super.setEncoding(encoding)
|
|
360
337
|
}
|
|
361
338
|
return this
|
|
@@ -464,17 +441,7 @@ function consumeStart (consume) {
|
|
|
464
441
|
|
|
465
442
|
const { _readableState: state } = consume.stream
|
|
466
443
|
|
|
467
|
-
|
|
468
|
-
// (which would break Buffer.concat in chunksDecode). Use the preserved
|
|
469
|
-
// raw Buffers (saved before super.setEncoding() in setEncoding()) for
|
|
470
|
-
// byte-level accurate consumption. Otherwise read from state.buffer.
|
|
471
|
-
const preserved = consume.stream[kPreservedBuffer]
|
|
472
|
-
if (preserved && preserved.length > 0) {
|
|
473
|
-
for (const chunk of preserved) {
|
|
474
|
-
consumePush(consume, chunk)
|
|
475
|
-
}
|
|
476
|
-
consume.stream[kPreservedBuffer] = null
|
|
477
|
-
} else if (state.bufferIndex) {
|
|
444
|
+
if (state.bufferIndex) {
|
|
478
445
|
const start = state.bufferIndex
|
|
479
446
|
const end = state.buffer.length
|
|
480
447
|
for (let n = start; n < end; n++) {
|
|
@@ -486,14 +453,29 @@ function consumeStart (consume) {
|
|
|
486
453
|
}
|
|
487
454
|
}
|
|
488
455
|
|
|
456
|
+
// If setEncoding() was called, state.buffer holds decoded strings, which
|
|
457
|
+
// consumePush() turns back into bytes. The trailing bytes of a multi-byte
|
|
458
|
+
// sequence split across a chunk boundary are not part of any of those
|
|
459
|
+
// strings, they are held inside the decoder until the rest arrives, so
|
|
460
|
+
// take them from there.
|
|
461
|
+
const decoder = state.decoder
|
|
462
|
+
if (decoder != null && decoder.lastNeed > 0) {
|
|
463
|
+
consumePush(consume, Buffer.from(decoder.lastChar.subarray(0, decoder.lastTotal - decoder.lastNeed)))
|
|
464
|
+
}
|
|
465
|
+
|
|
489
466
|
if (state.endEmitted) {
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
467
|
+
// No `this` to read the consume off here: consumeStart is a free function, called from
|
|
468
|
+
// the queueMicrotask above. The callback below does have one, because the emitter passes
|
|
469
|
+
// the stream as its receiver. Returning matters too - consumeEnd() clears consume.stream,
|
|
470
|
+
// which the resume() below would then dereference.
|
|
471
|
+
consumeEnd(consume, state.encoding)
|
|
472
|
+
return
|
|
495
473
|
}
|
|
496
474
|
|
|
475
|
+
consume.stream.on('end', function () {
|
|
476
|
+
consumeEnd(this[kConsume], this._readableState.encoding)
|
|
477
|
+
})
|
|
478
|
+
|
|
497
479
|
consume.stream.resume()
|
|
498
480
|
|
|
499
481
|
while (consume.stream.read() != null) {
|
|
@@ -583,7 +565,7 @@ function consumeEnd (consume, encoding) {
|
|
|
583
565
|
|
|
584
566
|
/**
|
|
585
567
|
* @param {Consume} consume
|
|
586
|
-
* @param {Buffer} chunk
|
|
568
|
+
* @param {Buffer|string} chunk
|
|
587
569
|
* @returns {void}
|
|
588
570
|
*/
|
|
589
571
|
function consumePush (consume, chunk) {
|
|
@@ -591,6 +573,14 @@ function consumePush (consume, chunk) {
|
|
|
591
573
|
return
|
|
592
574
|
}
|
|
593
575
|
|
|
576
|
+
if (typeof chunk === 'string') {
|
|
577
|
+
// Buffered before the consume started, while an encoding was set.
|
|
578
|
+
// consume.length has to stay a byte count and chunksDecode()/chunksConcat()
|
|
579
|
+
// only work on bytes, so re-encode. A string's own length is in UTF-16 code
|
|
580
|
+
// units and Uint8Array.prototype.set() ignores a string argument entirely.
|
|
581
|
+
chunk = Buffer.from(chunk, consume.stream._readableState.encoding)
|
|
582
|
+
}
|
|
583
|
+
|
|
594
584
|
consume.length += chunk.length
|
|
595
585
|
consume.body.push(chunk)
|
|
596
586
|
}
|
|
@@ -218,17 +218,62 @@ class MemoryCacheStore extends EventEmitter {
|
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
function findEntry (key, entries, now) {
|
|
221
|
-
|
|
222
|
-
entry
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
221
|
+
for (let i = 0; i < entries.length; i++) {
|
|
222
|
+
const entry = entries[i]
|
|
223
|
+
if (
|
|
224
|
+
entry.deleteAt > now &&
|
|
225
|
+
entry.method === key.method &&
|
|
226
|
+
varyMatches(key, entry)
|
|
227
|
+
) {
|
|
228
|
+
return entry
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function varyMatches (key, entry) {
|
|
234
|
+
if (entry.vary == null) {
|
|
235
|
+
return true
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
for (const headerName in entry.vary) {
|
|
239
|
+
if (Object.hasOwn(entry.vary, headerName) && !headerValueEquals(key.headers?.[headerName], entry.vary[headerName])) {
|
|
240
|
+
return false
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return true
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* @param {string|string[]|null|undefined} lhs
|
|
249
|
+
* @param {string|string[]|null|undefined} rhs
|
|
250
|
+
* @returns {boolean}
|
|
251
|
+
*/
|
|
252
|
+
function headerValueEquals (lhs, rhs) {
|
|
253
|
+
if (lhs == null && rhs == null) {
|
|
254
|
+
return true
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if ((lhs == null && rhs != null) ||
|
|
258
|
+
(lhs != null && rhs == null)) {
|
|
259
|
+
return false
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (Array.isArray(lhs) && Array.isArray(rhs)) {
|
|
263
|
+
if (lhs.length !== rhs.length) {
|
|
264
|
+
return false
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
for (let i = 0; i < lhs.length; i++) {
|
|
268
|
+
if (lhs[i] !== rhs[i]) {
|
|
269
|
+
return false
|
|
227
270
|
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return true
|
|
274
|
+
}
|
|
228
275
|
|
|
229
|
-
|
|
230
|
-
}))
|
|
231
|
-
))
|
|
276
|
+
return lhs === rhs
|
|
232
277
|
}
|
|
233
278
|
|
|
234
279
|
module.exports = MemoryCacheStore
|
|
@@ -456,7 +456,13 @@ function headerValueEquals (lhs, rhs) {
|
|
|
456
456
|
return false
|
|
457
457
|
}
|
|
458
458
|
|
|
459
|
-
|
|
459
|
+
for (let i = 0; i < lhs.length; i++) {
|
|
460
|
+
if (lhs[i] !== rhs[i]) {
|
|
461
|
+
return false
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return true
|
|
460
466
|
}
|
|
461
467
|
|
|
462
468
|
return lhs === rhs
|
package/lib/core/connect.js
CHANGED
|
@@ -105,13 +105,27 @@ function buildConnector ({ allowH2, preferH2, useH2c, maxCachedSessions, socketP
|
|
|
105
105
|
|
|
106
106
|
port = port || 80
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
const connectOptions = {
|
|
109
109
|
highWaterMark: 64 * 1024, // Same as nodejs fs streams.
|
|
110
110
|
...options,
|
|
111
111
|
localAddress,
|
|
112
112
|
port,
|
|
113
113
|
host: hostname
|
|
114
|
-
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const family = net.isIP(hostname)
|
|
117
|
+
if (family !== 0 && servername && servername !== hostname) {
|
|
118
|
+
connectOptions.host = servername
|
|
119
|
+
connectOptions.lookup = (_hostname, lookupOptions, cb) => {
|
|
120
|
+
if (lookupOptions.all) {
|
|
121
|
+
cb(null, [{ address: hostname, family }])
|
|
122
|
+
} else {
|
|
123
|
+
cb(null, hostname, family)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
socket = net.connect(connectOptions)
|
|
115
129
|
if (useH2c === true) {
|
|
116
130
|
socket.alpnProtocol = 'h2'
|
|
117
131
|
}
|
package/lib/core/request.js
CHANGED
|
@@ -472,7 +472,13 @@ function processHeader (request, key, val) {
|
|
|
472
472
|
} else if (typeof val[i] === 'object') {
|
|
473
473
|
throw new InvalidArgumentError(`invalid ${key} header`)
|
|
474
474
|
} else {
|
|
475
|
-
|
|
475
|
+
// Coerce primitives (and reject unsafe coercions such as functions
|
|
476
|
+
// with a crafted toString/Symbol.toPrimitive).
|
|
477
|
+
const str = `${val[i]}`
|
|
478
|
+
if (!isValidHeaderValue(str)) {
|
|
479
|
+
throw new InvalidArgumentError(`invalid ${key} header`)
|
|
480
|
+
}
|
|
481
|
+
arr.push(str)
|
|
476
482
|
}
|
|
477
483
|
}
|
|
478
484
|
val = arr
|
|
@@ -483,7 +489,12 @@ function processHeader (request, key, val) {
|
|
|
483
489
|
} else if (val === null) {
|
|
484
490
|
val = ''
|
|
485
491
|
} else {
|
|
492
|
+
// Coerce primitives (and reject unsafe coercions such as functions
|
|
493
|
+
// with a crafted toString/Symbol.toPrimitive).
|
|
486
494
|
val = `${val}`
|
|
495
|
+
if (!isValidHeaderValue(val)) {
|
|
496
|
+
throw new InvalidArgumentError(`invalid ${key} header`)
|
|
497
|
+
}
|
|
487
498
|
}
|
|
488
499
|
|
|
489
500
|
if (headerName === 'host') {
|
package/lib/core/symbols.js
CHANGED
|
@@ -56,6 +56,7 @@ module.exports = {
|
|
|
56
56
|
kCounter: Symbol('socket request counter'),
|
|
57
57
|
kMaxResponseSize: Symbol('max response size'),
|
|
58
58
|
kHTTP2Session: Symbol('http2Session'),
|
|
59
|
+
kHTTP2Options: Symbol('http2 options'),
|
|
59
60
|
kHTTP2SessionState: Symbol('http2Session state'),
|
|
60
61
|
kRetryHandlerDefaultRetry: Symbol('retry agent default retry'),
|
|
61
62
|
kConstruct: Symbol('constructable'),
|
|
@@ -10,6 +10,7 @@ const {
|
|
|
10
10
|
RequestContentLengthMismatchError,
|
|
11
11
|
ResponseContentLengthMismatchError,
|
|
12
12
|
RequestAbortedError,
|
|
13
|
+
InvalidArgumentError,
|
|
13
14
|
HeadersTimeoutError,
|
|
14
15
|
HeadersOverflowError,
|
|
15
16
|
SocketError,
|
|
@@ -1051,7 +1052,7 @@ function onSocketClose () {
|
|
|
1051
1052
|
|
|
1052
1053
|
function clearIdleSocketValidation (socket) {
|
|
1053
1054
|
if (socket[kIdleSocketValidationTimeout]) {
|
|
1054
|
-
|
|
1055
|
+
clearTimeout(socket[kIdleSocketValidationTimeout])
|
|
1055
1056
|
socket[kIdleSocketValidationTimeout] = null
|
|
1056
1057
|
}
|
|
1057
1058
|
|
|
@@ -1060,14 +1061,14 @@ function clearIdleSocketValidation (socket) {
|
|
|
1060
1061
|
|
|
1061
1062
|
function scheduleIdleSocketValidation (client, socket) {
|
|
1062
1063
|
socket[kIdleSocketValidation] = 1
|
|
1063
|
-
socket[kIdleSocketValidationTimeout] =
|
|
1064
|
+
socket[kIdleSocketValidationTimeout] = setTimeout(() => {
|
|
1064
1065
|
socket[kIdleSocketValidationTimeout] = null
|
|
1065
1066
|
socket[kIdleSocketValidation] = 2
|
|
1066
1067
|
|
|
1067
1068
|
if (client[kSocket] === socket && !socket.destroyed) {
|
|
1068
1069
|
client[kResume]()
|
|
1069
1070
|
}
|
|
1070
|
-
})
|
|
1071
|
+
}, 0)
|
|
1071
1072
|
socket[kIdleSocketValidationTimeout].unref?.()
|
|
1072
1073
|
}
|
|
1073
1074
|
|
|
@@ -1200,8 +1201,16 @@ function writeH1 (client, request) {
|
|
|
1200
1201
|
}
|
|
1201
1202
|
body = bodyStream.stream
|
|
1202
1203
|
contentLength = bodyStream.length
|
|
1203
|
-
} else if (util.isBlobLike(body) && request.contentType == null
|
|
1204
|
-
|
|
1204
|
+
} else if (util.isBlobLike(body) && request.contentType == null) {
|
|
1205
|
+
const contentType = body.type
|
|
1206
|
+
if (contentType) {
|
|
1207
|
+
const contentTypeValue = `${contentType}`
|
|
1208
|
+
if (!util.isValidHeaderValue(contentTypeValue)) {
|
|
1209
|
+
util.errorRequest(client, request, new InvalidArgumentError('invalid content-type header'))
|
|
1210
|
+
return false
|
|
1211
|
+
}
|
|
1212
|
+
headers.push('content-type', contentTypeValue)
|
|
1213
|
+
}
|
|
1205
1214
|
}
|
|
1206
1215
|
|
|
1207
1216
|
if (body && typeof body.read === 'function') {
|
|
@@ -26,10 +26,7 @@ const {
|
|
|
26
26
|
kStrictContentLength,
|
|
27
27
|
kOnError,
|
|
28
28
|
kMaxConcurrentStreams,
|
|
29
|
-
kPingInterval,
|
|
30
29
|
kHTTP2Session,
|
|
31
|
-
kHTTP2InitialWindowSize,
|
|
32
|
-
kHTTP2ConnectionWindowSize,
|
|
33
30
|
kHostAuthority,
|
|
34
31
|
kResume,
|
|
35
32
|
kSize,
|
|
@@ -41,7 +38,8 @@ const {
|
|
|
41
38
|
kEnableConnectProtocol,
|
|
42
39
|
kRemoteSettings,
|
|
43
40
|
kHTTP2Stream,
|
|
44
|
-
kHTTP2SessionState
|
|
41
|
+
kHTTP2SessionState,
|
|
42
|
+
kHTTP2Options
|
|
45
43
|
} = require('../core/symbols.js')
|
|
46
44
|
const { channels } = require('../core/diagnostics.js')
|
|
47
45
|
|
|
@@ -51,6 +49,14 @@ const kRequestStream = Symbol('request stream')
|
|
|
51
49
|
const kRequestStreamCleanup = Symbol('request stream cleanup')
|
|
52
50
|
const kRequestStreamState = Symbol('request stream state')
|
|
53
51
|
const kReceivedGoAway = Symbol('received goaway')
|
|
52
|
+
const kGoAwayReplayAttempts = Symbol('goaway replay attempts')
|
|
53
|
+
const kRefusedStreamRetry = Symbol('refused stream retry')
|
|
54
|
+
|
|
55
|
+
// RFC 9113 section 8.7: a client SHOULD NOT automatically retry a request more
|
|
56
|
+
// than once. Without a budget a peer that keeps refusing turns one request into
|
|
57
|
+
// an unbounded connect/refuse/reconnect loop that never settles and starves the
|
|
58
|
+
// event loop.
|
|
59
|
+
const MAX_GOAWAY_REPLAY_ATTEMPTS = 1
|
|
54
60
|
|
|
55
61
|
let extractBody
|
|
56
62
|
|
|
@@ -179,12 +185,24 @@ function completeRequest (client, request, resetPendingIdx = false) {
|
|
|
179
185
|
}
|
|
180
186
|
}
|
|
181
187
|
|
|
182
|
-
function
|
|
188
|
+
function canReplayRequest (request) {
|
|
183
189
|
const { body } = request
|
|
184
190
|
|
|
185
191
|
return body == null || util.isBuffer(body) || util.isBlobLike(body)
|
|
186
192
|
}
|
|
187
193
|
|
|
194
|
+
// Count a GOAWAY refusal against the request's replay budget. A peer that
|
|
195
|
+
// refuses every connection must eventually surface an error to the caller
|
|
196
|
+
// rather than being retried forever. Kept separate from canReplayRequest so
|
|
197
|
+
// that the REFUSED_STREAM retry, which has its own single-attempt limit, does
|
|
198
|
+
// not consume this budget just by asking whether the body can be replayed.
|
|
199
|
+
function registerGoAwayRefusal (request) {
|
|
200
|
+
const attempts = (request[kGoAwayReplayAttempts] ?? 0) + 1
|
|
201
|
+
request[kGoAwayReplayAttempts] = attempts
|
|
202
|
+
|
|
203
|
+
return attempts <= MAX_GOAWAY_REPLAY_ATTEMPTS
|
|
204
|
+
}
|
|
205
|
+
|
|
188
206
|
function closeStream (stream, code = NGHTTP2_REFUSED_STREAM) {
|
|
189
207
|
if (stream != null && !stream.destroyed && !stream.closed) {
|
|
190
208
|
try {
|
|
@@ -197,19 +215,44 @@ function detachRequestStreamForClose (request) {
|
|
|
197
215
|
const stream = request[kRequestStream]
|
|
198
216
|
|
|
199
217
|
clearRequestStream(request)
|
|
218
|
+
severRequestStream(stream)
|
|
200
219
|
|
|
201
220
|
return stream
|
|
202
221
|
}
|
|
203
222
|
|
|
223
|
+
// Unbind a stream from its request for good. releaseRequestStream() alone
|
|
224
|
+
// leaves the 'close' listener attached and kRequestStreamState populated, so a
|
|
225
|
+
// stream abandoned here would still run completeRequestStream() later — and
|
|
226
|
+
// splice out the request that has since been requeued onto another session.
|
|
227
|
+
function severRequestStream (stream) {
|
|
228
|
+
if (stream == null || stream[kRequestStreamState] == null) {
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
stream[kRequestStreamState] = null
|
|
233
|
+
stream.off('close', completeRequestStream)
|
|
234
|
+
// Upgrade streams use their own close cleanup, which would otherwise release
|
|
235
|
+
// the session a second time after the stream has been severed for GOAWAY.
|
|
236
|
+
stream.off('close', onUpgradeStreamClose)
|
|
237
|
+
|
|
238
|
+
if (stream[kHTTP2Session] != null) {
|
|
239
|
+
closeStreamSession(stream)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (!stream.destroyed && !stream.closed) {
|
|
243
|
+
stream.once('error', noop)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
204
247
|
function connectH2 (client, socket) {
|
|
205
248
|
client[kSocket] = socket
|
|
206
249
|
|
|
207
|
-
const http2InitialWindowSize = client[
|
|
208
|
-
const http2ConnectionWindowSize = client[
|
|
250
|
+
const http2InitialWindowSize = client[kHTTP2Options].sessionOptions?.initialWindowSize
|
|
251
|
+
const http2ConnectionWindowSize = client[kHTTP2Options].connectionWindowSize
|
|
209
252
|
|
|
210
253
|
const session = http2.connect(client[kUrl], {
|
|
211
254
|
createConnection: () => socket,
|
|
212
|
-
peerMaxConcurrentStreams: client[
|
|
255
|
+
peerMaxConcurrentStreams: client[kHTTP2Options].maxConcurrentStreams,
|
|
213
256
|
settings: {
|
|
214
257
|
// TODO(metcoder95): add support for PUSH
|
|
215
258
|
enablePush: false,
|
|
@@ -223,13 +266,16 @@ function connectH2 (client, socket) {
|
|
|
223
266
|
session[kSocket] = socket
|
|
224
267
|
session[kHTTP2SessionState] = {
|
|
225
268
|
idleTimeout: null,
|
|
269
|
+
// Armed while the peer advertises MAX_CONCURRENT_STREAMS = 0 and we have
|
|
270
|
+
// work that cannot start. See setNoStreamsTimeout.
|
|
271
|
+
noStreamsTimeout: null,
|
|
226
272
|
// Sockets start out ref'd. Session ref/unref proxies to the socket, so a
|
|
227
273
|
// single cached flag lets us skip redundant uv ref/unref calls, provided
|
|
228
274
|
// every ref/unref of the session or its socket goes through
|
|
229
275
|
// refH2Session/unrefH2Session.
|
|
230
276
|
refed: true,
|
|
231
277
|
ping: {
|
|
232
|
-
interval: client[
|
|
278
|
+
interval: client[kHTTP2Options].pingInterval === 0 ? null : setInterval(onHttp2SendPing, client[kHTTP2Options].pingInterval, session).unref()
|
|
233
279
|
}
|
|
234
280
|
}
|
|
235
281
|
session[kReceivedGoAway] = false
|
|
@@ -369,7 +415,74 @@ function resumeH2 (client) {
|
|
|
369
415
|
} else {
|
|
370
416
|
clearHttp2IdleTimeout(session)
|
|
371
417
|
}
|
|
418
|
+
|
|
419
|
+
if (client[kMaxConcurrentStreams] === 0 && client[kRunning] === 0 && client[kPending] > 0) {
|
|
420
|
+
setNoStreamsTimeout(session)
|
|
421
|
+
} else {
|
|
422
|
+
clearNoStreamsTimeout(session)
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function clearNoStreamsTimeout (session) {
|
|
428
|
+
const state = session[kHTTP2SessionState]
|
|
429
|
+
|
|
430
|
+
if (state?.noStreamsTimeout != null) {
|
|
431
|
+
clearTimeout(state.noStreamsTimeout)
|
|
432
|
+
state.noStreamsTimeout = null
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// A peer is allowed to advertise SETTINGS_MAX_CONCURRENT_STREAMS = 0 to refuse
|
|
437
|
+
// new streams (RFC 9113 §6.5.2), and is expected to raise it again later. Until
|
|
438
|
+
// it does, busy() reports the client as permanently busy and queued requests
|
|
439
|
+
// cannot open a stream — which means no per-stream timeout covers them, and no
|
|
440
|
+
// reconnect can happen either, so the SETTINGS frame that would lift the limit
|
|
441
|
+
// can never arrive. Give the peer headersTimeout to start honouring requests
|
|
442
|
+
// before failing them; a request that cannot even be sent has missed the same
|
|
443
|
+
// deadline as one whose headers never arrive.
|
|
444
|
+
function setNoStreamsTimeout (session) {
|
|
445
|
+
const client = session[kClient]
|
|
446
|
+
const state = session[kHTTP2SessionState]
|
|
447
|
+
const timeout = client[kHeadersTimeout]
|
|
448
|
+
|
|
449
|
+
if (!timeout || state.noStreamsTimeout != null) {
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
state.noStreamsTimeout = setTimeout(onNoStreamsTimeout, timeout, session).unref()
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function onNoStreamsTimeout (session) {
|
|
457
|
+
const client = session[kClient]
|
|
458
|
+
const state = session[kHTTP2SessionState]
|
|
459
|
+
|
|
460
|
+
state.noStreamsTimeout = null
|
|
461
|
+
|
|
462
|
+
if (
|
|
463
|
+
client[kHTTP2Session] !== session ||
|
|
464
|
+
client[kMaxConcurrentStreams] !== 0 ||
|
|
465
|
+
client[kRunning] !== 0 ||
|
|
466
|
+
client[kPending] === 0
|
|
467
|
+
) {
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const err = new HeadersTimeoutError(
|
|
472
|
+
`HTTP/2: server did not accept a new stream within ${client[kHeadersTimeout]}`
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
const requests = client[kQueue].splice(client[kPendingIdx])
|
|
476
|
+
for (let i = 0; i < requests.length; i++) {
|
|
477
|
+
if (requests[i] != null) {
|
|
478
|
+
util.errorRequest(client, requests[i], err)
|
|
479
|
+
}
|
|
372
480
|
}
|
|
481
|
+
|
|
482
|
+
// Drop the unusable session so the next request gets a fresh connection,
|
|
483
|
+
// whose SETTINGS may well allow streams again.
|
|
484
|
+
session[kError] = err
|
|
485
|
+
resetHttp2Session(session, err)
|
|
373
486
|
}
|
|
374
487
|
|
|
375
488
|
function clearHttp2IdleTimeout (session) {
|
|
@@ -527,7 +640,7 @@ function onHttp2SessionGoAway (errorCode, lastStreamID) {
|
|
|
527
640
|
if (request != null) {
|
|
528
641
|
streamsToClose.push(detachRequestStreamForClose(request))
|
|
529
642
|
|
|
530
|
-
if (
|
|
643
|
+
if (canReplayRequest(request) && registerGoAwayRefusal(request)) {
|
|
531
644
|
retriableRequests.push(request)
|
|
532
645
|
} else {
|
|
533
646
|
util.errorRequest(client, request, err)
|
|
@@ -552,6 +665,7 @@ function onHttp2SessionGoAway (errorCode, lastStreamID) {
|
|
|
552
665
|
}
|
|
553
666
|
|
|
554
667
|
clearHttp2IdleTimeout(this)
|
|
668
|
+
clearNoStreamsTimeout(this)
|
|
555
669
|
|
|
556
670
|
if (!this.closed && !this.destroyed) {
|
|
557
671
|
this.close()
|
|
@@ -576,6 +690,7 @@ function onHttp2SessionClose () {
|
|
|
576
690
|
}
|
|
577
691
|
|
|
578
692
|
clearHttp2IdleTimeout(this)
|
|
693
|
+
clearNoStreamsTimeout(this)
|
|
579
694
|
|
|
580
695
|
if (state.ping.interval != null) {
|
|
581
696
|
clearInterval(state.ping.interval)
|
|
@@ -687,6 +802,16 @@ function completeRequestStream () {
|
|
|
687
802
|
|
|
688
803
|
if (state.pendingEnd && !state.request.aborted && !state.request.completed) {
|
|
689
804
|
state.request.onResponseEnd(state.trailers || {})
|
|
805
|
+
} else if (!state.request.aborted && !state.request.completed) {
|
|
806
|
+
// The stream closed without a complete response and without reporting an
|
|
807
|
+
// error. finalizeRequest() below frees the queue slot either way, so
|
|
808
|
+
// without this the request would simply vanish and its caller would never
|
|
809
|
+
// hear back.
|
|
810
|
+
util.errorRequest(
|
|
811
|
+
state.client,
|
|
812
|
+
state.request,
|
|
813
|
+
new InformationalError('HTTP/2: stream closed before the response was complete')
|
|
814
|
+
)
|
|
690
815
|
}
|
|
691
816
|
|
|
692
817
|
finalizeRequest(state)
|
|
@@ -1286,6 +1411,38 @@ function onEnd () {
|
|
|
1286
1411
|
}
|
|
1287
1412
|
}
|
|
1288
1413
|
|
|
1414
|
+
function retryRefusedStream (stream, state) {
|
|
1415
|
+
const { client, request } = state
|
|
1416
|
+
|
|
1417
|
+
if (
|
|
1418
|
+
state.responseReceived ||
|
|
1419
|
+
request.aborted ||
|
|
1420
|
+
request.completed ||
|
|
1421
|
+
request[kRefusedStreamRetry] ||
|
|
1422
|
+
!canReplayRequest(request)
|
|
1423
|
+
) {
|
|
1424
|
+
return false
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// RFC 9113 section 8.7 permits retrying REFUSED_STREAM, but says clients
|
|
1428
|
+
// SHOULD NOT automatically retry the same request more than once.
|
|
1429
|
+
request[kRefusedStreamRetry] = true
|
|
1430
|
+
|
|
1431
|
+
// Detach the failed attempt before moving the request back to the pending
|
|
1432
|
+
// queue. The peer only reset this stream, so the HTTP/2 session remains
|
|
1433
|
+
// usable for the retry. Severing also drops the 'close' listener, so the
|
|
1434
|
+
// abandoned stream cannot later complete the retried request.
|
|
1435
|
+
detachRequestStreamForClose(request)
|
|
1436
|
+
state.stream = null
|
|
1437
|
+
state.requestFinalized = true
|
|
1438
|
+
|
|
1439
|
+
completeRequest(client, request)
|
|
1440
|
+
client[kQueue].splice(client[kPendingIdx], 0, request)
|
|
1441
|
+
client[kResume]()
|
|
1442
|
+
|
|
1443
|
+
return true
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1289
1446
|
function onError (err) {
|
|
1290
1447
|
const stream = this
|
|
1291
1448
|
const state = stream[kRequestStreamState]
|
|
@@ -1295,6 +1452,18 @@ function onError (err) {
|
|
|
1295
1452
|
}
|
|
1296
1453
|
|
|
1297
1454
|
stream.off('error', onError)
|
|
1455
|
+
|
|
1456
|
+
if (typeof stream.rstCode === 'number' && stream.rstCode !== NGHTTP2_NO_ERROR) {
|
|
1457
|
+
err.http2ErrorCode = stream.rstCode
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
if (
|
|
1461
|
+
stream.rstCode === NGHTTP2_REFUSED_STREAM &&
|
|
1462
|
+
retryRefusedStream(stream, state)
|
|
1463
|
+
) {
|
|
1464
|
+
return
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1298
1467
|
state.abort(err)
|
|
1299
1468
|
}
|
|
1300
1469
|
|