undici 7.16.0 → 7.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +53 -1
  2. package/docs/docs/api/Client.md +1 -0
  3. package/docs/docs/api/DiagnosticsChannel.md +57 -0
  4. package/docs/docs/api/Dispatcher.md +86 -0
  5. package/docs/docs/api/RoundRobinPool.md +145 -0
  6. package/docs/docs/api/WebSocket.md +21 -0
  7. package/docs/docs/best-practices/crawling.md +58 -0
  8. package/index.js +4 -1
  9. package/lib/api/api-upgrade.js +2 -1
  10. package/lib/core/connect.js +4 -1
  11. package/lib/core/diagnostics.js +28 -1
  12. package/lib/core/symbols.js +3 -0
  13. package/lib/core/util.js +29 -31
  14. package/lib/dispatcher/balanced-pool.js +10 -0
  15. package/lib/dispatcher/client-h1.js +0 -16
  16. package/lib/dispatcher/client-h2.js +153 -23
  17. package/lib/dispatcher/client.js +7 -2
  18. package/lib/dispatcher/dispatcher-base.js +11 -12
  19. package/lib/dispatcher/h2c-client.js +7 -78
  20. package/lib/dispatcher/pool-base.js +1 -1
  21. package/lib/dispatcher/proxy-agent.js +13 -2
  22. package/lib/dispatcher/round-robin-pool.js +137 -0
  23. package/lib/encoding/index.js +33 -0
  24. package/lib/handler/cache-handler.js +84 -27
  25. package/lib/handler/deduplication-handler.js +216 -0
  26. package/lib/handler/retry-handler.js +0 -2
  27. package/lib/interceptor/cache.js +35 -17
  28. package/lib/interceptor/decompress.js +2 -1
  29. package/lib/interceptor/deduplicate.js +109 -0
  30. package/lib/interceptor/dns.js +55 -13
  31. package/lib/mock/mock-utils.js +1 -2
  32. package/lib/mock/snapshot-agent.js +11 -5
  33. package/lib/mock/snapshot-recorder.js +12 -4
  34. package/lib/mock/snapshot-utils.js +4 -4
  35. package/lib/util/cache.js +29 -1
  36. package/lib/util/runtime-features.js +124 -0
  37. package/lib/web/cookies/parse.js +1 -1
  38. package/lib/web/fetch/body.js +29 -39
  39. package/lib/web/fetch/data-url.js +12 -160
  40. package/lib/web/fetch/formdata-parser.js +204 -127
  41. package/lib/web/fetch/index.js +18 -6
  42. package/lib/web/fetch/request.js +6 -0
  43. package/lib/web/fetch/response.js +2 -3
  44. package/lib/web/fetch/util.js +2 -65
  45. package/lib/web/infra/index.js +229 -0
  46. package/lib/web/subresource-integrity/subresource-integrity.js +6 -5
  47. package/lib/web/webidl/index.js +4 -2
  48. package/lib/web/websocket/connection.js +31 -21
  49. package/lib/web/websocket/frame.js +9 -15
  50. package/lib/web/websocket/stream/websocketstream.js +1 -1
  51. package/lib/web/websocket/util.js +2 -1
  52. package/package.json +5 -4
  53. package/types/agent.d.ts +1 -1
  54. package/types/api.d.ts +2 -2
  55. package/types/balanced-pool.d.ts +2 -1
  56. package/types/cache-interceptor.d.ts +1 -0
  57. package/types/client.d.ts +1 -1
  58. package/types/connector.d.ts +2 -2
  59. package/types/diagnostics-channel.d.ts +2 -2
  60. package/types/dispatcher.d.ts +12 -12
  61. package/types/fetch.d.ts +4 -4
  62. package/types/formdata.d.ts +1 -1
  63. package/types/h2c-client.d.ts +1 -1
  64. package/types/index.d.ts +9 -1
  65. package/types/interceptors.d.ts +36 -2
  66. package/types/pool.d.ts +1 -1
  67. package/types/readable.d.ts +2 -2
  68. package/types/round-robin-pool.d.ts +41 -0
  69. package/types/websocket.d.ts +9 -9
