undici 8.10.0 → 8.10.1

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.
@@ -147,6 +147,10 @@ added: v1.0.0
147
147
  WebSocket messages. Applied to uncompressed messages, compressed frame
148
148
  payloads, and decompressed (`permessage-deflate`) messages. Set to `0` to
149
149
  disable the limit. **Default:** `134217728`.
150
+ * `eventSource` {Object} (optional) EventSource-specific configuration.
151
+ * `maxEventSize` {number} The maximum allowed event size, in bytes, for
152
+ EventSource messages. Set to `0` to disable the limit.
153
+ **Default:** `buffer.kStringMaxLength`.
150
154
  * Returns: {Client}
151
155
 
152
156
  Instantiating a `Client` does not open a connection; the connection is
@@ -25,8 +25,9 @@ it is used only for HTTPS requests.
25
25
  proxied. Each entry may include a leading dot or `*.` wildcard (for example
26
26
  `.example.com`) to match subdomains, and an optional `:port` suffix to restrict
27
27
  the match to a specific port. A request bypasses the proxy when its host equals
28
- an entry or is a subdomain of one. Setting `no_proxy` to `*` bypasses the proxy
29
- for every request.
28
+ an entry or is a subdomain of one. A trailing dot is ignored on both sides, so
29
+ `example.com.` and `example.com` match each other. Setting `no_proxy` to `*`
30
+ bypasses the proxy for every request.
30
31
 
31
32
  The uppercase variants `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` are also
32
33
  honored. When both the lowercase and uppercase forms of a variable are set, the
@@ -66,6 +66,9 @@ added: v6.5.0
66
66
  wait before re-establishing a dropped connection. The server may override
67
67
  this value with a `retry` field. **Default:** `3000`.
68
68
 
69
+ EventSource-specific limits can be configured on the dispatcher using the
70
+ `eventSource` option. See [`Client`][] for details.
71
+
69
72
  Creates a new `EventSource` and immediately begins connecting to `url`. The
70
73
  request is sent with the `Accept: text/event-stream` header, a cache mode of
71
74
  `no-store`, and an initiator type of `other`.
