undici 7.0.0-alpha.3 → 7.0.0-alpha.4

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.
@@ -144,7 +144,7 @@ function resumeH2 (client) {
144
144
  const socket = client[kSocket]
145
145
 
146
146
  if (socket?.destroyed === false) {
147
- if (client[kSize] === 0 && client[kMaxConcurrentStreams] === 0) {
147
+ if (client[kSize] === 0 || client[kMaxConcurrentStreams] === 0) {
148
148
  socket.unref()
149
149
  client[kHTTP2Session].unref()
150
150
  } else {
@@ -184,17 +184,19 @@ function onHttp2SessionEnd () {
184
184
  * @param {number} errorCode
185
185
  */
186
186
  function onHttp2SessionGoAway (errorCode) {
187
- // We cannot recover, so best to close the session and the socket
187
+ // TODO(mcollina): Verify if GOAWAY implements the spec correctly:
188
+ // https://datatracker.ietf.org/doc/html/rfc7540#section-6.8
189
+ // Specifically, we do not verify the "valid" stream id.
190
+
188
191
  const err = this[kError] || new SocketError(`HTTP/2: "GOAWAY" frame received with code ${errorCode}`, util.getSocketInfo(this[kSocket]))
189
192
  const client = this[kClient]
190
193
 
191
194
  client[kSocket] = null
192
195
  client[kHTTPContext] = null
193
196
 
194
- if (this[kHTTP2Session] !== null) {
195
- this[kHTTP2Session].destroy(err)
196
- this[kHTTP2Session] = null
197
- }
197
+ // this is an HTTP2 session
198
+ this.close()
199
+ this[kHTTP2Session] = null
198
200
 
199
201
  util.destroy(this[kSocket], err)
200
202
 
@@ -218,7 +220,8 @@ function onHttp2SessionClose () {
218
220
 
219
221
  const err = this[kSocket][kError] || this[kError] || new SocketError('closed', util.getSocketInfo(socket))
220
222
 
221
- client[kHTTP2Session] = null
223
+ client[kSocket] = null
224
+ client[kHTTPContext] = null
222
225
 
223
226
  if (client.destroyed) {
224
227
  assert(client[kPending] === 0)
@@ -238,6 +241,7 @@ function onHttp2SocketClose () {
238
241
  const client = this[kHTTP2Session][kClient]
239
242
 
240
243
  client[kSocket] = null
244
+ client[kHTTPContext] = null
241
245
 
242
246
  if (this[kHTTP2Session] !== null) {
243
247
  this[kHTTP2Session].destroy(err)
@@ -301,7 +305,7 @@ function writeH2 (client, request) {
301
305
  }
302
306
 
303
307
  /** @type {import('node:http2').ClientHttp2Stream} */
304
- let stream
308
+ let stream = null
305
309
 
306
310
  const { hostname, port } = client[kUrl]
307
311
 
@@ -318,14 +322,21 @@ function writeH2 (client, request) {
318
322
  util.errorRequest(client, request, err)
319
323
 
320
324
  if (stream != null) {
321
- util.destroy(stream, err)
325
+ // Some chunks might still come after abort,
326
+ // let's ignore them
327
+ stream.removeAllListeners('data')
328
+
329
+ // On Abort, we close the stream to send RST_STREAM frame
330
+ stream.close()
331
+
332
+ // We move the running index to the next request
333
+ client[kOnError](err)
334
+ client[kResume]()
322
335
  }
323
336
 
324
337
  // We do not destroy the socket as we can continue using the session
325
338
  // the stream gets destroyed and the session remains to create new streams
326
339
  util.destroy(body, err)
327
- client[kQueue][client[kRunningIdx]++] = null
328
- client[kResume]()
329
340
  }
330
341
 
331
342
  try {
@@ -348,7 +359,7 @@ function writeH2 (client, request) {
348
359
  // We disabled endStream to allow the user to write to the stream
349
360
  stream = session.request(headers, { endStream: false, signal })
350
361
 
351
- if (stream.id && !stream.pending) {
362
+ if (!stream.pending) {
352
363
  request.onUpgrade(null, null, stream)
353
364
  ++session[kOpenStreams]
354
365
  client[kQueue][client[kRunningIdx]++] = null
@@ -438,6 +449,7 @@ function writeH2 (client, request) {
438
449
  endStream: shouldEndStream,
439
450
  signal
440
451
  })
452
+
441
453
  writeBodyH2()
442
454
  }
443
455
 
@@ -454,46 +466,52 @@ function writeH2 (client, request) {
454
466
  // for those scenarios, best effort is to destroy the stream immediately
455
467
  // as there's no value to keep it open.
456
468
  if (request.aborted) {
457
- const err = new RequestAbortedError()
458
- util.errorRequest(client, request, err)
459
- util.destroy(stream, err)
469
+ stream.removeAllListeners('data')
460
470
  return
461
471
  }
462
472
 
463
473
  if (request.onHeaders(Number(statusCode), parseH2Headers(realHeaders), stream.resume.bind(stream), '') === false) {
464
474
  stream.pause()
465
475
  }
476
+ })
466
477
 
467
- stream.on('data', (chunk) => {
468
- if (request.onData(chunk) === false) {
469
- stream.pause()
470
- }
471
- })
478
+ stream.on('data', (chunk) => {
479
+ if (request.onData(chunk) === false) {
480
+ stream.pause()
481
+ }
472
482
  })
473
483
 
474
- stream.once('end', () => {
484
+ stream.once('end', (err) => {
485
+ stream.removeAllListeners('data')
475
486
  // When state is null, it means we haven't consumed body and the stream still do not have
476
487
  // a state.
477
488
  // Present specially when using pipeline or stream
478
489
  if (stream.state?.state == null || stream.state.state < 6) {
479
- request.onComplete([])
480
- }
490
+ // Do not complete the request if it was aborted
491
+ if (!request.aborted) {
492
+ request.onComplete([])
493
+ }
481
494
 
482
- if (session[kOpenStreams] === 0) {
495
+ client[kQueue][client[kRunningIdx]++] = null
496
+ client[kResume]()
497
+ } else {
483
498
  // Stream is closed or half-closed-remote (6), decrement counter and cleanup
484
499
  // It does not have sense to continue working with the stream as we do not
485
500
  // have yet RST_STREAM support on client-side
501
+ --session[kOpenStreams]
502
+ if (session[kOpenStreams] === 0) {
503
+ session.unref()
504
+ }
486
505
 
487
- session.unref()
506
+ abort(err ?? new InformationalError('HTTP/2: stream half-closed (remote)'))
507
+ client[kQueue][client[kRunningIdx]++] = null
508
+ client[kPendingIdx] = client[kRunningIdx]
509
+ client[kResume]()
488
510
  }
489
-
490
- abort(new InformationalError('HTTP/2: stream half-closed (remote)'))
491
- client[kQueue][client[kRunningIdx]++] = null
492
- client[kPendingIdx] = client[kRunningIdx]
493
- client[kResume]()
494
511
  })
495
512
 
496
513
  stream.once('close', () => {
514
+ stream.removeAllListeners('data')
497
515
  session[kOpenStreams] -= 1
498
516
  if (session[kOpenStreams] === 0) {
499
517
  session.unref()
@@ -501,16 +519,18 @@ function writeH2 (client, request) {
501
519
  })
502
520
 
503
521
  stream.once('error', function (err) {
522
+ stream.removeAllListeners('data')
504
523
  abort(err)
505
524
  })
506
525
 
507
526
  stream.once('frameError', (type, code) => {
527
+ stream.removeAllListeners('data')
508
528
  abort(new InformationalError(`HTTP/2: "frameError" received - type ${type}, code ${code}`))
509
529
  })
510
530
 
511
- // stream.on('aborted', () => {
512
- // // TODO(HTTP/2): Support aborted
513
- // })
531
+ stream.on('aborted', () => {
532
+ stream.removeAllListeners('data')
533
+ })
514
534
 
515
535
  // stream.on('timeout', () => {
516
536
  // // TODO(HTTP/2): Support timeout
@@ -7,19 +7,21 @@ const {
7
7
  parseVaryHeader
8
8
  } = require('../util/cache')
9
9
 
10
+ function noop () {}
11
+
10
12
  /**
11
13
  * Writes a response to a CacheStore and then passes it on to the next handler
12
14
  */
13
15
  class CacheHandler extends DecoratorHandler {
14
16
  /**
15
- * @type {import('../../types/cache-interceptor.d.ts').default.CacheStore}
17
+ * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey}
16
18
  */
17
- #store
19
+ #cacheKey
18
20
 
19
21
  /**
20
- * @type {import('../../types/dispatcher.d.ts').default.RequestOptions}
22
+ * @type {import('../../types/cache-interceptor.d.ts').default.CacheStore}
21
23
  */
22
- #requestOptions
24
+ #store
23
25
 
24
26
  /**
25
27
  * @type {import('../../types/dispatcher.d.ts').default.DispatchHandlers}
@@ -27,25 +29,36 @@ class CacheHandler extends DecoratorHandler {
27
29
  #handler
28
30
 
29
31
  /**
30
- * @type {import('../../types/cache-interceptor.d.ts').default.CacheStoreWriteable | undefined}
32
+ * @type {import('node:stream').Writable | undefined}
31
33
  */
32
34
  #writeStream
33
35
 
34
36
  /**
35
37
  * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions} opts
36
- * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} requestOptions
38
+ * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey
37
39
  * @param {import('../../types/dispatcher.d.ts').default.DispatchHandlers} handler
38
40
  */
39
- constructor (opts, requestOptions, handler) {
41
+ constructor (opts, cacheKey, handler) {
40
42
  const { store } = opts
41
43
 
42
44
  super(handler)
43
45
 
44
46
  this.#store = store
45
- this.#requestOptions = requestOptions
47
+ this.#cacheKey = cacheKey
46
48
  this.#handler = handler
47
49
  }
48
50
 
51
+ onConnect (abort) {
52
+ if (this.#writeStream) {
53
+ this.#writeStream.destroy()
54
+ this.#writeStream = undefined
55
+ }
56
+
57
+ if (typeof this.#handler.onConnect === 'function') {
58
+ this.#handler.onConnect(abort)
59
+ }
60
+ }
61
+
49
62
  /**
50
63
  * @see {DispatchHandlers.onHeaders}
51
64
  *
@@ -61,52 +74,48 @@ class CacheHandler extends DecoratorHandler {
61
74
  resume,
62
75
  statusMessage
63
76
  ) {
64
- const downstreamOnHeaders = () => this.#handler.onHeaders(
65
- statusCode,
66
- rawHeaders,
67
- resume,
68
- statusMessage
69
- )
77
+ const downstreamOnHeaders = () => {
78
+ if (typeof this.#handler.onHeaders === 'function') {
79
+ return this.#handler.onHeaders(
80
+ statusCode,
81
+ rawHeaders,
82
+ resume,
83
+ statusMessage
84
+ )
85
+ } else {
86
+ return true
87
+ }
88
+ }
70
89
 
71
90
  if (
72
- !util.safeHTTPMethods.includes(this.#requestOptions.method) &&
91
+ !util.safeHTTPMethods.includes(this.#cacheKey.method) &&
73
92
  statusCode >= 200 &&
74
93
  statusCode <= 399
75
94
  ) {
76
- // https://www.rfc-editor.org/rfc/rfc9111.html#name-invalidating-stored-respons
77
- // Try/catch for if it's synchronous
95
+ // https://www.rfc-editor.org/rfc/rfc9111.html#name-invalidating-stored-response
78
96
  try {
79
- const result = this.#store.deleteByOrigin(this.#requestOptions.origin)
80
- if (
81
- result &&
82
- typeof result.catch === 'function' &&
83
- typeof this.#handler.onError === 'function'
84
- ) {
85
- // Fail silently
86
- result.catch(_ => {})
87
- }
88
- } catch (err) {
97
+ this.#store.delete(this.#cacheKey).catch?.(noop)
98
+ } catch {
89
99
  // Fail silently
90
100
  }
91
-
92
101
  return downstreamOnHeaders()
93
102
  }
94
103
 
95
- const headers = util.parseHeaders(rawHeaders)
104
+ const parsedRawHeaders = util.parseRawHeaders(rawHeaders)
105
+ const headers = util.parseHeaders(parsedRawHeaders)
96
106
 
97
107
  const cacheControlHeader = headers['cache-control']
98
- const contentLengthHeader = headers['content-length']
99
-
100
- if (!cacheControlHeader || !contentLengthHeader || this.#store.isFull) {
101
- // Don't have the headers we need, can't cache
102
- return downstreamOnHeaders()
103
- }
108
+ const isCacheFull = typeof this.#store.isFull !== 'undefined'
109
+ ? this.#store.isFull
110
+ : false
104
111
 
105
- const contentLength = Number(contentLengthHeader)
106
- if (!Number.isInteger(contentLength)) {
112
+ if (
113
+ !cacheControlHeader ||
114
+ isCacheFull
115
+ ) {
116
+ // Don't have the cache control header or the cache is full
107
117
  return downstreamOnHeaders()
108
118
  }
109
-
110
119
  const cacheControlDirectives = parseCacheControlHeader(cacheControlHeader)
111
120
  if (!canCacheResponse(statusCode, headers, cacheControlDirectives)) {
112
121
  return downstreamOnHeaders()
@@ -115,18 +124,18 @@ class CacheHandler extends DecoratorHandler {
115
124
  const now = Date.now()
116
125
  const staleAt = determineStaleAt(now, headers, cacheControlDirectives)
117
126
  if (staleAt) {
118
- const varyDirectives = headers.vary
119
- ? parseVaryHeader(headers.vary, this.#requestOptions.headers)
127
+ const varyDirectives = this.#cacheKey.headers && headers.vary
128
+ ? parseVaryHeader(headers.vary, this.#cacheKey.headers)
120
129
  : undefined
121
130
  const deleteAt = determineDeleteAt(now, cacheControlDirectives, staleAt)
122
131
 
123
132
  const strippedHeaders = stripNecessaryHeaders(
124
133
  rawHeaders,
125
- headers,
134
+ parsedRawHeaders,
126
135
  cacheControlDirectives
127
136
  )
128
137
 
129
- this.#writeStream = this.#store.createWriteStream(this.#requestOptions, {
138
+ this.#writeStream = this.#store.createWriteStream(this.#cacheKey, {
130
139
  statusCode,
131
140
  statusMessage,
132
141
  rawHeaders: strippedHeaders,
@@ -137,18 +146,24 @@ class CacheHandler extends DecoratorHandler {
137
146
  })
138
147
 
139
148
  if (this.#writeStream) {
140
- this.#writeStream.on('drain', resume)
141
- this.#writeStream.on('error', () => {
142
- this.#writeStream = undefined
143
- resume()
144
- })
149
+ const handler = this
150
+ this.#writeStream
151
+ .on('drain', resume)
152
+ .on('error', function () {
153
+ // TODO (fix): Make error somehow observable?
154
+ })
155
+ .on('close', function () {
156
+ if (handler.#writeStream === this) {
157
+ handler.#writeStream = undefined
158
+ }
159
+
160
+ // TODO (fix): Should we resume even if was paused downstream?
161
+ resume()
162
+ })
145
163
  }
146
164
  }
147
165
 
148
- if (typeof this.#handler.onHeaders === 'function') {
149
- return downstreamOnHeaders()
150
- }
151
- return false
166
+ return downstreamOnHeaders()
152
167
  }
153
168
 
154
169
  /**
@@ -178,10 +193,6 @@ class CacheHandler extends DecoratorHandler {
178
193
  */
179
194
  onComplete (rawTrailers) {
180
195
  if (this.#writeStream) {
181
- if (rawTrailers) {
182
- this.#writeStream.rawTrailers = rawTrailers
183
- }
184
-
185
196
  this.#writeStream.end()
186
197
  }
187
198
 
@@ -211,7 +222,7 @@ class CacheHandler extends DecoratorHandler {
211
222
  * @see https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen
212
223
  *
213
224
  * @param {number} statusCode
214
- * @param {Record<string, string>} headers
225
+ * @param {Record<string, string | string[]>} headers
215
226
  * @param {import('../util/cache.js').CacheControlDirectives} cacheControlDirectives
216
227
  */
217
228
  function canCacheResponse (statusCode, headers, cacheControlDirectives) {
@@ -223,7 +234,6 @@ function canCacheResponse (statusCode, headers, cacheControlDirectives) {
223
234
  }
224
235
 
225
236
  if (
226
- !cacheControlDirectives.public ||
227
237
  cacheControlDirectives.private === true ||
228
238
  cacheControlDirectives['no-cache'] === true ||
229
239
  cacheControlDirectives['no-store']
@@ -237,7 +247,11 @@ function canCacheResponse (statusCode, headers, cacheControlDirectives) {
237
247
  }
238
248
 
239
249
  // https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen
240
- if (headers['authorization']) {
250
+ if (headers.authorization) {
251
+ if (!cacheControlDirectives.public || typeof headers.authorization !== 'string') {
252
+ return false
253
+ }
254
+
241
255
  if (
242
256
  Array.isArray(cacheControlDirectives['no-cache']) &&
243
257
  cacheControlDirectives['no-cache'].includes('authorization')
@@ -282,9 +296,12 @@ function determineStaleAt (now, headers, cacheControlDirectives) {
282
296
  return now + (maxAge * 1000)
283
297
  }
284
298
 
285
- if (headers.expire) {
299
+ if (headers.expire && typeof headers.expire === 'string') {
286
300
  // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3
287
- return now + (Date.now() - new Date(headers.expire).getTime())
301
+ const expiresDate = new Date(headers.expire)
302
+ if (expiresDate instanceof Date && !isNaN(expiresDate)) {
303
+ return now + (Date.now() - expiresDate.getTime())
304
+ }
288
305
  }
289
306
 
290
307
  return undefined
@@ -306,11 +323,11 @@ function determineDeleteAt (now, cacheControlDirectives, staleAt) {
306
323
  /**
307
324
  * Strips headers required to be removed in cached responses
308
325
  * @param {Buffer[]} rawHeaders
309
- * @param {Record<string, string | string[]>} parsedHeaders
326
+ * @param {string[]} parsedRawHeaders
310
327
  * @param {import('../util/cache.js').CacheControlDirectives} cacheControlDirectives
311
- * @returns {(Buffer|Buffer[])[]}
328
+ * @returns {Buffer[]}
312
329
  */
313
- function stripNecessaryHeaders (rawHeaders, parsedHeaders, cacheControlDirectives) {
330
+ function stripNecessaryHeaders (rawHeaders, parsedRawHeaders, cacheControlDirectives) {
314
331
  const headersToRemove = ['connection']
315
332
 
316
333
  if (Array.isArray(cacheControlDirectives['no-cache'])) {
@@ -321,39 +338,52 @@ function stripNecessaryHeaders (rawHeaders, parsedHeaders, cacheControlDirective
321
338
  headersToRemove.push(...cacheControlDirectives['private'])
322
339
  }
323
340
 
324
- /**
325
- * These are the headers that are okay to cache. If this is assigned, we need
326
- * to remake the buffer representation of the headers
327
- * @type {Record<string, string | string[]> | undefined}
328
- */
329
341
  let strippedHeaders
330
342
 
331
- const headerNames = Object.keys(parsedHeaders)
332
- for (let i = 0; i < headerNames.length; i++) {
333
- const header = headerNames[i]
343
+ let offset = 0
344
+ for (let i = 0; i < parsedRawHeaders.length; i += 2) {
345
+ const headerName = parsedRawHeaders[i]
334
346
 
335
- if (headersToRemove.indexOf(header) !== -1) {
336
- // We have a at least one header we want to remove
347
+ if (headersToRemove.includes(headerName)) {
348
+ // We have at least one header we want to remove
337
349
  if (!strippedHeaders) {
338
- // This is the first header we want to remove, let's create the object
339
- // and backfill the previous headers into it
340
- strippedHeaders = {}
341
-
342
- for (let j = 0; j < i; j++) {
343
- strippedHeaders[headerNames[j]] = parsedHeaders[headerNames[j]]
350
+ // This is the first header we want to remove, let's create the array
351
+ // Since we're stripping headers, this will over allocate. We'll trim
352
+ // it later.
353
+ strippedHeaders = new Array(parsedRawHeaders.length)
354
+
355
+ // Backfill the previous headers into it
356
+ for (let j = 0; j < i; j += 2) {
357
+ strippedHeaders[j] = parsedRawHeaders[j]
358
+ strippedHeaders[j + 1] = parsedRawHeaders[j + 1]
344
359
  }
345
360
  }
346
361
 
362
+ // We can't map indices 1:1 from stripped headers to rawHeaders without
363
+ // creating holes (if we skip a header, we now have two holes where at
364
+ // element should be). So, let's keep an offset to keep strippedHeaders
365
+ // flattened. We can also use this at the end for trimming the empty
366
+ // elements off of strippedHeaders.
367
+ offset += 2
368
+
347
369
  continue
348
370
  }
349
371
 
350
- // This header is fine. Let's add it to strippedHeaders if it exists.
372
+ // We want to keep this header. Let's add it to strippedHeaders if it exists
351
373
  if (strippedHeaders) {
352
- strippedHeaders[header] = parsedHeaders[header]
374
+ strippedHeaders[i - offset] = parsedRawHeaders[i]
375
+ strippedHeaders[i + 1 - offset] = parsedRawHeaders[i + 1]
353
376
  }
354
377
  }
355
378
 
356
- return strippedHeaders ? util.encodeHeaders(strippedHeaders) : rawHeaders
379
+ if (strippedHeaders) {
380
+ // Trim off the empty values at the end
381
+ strippedHeaders.length -= offset
382
+ }
383
+
384
+ return strippedHeaders
385
+ ? util.encodeRawHeaders(strippedHeaders)
386
+ : rawHeaders
357
387
  }
358
388
 
359
389
  module.exports = CacheHandler
@@ -1,5 +1,6 @@
1
1
  'use strict'
2
2
 
3
+ const assert = require('node:assert')
3
4
  const DecoratorHandler = require('../handler/decorator-handler')
4
5
 
5
6
  /**
@@ -19,29 +20,36 @@ const DecoratorHandler = require('../handler/decorator-handler')
19
20
  class CacheRevalidationHandler extends DecoratorHandler {
20
21
  #successful = false
21
22
  /**
22
- * @type {(() => void)}
23
+ * @type {((boolean) => void) | null}
23
24
  */
24
- #successCallback
25
+ #callback
25
26
  /**
26
27
  * @type {(import('../../types/dispatcher.d.ts').default.DispatchHandlers)}
27
28
  */
28
29
  #handler
29
30
 
31
+ #abort
32
+
30
33
  /**
31
- * @param {() => void} successCallback Function to call if the cached value is valid
34
+ * @param {(boolean) => void} callback Function to call if the cached value is valid
32
35
  * @param {import('../../types/dispatcher.d.ts').default.DispatchHandlers} handler
33
36
  */
34
- constructor (successCallback, handler) {
35
- if (typeof successCallback !== 'function') {
36
- throw new TypeError('successCallback must be a function')
37
+ constructor (callback, handler) {
38
+ if (typeof callback !== 'function') {
39
+ throw new TypeError('callback must be a function')
37
40
  }
38
41
 
39
42
  super(handler)
40
43
 
41
- this.#successCallback = successCallback
44
+ this.#callback = callback
42
45
  this.#handler = handler
43
46
  }
44
47
 
48
+ onConnect (abort) {
49
+ this.#successful = false
50
+ this.#abort = abort
51
+ }
52
+
45
53
  /**
46
54
  * @see {DispatchHandlers.onHeaders}
47
55
  *
@@ -57,13 +65,21 @@ class CacheRevalidationHandler extends DecoratorHandler {
57
65
  resume,
58
66
  statusMessage
59
67
  ) {
68
+ assert(this.#callback != null)
69
+
60
70
  // https://www.rfc-editor.org/rfc/rfc9111.html#name-handling-a-validation-respo
61
- if (statusCode === 304) {
62
- this.#successful = true
63
- this.#successCallback()
71
+ this.#successful = statusCode === 304
72
+ this.#callback(this.#successful)
73
+ this.#callback = null
74
+
75
+ if (this.#successful) {
64
76
  return true
65
77
  }
66
78
 
79
+ if (typeof this.#handler.onConnect === 'function') {
80
+ this.#handler.onConnect(this.#abort)
81
+ }
82
+
67
83
  if (typeof this.#handler.onHeaders === 'function') {
68
84
  return this.#handler.onHeaders(
69
85
  statusCode,
@@ -72,7 +88,8 @@ class CacheRevalidationHandler extends DecoratorHandler {
72
88
  statusMessage
73
89
  )
74
90
  }
75
- return false
91
+
92
+ return true
76
93
  }
77
94
 
78
95
  /**
@@ -90,7 +107,7 @@ class CacheRevalidationHandler extends DecoratorHandler {
90
107
  return this.#handler.onData(chunk)
91
108
  }
92
109
 
93
- return false
110
+ return true
94
111
  }
95
112
 
96
113
  /**
@@ -99,7 +116,11 @@ class CacheRevalidationHandler extends DecoratorHandler {
99
116
  * @param {string[] | null} rawTrailers
100
117
  */
101
118
  onComplete (rawTrailers) {
102
- if (!this.#successful && typeof this.#handler.onComplete === 'function') {
119
+ if (this.#successful) {
120
+ return
121
+ }
122
+
123
+ if (typeof this.#handler.onComplete === 'function') {
103
124
  this.#handler.onComplete(rawTrailers)
104
125
  }
105
126
  }
@@ -110,8 +131,19 @@ class CacheRevalidationHandler extends DecoratorHandler {
110
131
  * @param {Error} err
111
132
  */
112
133
  onError (err) {
134
+ if (this.#successful) {
135
+ return
136
+ }
137
+
138
+ if (this.#callback) {
139
+ this.#callback(false)
140
+ this.#callback = null
141
+ }
142
+
113
143
  if (typeof this.#handler.onError === 'function') {
114
144
  this.#handler.onError(err)
145
+ } else {
146
+ throw err
115
147
  }
116
148
  }
117
149
  }
@@ -75,7 +75,8 @@ class RedirectHandler {
75
75
  this.opts.body &&
76
76
  typeof this.opts.body !== 'string' &&
77
77
  !ArrayBuffer.isView(this.opts.body) &&
78
- util.isIterable(this.opts.body)
78
+ util.isIterable(this.opts.body) &&
79
+ !util.isFormDataLike(this.opts.body)
79
80
  ) {
80
81
  // TODO: Should we allow re-using iterable if !this.opts.idempotent
81
82
  // or through some other flag?
@@ -227,9 +228,10 @@ function cleanRequestHeaders (headers, removeContent, unknownOrigin) {
227
228
  }
228
229
  }
229
230
  } else if (headers && typeof headers === 'object') {
230
- for (const key of Object.keys(headers)) {
231
+ const entries = typeof headers[Symbol.iterator] === 'function' ? headers : Object.entries(headers)
232
+ for (const [key, value] of entries) {
231
233
  if (!shouldRemoveHeader(key, removeContent, unknownOrigin)) {
232
- ret.push(key, headers[key])
234
+ ret.push(key, value)
233
235
  }
234
236
  }
235
237
  } else {