@@ -1,16 +1,9 @@
1
1
  'use strict'
2
- const { connect } = require('node:net')
3
2
 
4
- const { kClose, kDestroy } = require('../core/symbols')
5
3
  const { InvalidArgumentError } = require('../core/errors')
6
- const util = require('../core/util')
7
-
8
4
  const Client = require('./client')
9
- const DispatcherBase = require('./dispatcher-base')
10
-
11
- class H2CClient extends DispatcherBase {
12
- #client = null
13
5
 
6
+ class H2CClient extends Client {
14
7
  constructor (origin, clientOpts) {
15
8
  if (typeof origin === 'string') {
16
9
  origin = new URL(origin)
@@ -23,14 +16,14 @@ class H2CClient extends DispatcherBase {
23
16
  }
24
17
 
25
18
  const { connect, maxConcurrentStreams, pipelining, ...opts } =
26
- clientOpts ?? {}
19
+ clientOpts ?? {}
27
20
  let defaultMaxConcurrentStreams = 100
28
21
  let defaultPipelining = 100
29
22
 
30
23
  if (
31
24
  maxConcurrentStreams != null &&
32
- Number.isInteger(maxConcurrentStreams) &&
33
- maxConcurrentStreams > 0
25
+ Number.isInteger(maxConcurrentStreams) &&
26
+ maxConcurrentStreams > 0
34
27
  ) {
35
28
  defaultMaxConcurrentStreams = maxConcurrentStreams
36
29
  }
@@ -45,78 +38,14 @@ class H2CClient extends DispatcherBase {
45
38
  )
46
39
  }
47
40
 
48
- super()
49
-
50
- this.#client = new Client(origin, {
41
+ super(origin, {
51
42
  ...opts,
52
- connect: this.#buildConnector(connect),
53
43
  maxConcurrentStreams: defaultMaxConcurrentStreams,
54
44
  pipelining: defaultPipelining,
55
- allowH2: true
45
+ allowH2: true,
46
+ useH2c: true
56
47
  })
57
48
  }
58
-
59
- #buildConnector (connectOpts) {
60
- return (opts, callback) => {
61
- const timeout = connectOpts?.connectOpts ?? 10e3
62
- const { hostname, port, pathname } = opts
63
- const socket = connect({
64
- ...opts,
65
- host: hostname,
66
- port,
67
- pathname
68
- })
69
-
70
- // Set TCP keep alive options on the socket here instead of in connect() for the case of assigning the socket
71
- if (opts.keepAlive == null || opts.keepAlive) {
72
- const keepAliveInitialDelay =
73
- opts.keepAliveInitialDelay == null ? 60e3 : opts.keepAliveInitialDelay
74
- socket.setKeepAlive(true, keepAliveInitialDelay)
75
- }
76
-
77
- socket.alpnProtocol = 'h2'
78
-
79
- const clearConnectTimeout = util.setupConnectTimeout(
80
- new WeakRef(socket),
81
- { timeout, hostname, port }
82
- )
83
-
84
- socket
85
- .setNoDelay(true)
86
- .once('connect', function () {
87
- queueMicrotask(clearConnectTimeout)
88
-
89
- if (callback) {
90
- const cb = callback
91
- callback = null
92
- cb(null, this)
93
- }
94
- })
95
- .on('error', function (err) {
96
- queueMicrotask(clearConnectTimeout)
97
-
98
- if (callback) {
99
- const cb = callback
100
- callback = null
101
- cb(err)
102
- }
103
- })
104
-
105
- return socket
106
- }
107
- }
108
-
109
- dispatch (opts, handler) {
110
- return this.#client.dispatch(opts, handler)
111
- }
112
-
113
- [kClose] () {
114
- return this.#client.close()
115
- }
116
-
117
- [kDestroy] () {
118
- return this.#client.destroy()
119
- }
120
49
  }
121
50
 
122
51
  module.exports = H2CClient
@@ -52,7 +52,7 @@ class PoolBase extends DispatcherBase {
52
52
  for (let i = 0; i < this[kClients].length; i++) {
53
53
  closeAll[i] = this[kClients][i].close()
54
54
  }
55
- Promise.all(closeAll)
55
+ return Promise.all(closeAll)
56
56
  .then(this[kClosedResolve])
57
57
  }