@@ -349,6 +352,7 @@ eventSource.onerror = () => {
349
352
  ```
350
353
 
351
354
  [WHATWG-conformant]: https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events
355
+ [`Client`]: Client.md#new-clienturl-options
352
356
  [`Dispatcher`]: Dispatcher.md#class-dispatcher
353
357
  [`addEventListener()`]: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
354
358
  [server-sent events]: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
@@ -179,7 +179,7 @@ class MemoryCacheStore extends EventEmitter {
179
179
 
180
180
  // Perform eviction
181
181
  for (const [key, entries] of store.#entries) {
182
- for (const entry of entries.splice(0, entries.length / 2)) {
182
+ for (const entry of entries.splice(0, Math.ceil(entries.length / 2))) {
183
183
  store.#size -= entry.size
184
184
  store.#count -= 1
185
185
  }
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const { InvalidArgumentError, MaxOriginsReachedError } = require('../core/errors')
4
- const { kBusy, kClients, kConnected, kRunning, kClose, kDestroy, kDispatch, kUrl } = require('../core/symbols')
4
+ const { kBusy, kClients, kConnected, kRunning, kPending, kClose, kDestroy, kDispatch, kUrl } = require('../core/symbols')
5
5
  const DispatcherBase = require('./dispatcher-base')
6
6
  const Pool = require('./pool')
7
7
  const Client = require('./client')
@@ -97,7 +97,12 @@ class Agent extends DispatcherBase {
97
97
  return
98
98
  }
99
99
 
100
- if (dispatcher[kConnected] > 0 || dispatcher[kBusy]) {
100
+ // A GOAWAY detaches the HTTP/2 session before requeued requests are
101
+ // dispatched on a replacement connection. At that point the pool has
102
+ // no connected clients and is not busy, but it still has pending work.
103
+ // Closing it here lets the replacement Client finish those requests
104
+ // and then destroys that new connection with ClientDestroyedError.
105
+ if (dispatcher[kConnected] > 0 || dispatcher[kBusy] || dispatcher[kPending] > 0) {
101
106
  return
102
107
  }
103
108
 
@@ -54,7 +54,7 @@ class BalancedPool extends PoolBase {
54
54
  throw new InvalidArgumentError('factory must be a function.')
55
55
  }
56
56
 
57
- super()
57
+ super(opts)
58
58
 
59
59
  this[kOptions] = { ...util.deepClone(opts) }
60
60
  this[kIndex] = -1
@@ -1052,7 +1052,7 @@ function onSocketClose () {
1052
1052
 
1053
1053
  function clearIdleSocketValidation (socket) {
1054
1054
  if (socket[kIdleSocketValidationTimeout]) {
1055
- clearTimeout(socket[kIdleSocketValidationTimeout])
1055
+ clearImmediate(socket[kIdleSocketValidationTimeout])
1056
1056
  socket[kIdleSocketValidationTimeout] = null
1057
1057
  }
1058
1058
 
@@ -1061,15 +1061,23 @@ function clearIdleSocketValidation (socket) {
1061
1061
 
1062
1062
  function scheduleIdleSocketValidation (client, socket) {
1063
1063
  socket[kIdleSocketValidation] = 1
1064
- socket[kIdleSocketValidationTimeout] = setTimeout(() => {
1064
+ // Yield to the check phase (after poll) so unsolicited bytes / FIN / RST
1065
+ // already pending on this idle keep-alive socket are processed before the
1066
+ // next request is written (GHSA-35p6-xmwp-9g52).
1067
+ //
1068
+ // setTimeout(0) pays Node's ~1ms timer floor on every sequential reuse
1069
+ // (#5493). setImmediate avoids that, but an *unref'd* Immediate lets poll
1070
+ // block for ~500ms when the event loop is otherwise idle (#5600 / #5606).
1071
+ // A ref'd Immediate both keeps the pending request alive and makes poll
1072
+ // return immediately — the hybrid those issues asked for.
1073
+ socket[kIdleSocketValidationTimeout] = setImmediate(() => {
1065
1074
  socket[kIdleSocketValidationTimeout] = null
1066
1075
  socket[kIdleSocketValidation] = 2
1067
1076
 
1068
1077
  if (client[kSocket] === socket && !socket.destroyed) {
1069
1078
  client[kResume]()
1070
1079
  }
1071
- }, 0)
1072
- socket[kIdleSocketValidationTimeout].unref?.()
1080
+ })
1073
1081
  }
1074
1082
 
1075
1083
  /**
@@ -10,7 +10,8 @@ const {
10
10
  InformationalError,
11
11
  InvalidArgumentError,
12
12
  HeadersTimeoutError,
13
- BodyTimeoutError
13
+ BodyTimeoutError,
14
+ ResponseExceededMaxSizeError
14
15
  } = require('../core/errors.js')
15
16
  const {
16
17
  kUrl,
@@ -39,7 +40,8 @@ const {
39
40
  kRemoteSettings,
40
41
  kHTTP2Stream,
41
42
  kHTTP2SessionState,
42
- kHTTP2Options
43
+ kHTTP2Options,
44
+ kMaxResponseSize
43
45
  } = require('../core/symbols.js')
44
46
  const { channels } = require('../core/diagnostics.js')
45
47
 
@@ -1022,9 +1024,11 @@ function writeH2 (client, request) {
1022
1024
  const state = {
1023
1025
  abort: null,
1024
1026
  body: request.body,
1027
+ bytesRead: 0,
1025
1028
  client,
1026
1029
  contentLength: null,
1027
1030
  expectsPayload: false,
1031
+ maxResponseSize: client[kMaxResponseSize],
1028
1032
  request,
1029
1033
  headersTimeout,
1030
1034
  bodyTimeout,
@@ -1260,6 +1264,7 @@ function writeH2 (client, request) {
1260
1264
  // become unreachable once the stream closes, so plain `on` avoids the
1261
1265
  // per-listener `once` wrapper allocation.
1262
1266
  stream.on('response', onResponse)
1267
+ stream.on('headers', onInterimResponse)
1263
1268
  stream.on('end', onEnd)
1264
1269
  stream.on('error', onError)
1265
1270
  stream.on('frameError', onFrameError)
@@ -1280,6 +1285,7 @@ function removeRequestStreamListeners (stream) {
1280
1285
  stream.off('error', noop)
1281
1286
  stream.off('continue', writeBodyH2)
1282
1287
  stream.off('response', onResponse)
1288
+ stream.off('headers', onInterimResponse)
1283
1289
  stream.off('end', onEnd)
1284
1290
  stream.off('error', onError)
1285
1291
  stream.off('frameError', onFrameError)
@@ -1322,17 +1328,51 @@ function onData (chunk) {
1322
1328
  return
1323
1329
  }
1324
1330
 
1325
- const { request } = state
1331
+ const { request, maxResponseSize } = state
1326
1332
 
1327
1333
  if (request.aborted || request.completed) {
1328
1334
  return
1329
1335
  }
1330
1336
 
1337
+ if (maxResponseSize > -1 && state.bytesRead + chunk.length > maxResponseSize) {
1338
+ // Unlike HTTP/1.1, which destroys the socket because it cannot abandon one
1339
+ // response without losing framing, resetting the offending stream leaves
1340
+ // the session usable for its siblings.
1341
+ state.abort(new ResponseExceededMaxSizeError())
1342
+ return
1343
+ }
1344
+
1345
+ state.bytesRead += chunk.length
1346
+
1331
1347
  if (request.onResponseData(chunk) === false) {
1332
1348
  stream.pause()
1333
1349
  }
1334
1350
  }
1335
1351
 
1352
+ function onInterimResponse (headers) {
1353
+ const stream = this
1354
+ const state = stream[kRequestStreamState]
1355
+
1356
+ if (state == null) {
1357
+ return
1358
+ }
1359
+
1360
+ const { request } = state
1361
+
1362
+ if (request.aborted || request.completed) {
1363
+ return
1364
+ }
1365
+
1366
+ // node http2 emits 'headers' for interim (1xx) informational responses,
1367
+ // while the final response arrives via 'response'. Forward these to the
1368
+ // handler so that onInfo is invoked, matching the HTTP/1 behaviour and the
1369
+ // documented onInfo contract.
1370
+ const statusCode = headers[HTTP2_HEADER_STATUS]
1371
+ delete headers[HTTP2_HEADER_STATUS]
1372
+
1373
+ request.onResponseStart(Number(statusCode), headers, noop, '')
1374
+ }
1375
+
1336
1376
  function onResponse (headers) {
1337
1377
  const stream = this
1338
1378
  const state = stream[kRequestStreamState]
@@ -137,7 +137,8 @@ class Client extends DispatcherBase {
137
137
  connectionWindowSize,
138
138
  pingInterval,
139
139
  webSocket,
140
- h2Options
140
+ h2Options,
141
+ eventSource
141
142
  } = {}) {
142
143
  if (keepAlive !== undefined) {
143
144
  throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead')
@@ -276,7 +277,7 @@ class Client extends DispatcherBase {
276
277
  }
277
278
  }
278
279
 
279
- super({ webSocket })
280
+ super({ webSocket, eventSource })
280
281
 
281
282
  if (typeof connect !== 'function') {
282
283
  connect = buildConnector({
@@ -1,5 +1,6 @@
1
1
  'use strict'
2
2
 
3
+ const buffer = require('node:buffer')
3
4
  const Dispatcher = require('./dispatcher')
4
5
  const {
5
6
  ClientDestroyedError,
@@ -11,6 +12,7 @@ const { kDestroy, kClose, kClosed, kDestroyed, kDispatch } = require('../core/sy
11
12
  const kOnDestroyed = Symbol('onDestroyed')
12
13
  const kOnClosed = Symbol('onClosed')
13
14
  const kWebSocketOptions = Symbol('webSocketOptions')
15
+ const kEventSourceOptions = Symbol('eventSourceOptions')
14
16
 
15
17
  class DispatcherBase extends Dispatcher {
16
18
  /** @type {boolean} */
@@ -31,10 +33,11 @@ class DispatcherBase extends Dispatcher {
31
33
  constructor (opts) {
32
34
  super()
33
35
  this[kWebSocketOptions] = opts?.webSocket ?? {}
36
+ this[kEventSourceOptions] = opts?.eventSource ?? {}
34
37
  }
35
38
 
36
39
  /**
37
- * @returns {import('../../types/dispatcher').WebSocketOptions}
40
+ * @returns {import('../../types/client').Client.WebSocketOptions}
38
41
  */
39
42
  get webSocketOptions () {
40
43
  return {
@@ -43,6 +46,15 @@ class DispatcherBase extends Dispatcher {
43
46
  }
44
47
  }
45
48
 
49
+ /**
50
+ * @returns {import('../../types/client').Client.EventSourceOptions}
51
+ */
52
+ get eventSourceOptions () {
53
+ return {
54
+ maxEventSize: this[kEventSourceOptions].maxEventSize ?? buffer.kStringMaxLength
55
+ }
56
+ }
57
+
46
58
  /** @returns {boolean} */
47
59
  get destroyed () {
48
60
  return this[kDestroyed]
@@ -1,5 +1,6 @@
1
1
  'use strict'
2
2
  const EventEmitter = require('node:events')
3
+ const { kUrl } = require('../core/symbols')
3
4
 
4
5
  class Dispatcher extends EventEmitter {
5
6
  dispatch () {
@@ -35,6 +36,15 @@ class Dispatcher extends EventEmitter {
35
36
  }
36
37
  }
37
38
 
39
+ const originalDispatch = dispatch
40
+ const self = this
41
+ dispatch = function (opts, handler) {
42
+ if (opts && typeof opts === 'object' && !opts.origin && self[kUrl]) {
43
+ opts = Object.assign({}, opts, { origin: self[kUrl].origin })
44
+ }
45
+ return originalDispatch(opts, handler)
46
+ }
47
+
38
48
  return new Proxy(this, {
39
49
  get: (target, key) => key === 'dispatch' ? dispatch : target[key]
40
50
  })
@@ -16,7 +16,7 @@ class EnvHttpProxyAgent extends DispatcherBase {
16
16
  #opts = null
17
17
 
18
18
  constructor (opts = {}) {
19
- super()
19
+ super(opts)
20
20
  this.#opts = opts
21
21
 
22
22
  const { httpProxy, httpsProxy, noProxy, ...agentOpts } = opts
@@ -69,6 +69,13 @@ class EnvHttpProxyAgent extends DispatcherBase {
69
69
  // brackets from IPv6 literals (e.g. "[::1]" -> "::1") so that the
70
70
  // result matches the unbracketed form stored by #parseNoProxy.
71
71
  hostname = hostname.replace(/:\d*$/, '').replace(/^\[(.+)\]$/, '$1').toLowerCase()
72
+ // Drop a trailing dot: it only marks the fully qualified form of a domain
73
+ // name ("example.com." and "example.com" are the same name, RFC 1034 root
74
+ // label). This runs on every dispatch, so it is a charCode check rather
75
+ // than a third regex. `length > 1` leaves the degenerate host "." alone.
76
+ if (hostname.length > 1 && hostname.charCodeAt(hostname.length - 1) === 46) {
77
+ hostname = hostname.slice(0, -1)
78
+ }
72
79
  port = Number.parseInt(port, 10) || DEFAULT_PORTS[protocol] || 0
73
80
  if (!this.#shouldProxy(hostname, port)) {
74
81
  return this[kNoProxyAgent]
@@ -143,8 +150,8 @@ class EnvHttpProxyAgent extends DispatcherBase {
143
150
  }
144
151
 
145
152
  noProxyEntries.push({
146
- // strip leading dot or asterisk with dot
147
- hostname: hostname.replace(/^\*?\./, '').toLowerCase(),
153
+ // strip leading dot or asterisk with dot, and any trailing dot
154
+ hostname: hostname.replace(/^\*?\./, '').replace(/^(.+)\.$/, '$1').toLowerCase(),
148
155
  port
149
156
  })
150
157
  }
@@ -120,7 +120,7 @@ class ProxyAgent extends DispatcherBase {
120
120
 
121
121
  const { proxyTunnel, connectTimeout } = opts
122
122
 
123
- super()
123
+ super(opts)
124
124
 
125
125
  const url = this.#getUrl(opts)
126
126
  const { href, origin, port, protocol, username, password, hostname: proxyHostname } = url
@@ -65,7 +65,7 @@ class RoundRobinPool extends PoolBase {
65
65
  })
66
66
  }
67
67
 
68
- super()
68
+ super(options)
69
69
 
70
70
  this[kConnections] = connections || null
71
71
  this[kUrl] = util.parseOrigin(origin)
@@ -29,7 +29,7 @@ let experimentalWarningEmitted = false
29
29
  */
30
30
  class Socks5ProxyAgent extends DispatcherBase {
31
31
  constructor (proxyUrl, options = {}) {
32
- super()
32
+ super(options)
33
33
 
34
34
  // Emit experimental warning only once
35
35
  if (!experimentalWarningEmitted) {
@@ -115,6 +115,7 @@ class Socks5ProxyAgent extends DispatcherBase {
115
115
  const authenticationReady = Promise.withResolvers()
116
116
 
117
117
  const authenticationTimeout = setTimeout(() => {
118
+ socks5Client.destroy()
118
119
  authenticationReady.reject(new Error('SOCKS5 authentication timeout'))
119
120
  }, 5000)
120
121
 
@@ -148,6 +149,7 @@ class Socks5ProxyAgent extends DispatcherBase {
148
149
  const connectionReady = Promise.withResolvers()
149
150
 
150
151
  const connectionTimeout = setTimeout(() => {
152
+ socks5Client.destroy()
151
153
  connectionReady.reject(new Error('SOCKS5 connection timeout'))
152
154
  }, 5000)
153
155
 
@@ -173,6 +173,14 @@ class CacheHandler {
173
173
  this.#handler.onRequestStart?.(controller, context)
174
174
  }
175
175
 
176
+ onBodySent (chunk) {
177
+ this.#handler.onBodySent?.(chunk)
178
+ }
179
+
180
+ onRequestSent () {
181
+ this.#handler.onRequestSent?.()
182
+ }
183
+
176
184
  onRequestUpgrade (controller, statusCode, headers, socket) {
177
185
  this.#handler.onRequestUpgrade?.(controller, statusCode, headers, socket)
178
186
  }
@@ -62,5 +62,11 @@ module.exports = class DecoratorHandler {
62
62
  /**
63
63
  * @deprecated
64
64
  */
65
- onBodySent () {}
65
+ onBodySent (...args) {
66
+ return this.#handler.onBodySent?.(...args)
67
+ }
68
+
69
+ onRequestSent (...args) {
70
+ return this.#handler.onRequestSent?.(...args)
71
+ }
66
72
  }
@@ -365,12 +365,22 @@ class DeduplicationHandler {
365
365
  get aborted () { return state.aborted },
366
366
  get reason () { return state.reason },
367
367
  abort: (reason) => {
368
+ if (state.aborted) {
369
+ return
370
+ }
371
+
368
372
  state.aborted = true
369
373
  state.reason = reason ?? null
370
374
  waitingHandler.done = true
371
375
  waitingHandler.pendingTrailers = null
372
376
  waitingHandler.bufferedChunks = []
373
377
  waitingHandler.bufferedBytes = 0
378
+
379
+ try {
380
+ handler.onResponseError?.(waitingHandler.controller, state.reason ?? new RequestAbortedError())
381
+ } catch {
382
+ // Ignore errors from waiting handlers
383
+ }
374
384
  }
375
385
  }
376
386
 
@@ -444,12 +454,8 @@ class DeduplicationHandler {
444
454
  waitingHandler.bufferedChunks = []
445
455
  waitingHandler.bufferedBytes = 0
446
456
 
447
- try {
448
- waitingHandler.controller.abort(err)
449
- waitingHandler.handler.onResponseError?.(waitingHandler.controller, err)
450
- } catch {
451
- // Ignore errors from waiting handlers
452
- }
457
+ // controller.abort(err) notifies the handler via onResponseError
458
+ waitingHandler.controller.abort(err)
453
459
  }
454
460
 
455
461
  #pruneDoneWaitingHandlers () {
@@ -43,6 +43,14 @@ class RedirectHandler {
43
43
  this.handler.onRequestStart?.(controller, { ...context, history: this.history })
44
44
  }
45
45
 
46
+ onBodySent (chunk) {
47
+ this.handler.onBodySent?.(chunk)
48
+ }
49
+
50
+ onRequestSent () {
51
+ this.handler.onRequestSent?.()
52
+ }
53
+
46
54
  onRequestUpgrade (controller, statusCode, headers, socket) {
47
55
  this.handler.onRequestUpgrade?.(controller, statusCode, headers, socket)
48
56
  }
@@ -2,7 +2,7 @@
2
2
  const assert = require('node:assert')
3
3
 
4
4
  const { kRetryHandlerDefaultRetry } = require('../core/symbols')
5
- const { RequestRetryError } = require('../core/errors')
5
+ const { RequestRetryError, RequestAbortedError } = require('../core/errors')
6
6
  const {
7
7
  isDisturbed,
8
8
  parseRangeHeader,
@@ -41,14 +41,26 @@ function validatePartialResponseContentLength (headers, range, statusCode, retry
41
41
  // new one: backpressure pauses the new connection's controller, but the
42
42
  // consumer's resume() targets the old one, so the resumed body stalls forever.
43
43
  // The proxy always forwards to the controller of the currently active connection.
44
+ // An abort is additionally reported to the handler so it can cancel a pending
45
+ // retry backoff instead of letting the request hang until the backoff elapses.
46
+ // The notification is a private callback the handler hands over on construction,
47
+ // so nothing outside the handler can trigger it.
44
48
  class RetryController {
45
- constructor () {
49
+ #onAbort
50
+
51
+ constructor (onAbort) {
52
+ this.#onAbort = onAbort
46
53
  this.target = null
47
54
  }
48
55
 
49
56
  pause () { this.target?.pause() }
50
57
  resume () { this.target?.resume() }
51
- abort (reason) { this.target?.abort(reason) }
58
+
59
+ abort (reason) {
60
+ this.target?.abort(reason)
61
+ this.#onAbort(reason)
62
+ }
63
+
52
64
  get paused () { return this.target?.paused ?? false }
53
65
  get aborted () { return this.target?.aborted ?? false }
54
66
  get reason () { return this.target?.reason ?? null }
@@ -112,7 +124,16 @@ class RetryHandler {
112
124
  this.etag = null
113
125
  this.statusCode = null
114
126
  this.headers = null
115
- this.controllerProxy = new RetryController()
127
+ this.controllerProxy = new RetryController(reason => this.#onAbort(reason))
128
+ // A retry decision is in flight (the policy may be holding a backoff
129
+ // timer). While pending, a consumer abort cancels the wait.
130
+ this.retryPending = false
131
+ // Backoff timer returned by the retry policy, so #onAbort can cancel it.
132
+ // Null for custom policies that do not return their timer.
133
+ this.retryTimer = null
134
+ // Set once an abort during the backoff delivered the terminal error
135
+ // downstream; late policy callbacks and connection errors are then moot.
136
+ this.aborted = false
116
137
  }
117
138
 
118
139
  onResponseStartWithRetry (controller, statusCode, headers, statusMessage, err) {
@@ -135,6 +156,13 @@ class RetryHandler {
135
156
  }
136
157
 
137
158
  function shouldRetry (passedErr) {
159
+ if (this.aborted) {
160
+ // Aborted while the policy was deciding; the decision is moot.
161
+ return
162
+ }
163
+ this.retryPending = false
164
+ this.retryTimer = null
165
+
138
166
  if (passedErr) {
139
167
  this.headersSent = true
140
168
  this.handler.onResponseStart?.(this.controllerProxy, statusCode, headers, statusMessage)
@@ -154,14 +182,17 @@ class RetryHandler {
154
182
  // between, leaving this one paused forever -- the very stall the proxy exists
155
183
  // to prevent.
156
184
  controller.pause()
157
- this.retryOpts.retry(
185
+ // The default policy returns its backoff timer so an abort can cancel it;
186
+ // a custom policy may return anything (or nothing), which is ignored.
187
+ this.retryPending = true
188
+ this.retryTimer = this.retryOpts.retry(
158
189
  err,
159
190
  {
160
191
  state: { counter: this.retryCount },
161
192
  opts: { retryOptions: this.retryOpts, ...this.opts }
162
193
  },
163
194
  shouldRetry.bind(this)
164
- )
195
+ ) ?? null
165
196
  }
166
197
 
167
198
  onRequestStart (controller, context) {
@@ -176,6 +207,14 @@ class RetryHandler {
176
207
  }
177
208
  }
178
209
 
210
+ onBodySent (chunk) {
211
+ this.handler.onBodySent?.(chunk)
212
+ }
213
+
214
+ onRequestSent () {
215
+ this.handler.onRequestSent?.()
216
+ }
217
+
179
218
  onRequestUpgrade (_controller, statusCode, headers, socket) {
180
219
  this.handler.onRequestUpgrade?.(this.controllerProxy, statusCode, headers, socket)
181
220
  }
@@ -190,7 +229,8 @@ class RetryHandler {
190
229
  timeoutFactor,
191
230
  statusCodes,
192
231
  errorCodes,
193
- methods
232
+ methods,
233
+ retryAfter
194
234
  } = retryOptions
195
235
  const { counter } = state
196
236
 
@@ -222,7 +262,7 @@ class RetryHandler {
222
262
  return
223
263
  }
224
264
 
225
- let retryAfterHeader = headers?.['retry-after']
265
+ let retryAfterHeader = retryAfter === false ? undefined : headers?.['retry-after']
226
266
  if (retryAfterHeader) {
227
267
  retryAfterHeader = Number(retryAfterHeader)
228
268
  retryAfterHeader = Number.isNaN(retryAfterHeader)
@@ -237,7 +277,9 @@ class RetryHandler {
237
277
  ? Math.min(retryAfterHeader, maxTimeout)
238
278
  : Math.min(minTimeout * timeoutFactor ** (counter - 1), maxTimeout)
239
279
 
240
- setTimeout(() => cb(null), retryTimeout)
280
+ // Return the backoff timer so the handler can cancel it when the
281
+ // consumer aborts while the retry decision is pending.
282
+ return setTimeout(() => cb(null), retryTimeout)
241
283
  }
242
284
 
243
285
  onResponseStart (controller, statusCode, headers, statusMessage) {
@@ -435,6 +477,12 @@ class RetryHandler {
435
477
  }
436
478
 
437
479
  onResponseError (controller, err) {
480
+ if (this.aborted) {
481
+ // #onAbort already delivered the terminal error downstream; the late
482
+ // error of the torn-down connection must not be forwarded twice.
483
+ return
484
+ }
485
+
438
486
  // controller is THIS failed connection (not the proxy): we inspect whether
439
487
  // the consumer aborted it to decide retry-vs-propagate.
440
488
  if (controller?.aborted || isDisturbed(this.opts.body)) {
@@ -443,6 +491,13 @@ class RetryHandler {
443
491
  }
444
492
 
445
493
  function shouldRetry (returnedErr) {
494
+ if (this.aborted) {
495
+ // Aborted while the policy was deciding; the decision is moot.
496
+ return
497
+ }
498
+ this.retryPending = false
499
+ this.retryTimer = null
500
+
446
501
  if (!returnedErr) {
447
502
  this.retry()
448
503
  return
@@ -462,14 +517,31 @@ class RetryHandler {
462
517
  this.retryCount += 1
463
518
  }
464
519
 
465
- this.retryOpts.retry(
520
+ this.retryPending = true
521
+ this.retryTimer = this.retryOpts.retry(
466
522
  err,
467
523
  {
468
524
  state: { counter: this.retryCount },
469
525
  opts: { retryOptions: this.retryOpts, ...this.opts }
470
526
  },
471
527
  shouldRetry.bind(this)
472
- )
528
+ ) ?? null
529
+ }
530
+
531
+ #onAbort (reason) {
532
+ // A consumer abort lands on the controller proxy. If the retry policy is
533
+ // still deciding (typically holding a backoff timer), cancel the wait and
534
+ // surface the abort immediately instead of letting the request hang until
535
+ // the backoff elapses.
536
+ if (!this.retryPending) {
537
+ return
538
+ }
539
+
540
+ this.aborted = true
541
+ this.retryPending = false
542
+ clearTimeout(this.retryTimer)
543
+ this.retryTimer = null
544
+ this.handler.onResponseError?.(this.controllerProxy, reason ?? new RequestAbortedError())
473
545
  }
474
546
  }
475
547
 
@@ -84,7 +84,7 @@ class DumpHandler extends DecoratorHandler {
84
84
  return
85
85
  }
86
86
 
87
- if (this.#controller.aborted === true) {
87
+ if (this.aborted === true) {
88
88
  super.onResponseError(controller, this.reason)
89
89
  return
90
90
  }
@@ -73,7 +73,7 @@ class MockAgent extends Dispatcher {
73
73
  opts.origin = normalizeOrigin(opts.origin)
74
74
 
75
75
  // Call MockAgent.get to perform additional setup before dispatching as normal
76
- this.get(opts.origin)
76
+ const mockDispatcher = this.get(opts.origin)
77
77
 
78
78
  this[kMockAgentAddCallHistoryLog](opts)
79
79
 
@@ -81,6 +81,18 @@ class MockAgent extends Dispatcher {
81
81
 
82
82
  const dispatchOpts = { ...opts }
83
83
 
84
+ // Agent keeps HTTP/1.1-only dispatchers under a separate key. Legacy
85
+ // global dispatcher consumers use that path, so mirror the mock dispatches
86
+ // before delegating to the internal Agent.
87
+ if (dispatchOpts.allowH2 === false) {
88
+ const http1OnlyKey = `${dispatchOpts.origin}#http1-only`
89
+ if (!this[kClients].has(http1OnlyKey)) {
90
+ const http1OnlyDispatcher = this[kFactory](dispatchOpts.origin)
91
+ http1OnlyDispatcher[kDispatches] = mockDispatcher[kDispatches]
92
+ this[kMockAgentSet](http1OnlyKey, http1OnlyDispatcher)
93
+ }
94
+ }
95
+
84
96
  if (acceptNonStandardSearchParameters && dispatchOpts.path) {
85
97
  const [path, searchParams] = dispatchOpts.path.split('?')
86
98
  const normalizedSearchParams = normalizeSearchParams(searchParams, acceptNonStandardSearchParameters)
@@ -333,8 +333,7 @@ function mockDispatch (opts, handler) {
333
333
  handler.onResponseError(null, new InvalidArgumentError('reply options callback must return an object'))
334
334
  return
335
335
  }
336
- mockDispatch.data = { ...responseDefaults, ...resolvedData }
337
- dispatchMockReply(mockDispatches, mockDispatch, key, opts, handler)
336
+ dispatchMockReply(mockDispatches, mockDispatch, key, opts, handler, { ...responseDefaults, ...resolvedData })
338
337
  },