58
58
  }
@@ -7,6 +7,7 @@ const DispatcherBase = require('./dispatcher-base')
7
7
  const { InvalidArgumentError, RequestAbortedError, SecureProxyConnectionError } = require('../core/errors')
8
8
  const buildConnector = require('../core/connect')
9
9
  const Client = require('./client')
10
+ const { channels } = require('../core/diagnostics')
10
11
 
11
12
  const kAgent = Symbol('proxy agent')
12
13
  const kClient = Symbol('proxy client')
@@ -150,7 +151,7 @@ class ProxyAgent extends DispatcherBase {
150
151
  requestedPath += `:${defaultProtocolPort(opts.protocol)}`
151
152
  }
152
153
  try {
153
- const { socket, statusCode } = await this[kClient].connect({
154
+ const connectParams = {
154
155
  origin,
155
156
  port,
156
157
  path: requestedPath,
@@ -161,11 +162,21 @@ class ProxyAgent extends DispatcherBase {
161
162
  ...(opts.connections == null || opts.connections > 0 ? { 'proxy-connection': 'keep-alive' } : {})
162
163
  },
163
164
  servername: this[kProxyTls]?.servername || proxyHostname
164
- })
165
+ }
166
+ const { socket, statusCode } = await this[kClient].connect(connectParams)
165
167
  if (statusCode !== 200) {
166
168
  socket.on('error', noop).destroy()
167
169
  callback(new RequestAbortedError(`Proxy response (${statusCode}) !== 200 when HTTP Tunneling`))
170
+ return
168
171
  }
172
+
173
+ if (channels.proxyConnected.hasSubscribers) {
174
+ channels.proxyConnected.publish({
175
+ socket,
176
+ connectParams
177
+ })
178
+ }
179
+
169
180
  if (opts.protocol !== 'https:') {
170
181
  callback(null, socket)
171
182
  return
@@ -0,0 +1,137 @@
1
+ 'use strict'
2
+
3
+ const {
4
+ PoolBase,
5
+ kClients,
6
+ kNeedDrain,
7
+ kAddClient,
8
+ kGetDispatcher,
9
+ kRemoveClient
10
+ } = require('./pool-base')
11
+ const Client = require('./client')
12
+ const {
13
+ InvalidArgumentError
14
+ } = require('../core/errors')
15
+ const util = require('../core/util')
16
+ const { kUrl } = require('../core/symbols')
17
+ const buildConnector = require('../core/connect')
18
+
19
+ const kOptions = Symbol('options')
20
+ const kConnections = Symbol('connections')
21
+ const kFactory = Symbol('factory')
22
+ const kIndex = Symbol('index')
23
+
24
+ function defaultFactory (origin, opts) {
25
+ return new Client(origin, opts)
26
+ }
27
+
28
+ class RoundRobinPool extends PoolBase {
29
+ constructor (origin, {
30
+ connections,
31
+ factory = defaultFactory,
32
+ connect,
33
+ connectTimeout,
34
+ tls,
35
+ maxCachedSessions,
36
+ socketPath,
37
+ autoSelectFamily,
38
+ autoSelectFamilyAttemptTimeout,
39
+ allowH2,
40
+ clientTtl,
41
+ ...options
42
+ } = {}) {
43
+ if (connections != null && (!Number.isFinite(connections) || connections < 0)) {
44
+ throw new InvalidArgumentError('invalid connections')
45
+ }
46
+
47
+ if (typeof factory !== 'function') {
48
+ throw new InvalidArgumentError('factory must be a function.')
49
+ }
50
+
51
+ if (connect != null && typeof connect !== 'function' && typeof connect !== 'object') {
52
+ throw new InvalidArgumentError('connect must be a function or an object')
53
+ }
54
+
55
+ if (typeof connect !== 'function') {
56
+ connect = buildConnector({
57
+ ...tls,
58
+ maxCachedSessions,
59
+ allowH2,
60
+ socketPath,
61
+ timeout: connectTimeout,
62
+ ...(typeof autoSelectFamily === 'boolean' ? { autoSelectFamily, autoSelectFamilyAttemptTimeout } : undefined),
63
+ ...connect
64
+ })
65
+ }
66
+
67
+ super()
68
+
69
+ this[kConnections] = connections || null
70
+ this[kUrl] = util.parseOrigin(origin)
71
+ this[kOptions] = { ...util.deepClone(options), connect, allowH2, clientTtl }
72
+ this[kOptions].interceptors = options.interceptors
73
+ ? { ...options.interceptors }
74
+ : undefined
75
+ this[kFactory] = factory
76
+ this[kIndex] = -1
77
+
78
+ this.on('connect', (origin, targets) => {
79
+ if (clientTtl != null && clientTtl > 0) {
80
+ for (const target of targets) {
81
+ Object.assign(target, { ttl: Date.now() })
82
+ }
83
+ }
84
+ })
85
+
86
+ this.on('connectionError', (origin, targets, error) => {
87
+ for (const target of targets) {
88
+ const idx = this[kClients].indexOf(target)
89
+ if (idx !== -1) {
90
+ this[kClients].splice(idx, 1)
91
+ }
92
+ }
93
+ })
94
+ }
95
+
96
+ [kGetDispatcher] () {
97
+ const clientTtlOption = this[kOptions].clientTtl
98
+ const clientsLength = this[kClients].length
99
+
100
+ // If we have no clients yet, create one
101
+ if (clientsLength === 0) {
102
+ const dispatcher = this[kFactory](this[kUrl], this[kOptions])
103
+ this[kAddClient](dispatcher)
104
+ return dispatcher
105
+ }
106
+
107
+ // Round-robin through existing clients
108
+ let checked = 0
109
+ while (checked < clientsLength) {
110
+ this[kIndex] = (this[kIndex] + 1) % clientsLength
111
+ const client = this[kClients][this[kIndex]]
112
+
113
+ // Check if client is stale (TTL expired)
114
+ if (clientTtlOption != null && clientTtlOption > 0 && client.ttl && ((Date.now() - client.ttl) > clientTtlOption)) {
115
+ this[kRemoveClient](client)
116
+ checked++
117
+ continue
118
+ }
119
+
120
+ // Return client if it's not draining
121
+ if (!client[kNeedDrain]) {
122
+ return client
123
+ }
124
+
125
+ checked++
126
+ }
127
+
128
+ // All clients are busy, create a new one if we haven't reached the limit
129
+ if (!this[kConnections] || clientsLength < this[kConnections]) {
130
+ const dispatcher = this[kFactory](this[kUrl], this[kOptions])
131
+ this[kAddClient](dispatcher)
132
+ return dispatcher
133
+ }
134
+ }
135
+ }
136
+
137
+ module.exports = RoundRobinPool
@@ -0,0 +1,33 @@
1
+ 'use strict'
2
+
3
+ const textDecoder = new TextDecoder()
4
+
5
+ /**
6
+ * @see https://encoding.spec.whatwg.org/#utf-8-decode
7
+ * @param {Uint8Array} buffer
8
+ */
9
+ function utf8DecodeBytes (buffer) {
10
+ if (buffer.length === 0) {
11
+ return ''
12
+ }
13
+
14
+ // 1. Let buffer be the result of peeking three bytes from
15
+ // ioQueue, converted to a byte sequence.
16
+
17
+ // 2. If buffer is 0xEF 0xBB 0xBF, then read three
18
+ // bytes from ioQueue. (Do nothing with those bytes.)
19
+ if (buffer[0] === 0xEF && buffer[1] === 0xBB && buffer[2] === 0xBF) {
20
+ buffer = buffer.subarray(3)
21
+ }
22
+
23
+ // 3. Process a queue with an instance of UTF-8’s
24
+ // decoder, ioQueue, output, and "replacement".
25
+ const output = textDecoder.decode(buffer)
26
+
27
+ // 4. Return output.
28
+ return output
29
+ }
30
+
31
+ module.exports = {
32
+ utf8DecodeBytes
33
+ }
@@ -17,11 +17,11 @@ const HEURISTICALLY_CACHEABLE_STATUS_CODES = [
17
17
 
18
18
  // Status codes which semantic is not handled by the cache
19
19
  // https://datatracker.ietf.org/doc/html/rfc9111#section-3
20
- // This list should not grow beyond 206 and 304 unless the RFC is updated
20
+ // This list should not grow beyond 206 unless the RFC is updated
21
21
  // by a newer one including more. Please introduce another list if
22
22
  // implementing caching of responses with the 'must-understand' directive.
23
23
  const NOT_UNDERSTOOD_STATUS_CODES = [
24
- 206, 304
24
+ 206
25
25
  ]
26
26
 
27
27
  const MAX_RESPONSE_AGE = 2147483647000
@@ -104,6 +104,7 @@ class CacheHandler {
104
104
  resHeaders,
105
105
  statusMessage
106
106
  )
107
+ const handler = this
107
108
 
108
109
  if (
109
110
  !util.safeHTTPMethods.includes(this.#cacheKey.method) &&
@@ -189,36 +190,92 @@ class CacheHandler {
189
190
  deleteAt
190
191
  }
191
192
 
192
- if (typeof resHeaders.etag === 'string' && isEtagUsable(resHeaders.etag)) {
193
- value.etag = resHeaders.etag
194
- }
193
+ // Not modified, re-use the cached value
194
+ // https://www.rfc-editor.org/rfc/rfc9111.html#name-handling-304-not-modified
195
+ if (statusCode === 304) {
196
+ /**
197
+ * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue}
198
+ */
199
+ const cachedValue = this.#store.get(this.#cacheKey)
200
+ if (!cachedValue) {
201
+ // Do not create a new cache entry, as a 304 won't have a body - so cannot be cached.
202
+ return downstreamOnHeaders()
203
+ }
195
204
 
196
- this.#writeStream = this.#store.createWriteStream(this.#cacheKey, value)
197
- if (!this.#writeStream) {
198
- return downstreamOnHeaders()
199
- }
205
+ // Re-use the cached value: statuscode, statusmessage, headers and body
206
+ value.statusCode = cachedValue.statusCode
207
+ value.statusMessage = cachedValue.statusMessage
208
+ value.etag = cachedValue.etag
209
+ value.headers = { ...cachedValue.headers, ...strippedHeaders }
200
210
 
201
- const handler = this
202
- this.#writeStream
203
- .on('drain', () => controller.resume())
204
- .on('error', function () {
205
- // TODO (fix): Make error somehow observable?
206
- handler.#writeStream = undefined
207
-
208
- // Delete the value in case the cache store is holding onto state from
209
- // the call to createWriteStream
210
- handler.#store.delete(handler.#cacheKey)
211
- })
212
- .on('close', function () {
213
- if (handler.#writeStream === this) {
214
- handler.#writeStream = undefined
211
+ downstreamOnHeaders()
212
+
213
+ this.#writeStream = this.#store.createWriteStream(this.#cacheKey, value)
214
+
215
+ if (!this.#writeStream || !cachedValue?.body) {
216
+ return
217
+ }
218
+
219
+ const bodyIterator = cachedValue.body.values()
220
+
221
+ const streamCachedBody = () => {
222
+ for (const chunk of bodyIterator) {
223
+ const full = this.#writeStream.write(chunk) === false
224
+ this.#handler.onResponseData?.(controller, chunk)
225
+ // when stream is full stop writing until we get a 'drain' event
226
+ if (full) {
227
+ break
228
+ }
215
229
  }
230
+ }
216
231
 
217
- // TODO (fix): Should we resume even if was paused downstream?
218
- controller.resume()
219
- })
232
+ this.#writeStream
233
+ .on('error', function () {
234
+ handler.#writeStream = undefined
235
+ handler.#store.delete(handler.#cacheKey)
236
+ })
237
+ .on('drain', () => {
238
+ streamCachedBody()
239
+ })
240
+ .on('close', function () {
241
+ if (handler.#writeStream === this) {
242
+ handler.#writeStream = undefined
243
+ }
244
+ })
245
+
246
+ streamCachedBody()
247
+ } else {
248
+ if (typeof resHeaders.etag === 'string' && isEtagUsable(resHeaders.etag)) {
249
+ value.etag = resHeaders.etag
250
+ }
220
251
 
221
- return downstreamOnHeaders()
252
+ this.#writeStream = this.#store.createWriteStream(this.#cacheKey, value)
253
+
254
+ if (!this.#writeStream) {
255
+ return downstreamOnHeaders()
256
+ }
257
+
258
+ this.#writeStream
259
+ .on('drain', () => controller.resume())
260
+ .on('error', function () {
261
+ // TODO (fix): Make error somehow observable?
262
+ handler.#writeStream = undefined
263
+
264
+ // Delete the value in case the cache store is holding onto state from
265
+ // the call to createWriteStream
266
+ handler.#store.delete(handler.#cacheKey)
267
+ })
268
+ .on('close', function () {
269
+ if (handler.#writeStream === this) {
270
+ handler.#writeStream = undefined
271
+ }
272
+
273
+ // TODO (fix): Should we resume even if was paused downstream?
274
+ controller.resume()
275
+ })
276
+
277
+ downstreamOnHeaders()
278
+ }
222
279
  }
223
280
 
224
281
  onResponseData (controller, chunk) {
@@ -0,0 +1,216 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * @typedef {import('../../types/dispatcher.d.ts').default.DispatchHandler} DispatchHandler
5
+ */
6
+
7
+ /**
8
+ * Handler that buffers response data and notifies multiple waiting handlers.
9
+ * Used for request deduplication.
10
+ *
11
+ * @implements {DispatchHandler}
12
+ */
13
+ class DeduplicationHandler {
14
+ /**
15
+ * @type {DispatchHandler}
16
+ */
17
+ #primaryHandler
18
+
19
+ /**
20
+ * @type {DispatchHandler[]}
21
+ */
22
+ #waitingHandlers = []
23
+
24
+ /**
25
+ * @type {Buffer[]}
26
+ */
27
+ #chunks = []
28
+
29
+ /**
30
+ * @type {number}
31
+ */
32
+ #statusCode = 0
33
+
34
+ /**
35
+ * @type {Record<string, string | string[]>}
36
+ */
37
+ #headers = {}
38
+
39
+ /**
40
+ * @type {string}
41
+ */
42
+ #statusMessage = ''
43
+
44
+ /**
45
+ * @type {boolean}
46
+ */
47
+ #aborted = false
48
+
49
+ /**
50
+ * @type {import('../../types/dispatcher.d.ts').default.DispatchController | null}
51
+ */
52
+ #controller = null
53
+
54
+ /**
55
+ * @type {(() => void) | null}
56
+ */
57
+ #onComplete = null
58
+
59
+ /**
60
+ * @param {DispatchHandler} primaryHandler The primary handler
61
+ * @param {() => void} onComplete Callback when request completes
62
+ */
63
+ constructor (primaryHandler, onComplete) {
64
+ this.#primaryHandler = primaryHandler
65
+ this.#onComplete = onComplete
66
+ }
67
+
68
+ /**
69
+ * Add a waiting handler that will receive the buffered response
70
+ * @param {DispatchHandler} handler
71
+ */
72
+ addWaitingHandler (handler) {
73
+ this.#waitingHandlers.push(handler)
74
+ }
75
+
76
+ /**
77
+ * @param {() => void} abort
78
+ * @param {any} context
79
+ */
80
+ onRequestStart (controller, context) {
81
+ this.#controller = controller
82
+ this.#primaryHandler.onRequestStart?.(controller, context)
83
+ }
84
+
85
+ /**
86
+ * @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller
87
+ * @param {number} statusCode
88
+ * @param {import('../../types/header.d.ts').IncomingHttpHeaders} headers
89
+ * @param {Socket} socket
90
+ */
91
+ onRequestUpgrade (controller, statusCode, headers, socket) {
92
+ this.#primaryHandler.onRequestUpgrade?.(controller, statusCode, headers, socket)
93
+ }
94
+
95
+ /**
96
+ * @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller
97
+ * @param {number} statusCode
98
+ * @param {Record<string, string | string[]>} headers
99
+ * @param {string} statusMessage
100
+ */
101
+ onResponseStart (controller, statusCode, headers, statusMessage) {
102
+ this.#statusCode = statusCode
103
+ this.#headers = headers
104
+ this.#statusMessage = statusMessage
105
+ this.#primaryHandler.onResponseStart?.(controller, statusCode, headers, statusMessage)
106
+ }
107
+
108
+ /**
109
+ * @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller
110
+ * @param {Buffer} chunk
111
+ */
112
+ onResponseData (controller, chunk) {
113
+ // Buffer the chunk for waiting handlers
114
+ this.#chunks.push(Buffer.from(chunk))
115
+ this.#primaryHandler.onResponseData?.(controller, chunk)
116
+ }
117
+
118
+ /**
119
+ * @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller
120
+ * @param {object} trailers
121
+ */
122
+ onResponseEnd (controller, trailers) {
123
+ this.#primaryHandler.onResponseEnd?.(controller, trailers)
124
+ this.#notifyWaitingHandlers()
125
+ this.#onComplete?.()
126
+ }
127
+
128
+ /**
129
+ * @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller
130
+ * @param {Error} err
131
+ */
132
+ onResponseError (controller, err) {
133
+ this.#aborted = true
134
+ this.#primaryHandler.onResponseError?.(controller, err)
135
+ this.#notifyWaitingHandlersError(err)
136
+ this.#onComplete?.()
137
+ }
138
+
139
+ /**
140
+ * Notify all waiting handlers with the buffered response
141
+ */
142
+ #notifyWaitingHandlers () {
143
+ const body = Buffer.concat(this.#chunks)
144
+
145
+ for (const handler of this.#waitingHandlers) {
146
+ // Create a simple controller for each waiting handler
147
+ const waitingController = {
148
+ resume () {},
149
+ pause () {},
150
+ get paused () { return false },
151
+ get aborted () { return false },
152
+ get reason () { return null },
153
+ abort () {}
154
+ }
155
+
156
+ try {
157
+ handler.onRequestStart?.(waitingController, null)
158
+
159
+ if (waitingController.aborted) {
160
+ continue
161
+ }
162
+
163
+ handler.onResponseStart?.(
164
+ waitingController,
165
+ this.#statusCode,
166
+ this.#headers,
167
+ this.#statusMessage
168
+ )
169
+
170
+ if (waitingController.aborted) {
171
+ continue
172
+ }
173
+
174
+ if (body.length > 0) {
175
+ handler.onResponseData?.(waitingController, body)
176
+ }
177
+
178
+ handler.onResponseEnd?.(waitingController, {})
179
+ } catch {
180
+ // Ignore errors from waiting handlers
181
+ }
182
+ }
183
+
184
+ this.#waitingHandlers = []
185
+ this.#chunks = []
186
+ }
187
+
188
+ /**
189
+ * Notify all waiting handlers of an error
190
+ * @param {Error} err
191
+ */
192
+ #notifyWaitingHandlersError (err) {
193
+ for (const handler of this.#waitingHandlers) {
194
+ const waitingController = {
195
+ resume () {},
196
+ pause () {},
197
+ get paused () { return false },
198
+ get aborted () { return true },
199
+ get reason () { return err },
200
+ abort () {}
201
+ }
202
+
203
+ try {
204
+ handler.onRequestStart?.(waitingController, null)
205
+ handler.onResponseError?.(waitingController, err)
206
+ } catch {
207
+ // Ignore errors from waiting handlers
208
+ }
209
+ }
210
+
211
+ this.#waitingHandlers = []
212
+ this.#chunks = []
213
+ }
214
+ }
215
+
216
+ module.exports = DeduplicationHandler
@@ -92,8 +92,6 @@ class RetryHandler {
92
92
 
93
93
  function shouldRetry (passedErr) {
94
94
  if (passedErr) {
95
- this.headersSent = true
96
-
97
95
  this.headersSent = true
98
96
  this.handler.onResponseStart?.(controller, statusCode, headers, statusMessage)
99
97
  controller.resume()