339
338
  (error) => {
340
339
  handler.onResponseError(null, error)
@@ -347,7 +346,7 @@ function mockDispatch (opts, handler) {
347
346
  throw new InvalidArgumentError('reply options callback must return an object')
348
347
  }
349
348
 
350
- mockDispatch.data = { ...responseDefaults, ...callbackResult }
349
+ return dispatchMockReply(mockDispatches, mockDispatch, key, opts, handler, { ...responseDefaults, ...callbackResult })
351
350
  }
352
351
 
353
352
  return dispatchMockReply(mockDispatches, mockDispatch, key, opts, handler)
@@ -356,9 +355,13 @@ function mockDispatch (opts, handler) {
356
355
  /**
357
356
  * Replies to a request once the mock dispatch data is fully resolved
358
357
  */
359
- function dispatchMockReply (mockDispatches, mockDispatch, key, opts, handler) {
360
- // Parse mockDispatch data
361
- const { data: response, delay } = mockDispatch
358
+ function dispatchMockReply (mockDispatches, mockDispatch, key, opts, handler, resolvedResponse) {
359
+ // Parse mockDispatch data. When a reply callback has already been resolved
360
+ // in mockDispatch() (i.e. no body lifecycle hooks are involved), the resolved
361
+ // response is passed in here, leaving mockDispatch.data untouched so the
362
+ // callback can be re-invoked for persistent / times() replies.
363
+ const { data: responseData, delay } = mockDispatch
364
+ const response = resolvedResponse ?? responseData
362
365
 
363
366
  // If specified, trigger dispatch error
364
367
  if (response.error !== null) {
@@ -454,8 +457,7 @@ function dispatchMockReply (mockDispatches, mockDispatch, key, opts, handler) {
454
457
  handler.onResponseError(null, new InvalidArgumentError('reply options callback must return an object'))
455
458
  return
456
459
  }
457
- mockDispatch.data = { ...responseDefaults, ...resolvedData }
458
- handleReply(dispatches, mockDispatch.data)
460
+ handleReply(dispatches, { ...responseDefaults, ...resolvedData })
459
461
  },
460
462
  (err) => {
461
463
  handler.onResponseError(null, err)
@@ -468,8 +470,7 @@ function dispatchMockReply (mockDispatches, mockDispatch, key, opts, handler) {
468
470
  throw new InvalidArgumentError('reply options callback must return an object')
469
471
  }
470
472
 
471
- mockDispatch.data = { ...responseDefaults, ...callbackResult }
472
- handleReply(dispatches, mockDispatch.data)
473
+ handleReply(dispatches, { ...responseDefaults, ...callbackResult })
473
474
  return
474
475
  }
475
476
 
@@ -189,14 +189,16 @@ function parseUnparsedAttributes (unparsedAttributes, cookieAttributeList = {})
189
189
  // 1. If the first character of the attribute-value is not a DIGIT or a
190
190
  // "-" character, ignore the cookie-av.
191
191
  const charCode = attributeValue.charCodeAt(0)
192
+ const startsWithDigit = charCode >= 48 && charCode <= 57
193
+ const startsWithSignedDigit = attributeValue[0] === '-' && attributeValue.length > 1
192
194
 
193
- if ((charCode < 48 || charCode > 57) && attributeValue[0] !== '-') {
195
+ if (!startsWithDigit && !startsWithSignedDigit) {
194
196
  return parseUnparsedAttributes(unparsedAttributes, cookieAttributeList)
195
197
  }
196
198
 
197
199
  // 2. If the remainder of attribute-value contains a non-DIGIT
198
200
  // character, ignore the cookie-av.
199
- if (!/^\d+$/.test(attributeValue)) {
201
+ if (/[^\d]/.test(attributeValue.slice(1))) {
200
202
  return parseUnparsedAttributes(unparsedAttributes, cookieAttributeList)
201
203
  }
202
204
 
@@ -1,4 +1,5 @@
1
1
  'use strict'
2
+ const buffer = require('node:buffer')
2
3
  const { Transform } = require('node:stream')
3
4
  const { isASCIINumber, isValidLastEventId } = require('./util')
4
5
 
@@ -23,6 +24,8 @@ const COLON = 0x3A
23
24
  */
24
25
  const SPACE = 0x20
25
26
 
27
+ const defaultMaxEventSize = buffer.kStringMaxLength
28
+
26
29
  const DATA = Buffer.from('data')
27
30
  const EVENT = Buffer.from('event')
28
31
  const ID = Buffer.from('id')
@@ -66,6 +69,12 @@ function isFieldName (line, length, field) {
66
69
  return true
67
70
  }
68
71
 
72
+ function createMaxEventSizeExceededError () {
73
+ const error = new Error('EventSource message size exceeded')
74
+ error.aborted = false
75
+ return error
76
+ }
77
+
69
78
  /**
70
79
  * @typedef {object} EventSourceStreamEvent
71
80
  * @type {object}
@@ -114,6 +123,8 @@ class EventSourceStream extends Transform {
114
123
  pos = 0
115
124
  lineChunkIndex = 0
116
125
  linePos = 0
126
+ eventDataSize = 0
127
+ maxEventSize
117
128
 
118
129
  event = {
119
130
  data: undefined,
@@ -125,6 +136,7 @@ class EventSourceStream extends Transform {
125
136
  /**
126
137
  * @param {object} options
127
138
  * @param {boolean} [options.readableObjectMode]
139
+ * @param {number} [options.maxEventSize]
128
140
  * @param {eventSourceSettings} [options.eventSourceSettings]
129
141
  * @param {(chunk: any, encoding?: BufferEncoding | undefined) => boolean} [options.push]
130
142
  */
@@ -136,6 +148,7 @@ class EventSourceStream extends Transform {
136
148
  super(options)
137
149
 
138
150
  this.state = options.eventSourceSettings || {}
151
+ this.maxEventSize = options.maxEventSize ?? defaultMaxEventSize
139
152
  if (options.push) {
140
153
  this.push = options.push
141
154
  }
@@ -231,7 +244,12 @@ class EventSourceStream extends Transform {
231
244
 
232
245
  // In any case, we can process the line as we reached an
233
246
  // end-of-line character
234
- this.parseLine(this.readLine(), this.event)
247
+ try {
248
+ this.parseLine(this.readLine(), this.event)
249
+ } catch (error) {
250
+ callback(error)
251
+ return
252
+ }
235
253
  this.consumeCurrentByte()
236
254
  // A line was processed and this could be the end of the event. We need
237
255
  // to check if the next line is empty to determine if the event is
@@ -282,6 +300,13 @@ class EventSourceStream extends Transform {
282
300
  }
283
301
 
284
302
  if (isFieldName(line, fieldLength, DATA)) {
303
+ const valueBytes = line.length - valueStart
304
+ const eventDataSize = this.eventDataSize + (event.data === undefined ? 0 : 1) + valueBytes
305
+
306
+ if (this.maxEventSize > 0 && eventDataSize > this.maxEventSize) {
307
+ throw createMaxEventSizeExceededError()
308
+ }
309
+
285
310
  const value = line.toString('utf8', valueStart)
286
311
 
287
312
  if (event.data === undefined) {
@@ -289,6 +314,7 @@ class EventSourceStream extends Transform {
289
314
  } else {
290
315
  event.data += `\n${value}`
291
316
  }
317
+ this.eventDataSize = eventDataSize
292
318
  return
293
319
  }
294
320
 
@@ -345,6 +371,7 @@ class EventSourceStream extends Transform {
345
371
  this.event.event = undefined
346
372
  this.event.id = undefined
347
373
  this.event.retry = undefined
374
+ this.eventDataSize = 0
348
375
  }
349
376
 
350
377
  hasPendingEvent () {
@@ -10,6 +10,7 @@ const { isNetworkError } = require('../fetch/response')
10
10
  const { kEnumerableProperty } = require('../../core/util')
11
11
  const { environmentSettingsObject } = require('../fetch/util')
12
12
  const { createPotentialCORSRequest } = require('./util')
13
+ const { getGlobalDispatcher } = require('../../global')
13
14
 
14
15
  let experimentalWarned = false
15
16
 
@@ -281,6 +282,7 @@ class EventSource extends EventTarget {
281
282
 
282
283
  const eventSourceStream = new EventSourceStream({
283
284
  eventSourceSettings: this.#state,
285
+ maxEventSize: this.#dispatcher.eventSourceOptions?.maxEventSize,
284
286
  push: (event) => {
285
287
  this.dispatchEvent(createFastMessageEvent(
286
288
  event.type,
@@ -465,7 +467,8 @@ webidl.converters.EventSourceInitDict = webidl.dictionaryConverter([
465
467
  },
466
468
  {
467
469
  key: 'dispatcher', // undici only
468
- converter: webidl.converters.any
470
+ converter: webidl.converters.any,
471
+ defaultValue: () => getGlobalDispatcher()
469
472
  },
470
473
  {
471
474
  key: 'node', // undici only
@@ -923,7 +923,7 @@ function makeRequest (init) {
923
923
  serviceWorkers: init.serviceWorkers ?? 'all',
924
924
  initiator: init.initiator ?? '',
925
925
  destination: init.destination ?? '',
926
- priority: init.priority ?? null,
926
+ priority: init.priority ?? 'auto',
927
927
  origin: init.origin ?? 'client',
928
928
  policyContainer: init.policyContainer ?? 'client',
929
929
  referrer: init.referrer ?? 'client',
@@ -1129,8 +1129,7 @@ webidl.converters.RequestInit = webidl.dictionaryConverter([
1129
1129
  {
1130
1130
  key: 'priority',
1131
1131
  converter: webidl.converters.DOMString,
1132
- allowedValues: ['high', 'low', 'auto'],
1133
- defaultValue: () => 'auto'
1132
+ allowedValues: ['high', 'low', 'auto']
1134
1133
  }
1135
1134
  ])
1136
1135
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "undici",
3
- "version": "8.10.0",
3
+ "version": "8.10.1",
4
4
  "description": "An HTTP/1.1 client, written from scratch for Node.js",
5
5
  "homepage": "https://undici.nodejs.org",
6
6
  "bugs": {
@@ -97,7 +97,7 @@
97
97
  "test:websocket:autobahn": "node test/autobahn/client.js",
98
98
  "test:websocket:autobahn:report": "node test/autobahn/report.js",
99
99
  "test:wpt:setup": "node test/web-platform-tests/wpt-runner.mjs setup",
100
- "test:wpt": "npm run test:wpt:setup && node test/web-platform-tests/wpt-runner.mjs run /fetch /mimesniff /websockets /serviceWorkers /eventsource",
100
+ "test:wpt": "npm run test:wpt:setup && node test/web-platform-tests/wpt-runner.mjs run /fetch /mimesniff /xhr /websockets /eventsource",
101
101
  "test:cache-tests": "node test/cache-interceptor/cache-tests.mjs --ci",
102
102
  "coverage": "npm run coverage:clean && cross-env NODE_V8_COVERAGE=./coverage/tmp npm run test:javascript && npm run coverage:report",
103
103
  "coverage:ci": "npm run coverage:clean && cross-env NODE_V8_COVERAGE=./coverage/tmp npm run test:javascript && npm run coverage:report:ci",
@@ -109,7 +109,7 @@
109
109
  "prepare": "husky && node ./scripts/platform-shell.js"
110
110
  },
111
111
  "devDependencies": {
112
- "@fastify/busboy": "3.2.0",
112
+ "@fastify/busboy": "3.2.2",
113
113
  "@matteo.collina/tspl": "^0.2.0",
114
114
  "@metcoder95/https-pem": "^1.0.0",
115
115
  "@sinonjs/fake-timers": "^12.0.0",
package/types/client.d.ts CHANGED
@@ -80,6 +80,8 @@ export declare namespace Client {
80
80
  maxResponseSize?: number;
81
81
  /** WebSocket-specific options */
82
82
  webSocket?: Client.WebSocketOptions;
83
+ /** EventSource-specific options */
84
+ eventSource?: Client.EventSourceOptions;
83
85
  /** Enables a family autodetection algorithm that loosely implements section 5 of RFC 8305. */
84
86
  autoSelectFamily?: boolean;
85
87
  /** The amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the `autoSelectFamily` option. */
@@ -169,6 +171,14 @@ export declare namespace Client {
169
171
  */
170
172
  settings?: Omit<SessionOptions['settings'], 'enablePush' | 'maxConcurrentStreams' | 'enableConnectProtocol'>
171
173
  }
174
+ export interface EventSourceOptions {
175
+ /**
176
+ * Maximum allowed event size in bytes for EventSource messages.
177
+ * Set to 0 to disable the limit.
178
+ * @default buffer.kStringMaxLength
179
+ */
180
+ maxEventSize?: number;
181
+ }
172
182
  }
173
183
 
174
184
  export default Client