undici 6.29.0 → 6.29.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.
@@ -29,6 +29,8 @@ Returns: `Client`
29
29
  * **webSocket** `WebSocketOptions` (optional) - WebSocket-specific configuration options.
30
30
  * **maxFragments** `number` (optional) - Default: `131072` - Maximum number of fragments in a message. Set to 0 to disable the limit.
31
31
  * **maxPayloadSize** `number` (optional) - Default: `134217728` (128 MB) - Maximum allowed payload size in bytes for WebSocket messages. Applied to uncompressed messages, compressed frame payloads, and decompressed (permessage-deflate) messages. Set to 0 to disable the limit.
32
+ * **eventSource** `EventSourceOptions` (optional) - EventSource-specific configuration options.
33
+ * **maxEventSize** `number` (optional) - The maximum total bytes of data and metadata field values retained for a pending EventSource event. Each unfinished line is separately limited to this value plus seven syntax bytes. The number of `data:` lines per event and input chunks buffered for an unfinished line are each limited to `min(131072, max(1024, floor(maxEventSize / 128)))` to bound object overhead. Set to `0` to disable these limits. Default: `buffer.kStringMaxLength`.
32
34
  * **pipelining** `number | null` (optional) - Default: `1` - The amount of concurrent requests to be sent over the single TCP/TLS connection according to [RFC7230](https://tools.ietf.org/html/rfc7230#section-6.3.2). Carefully consider your workload and environment before enabling concurrent requests as pipelining may reduce performance if used incorrectly. Pipelining is sensitive to network stack settings as well as head of line blocking caused by e.g. long running requests. Set to `0` to disable keep-alive connections.
33
35
  * **connect** `ConnectOptions | Function | null` (optional) - Default: `null`.
34
36
  * **strictContentLength** `Boolean` (optional) - Default: `true` - Whether to treat request content length mismatches as errors. If true, an error is thrown when the request content-length header doesn't match the length of the request body.
@@ -23,7 +23,7 @@ function defaultFactory (origin, opts) {
23
23
  }
24
24
 
25
25
  class Agent extends DispatcherBase {
26
- constructor ({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) {
26
+ constructor ({ factory = defaultFactory, maxRedirections = 0, connect, tls, ...options } = {}) {
27
27
  if (typeof factory !== 'function') {
28
28
  throw new InvalidArgumentError('factory must be a function.')
29
29
  }
@@ -42,11 +42,15 @@ class Agent extends DispatcherBase {
42
42
  connect = { ...connect }
43
43
  }
44
44
 
45
+ if (tls && typeof tls !== 'function') {
46
+ tls = { ...tls }
47
+ }
48
+
45
49
  this[kInterceptors] = options.interceptors?.Agent && Array.isArray(options.interceptors.Agent)
46
50
  ? options.interceptors.Agent
47
51
  : [createRedirectInterceptor({ maxRedirections })]
48
52
 
49
- this[kOptions] = { ...util.deepClone(options), connect }
53
+ this[kOptions] = { ...util.deepClone(options), connect, tls }
50
54
  this[kOptions].interceptors = options.interceptors
51
55
  ? { ...options.interceptors }
52
56
  : undefined
@@ -727,8 +727,6 @@ async function connectH1 (client, socket) {
727
727
  socket[kParser] = new Parser(client, socket, llhttpInstance)
728
728
 
729
729
  addListener(socket, 'error', function (err) {
730
- assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID')
731
-
732
730
  const parser = this[kParser]
733
731
 
734
732
  // On Mac OS, we get an ECONNRESET even if there is a full body to be forwarded
@@ -136,8 +136,6 @@ async function connectH2 (client, socket) {
136
136
  socket[kHTTP2Session] = session
137
137
 
138
138
  util.addListener(socket, 'error', function (err) {
139
- assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID')
140
-
141
139
  this[kError] = err
142
140
 
143
141
  this[kClient][kOnError](err)
@@ -211,8 +209,6 @@ function resumeH2 (client) {
211
209
  }
212
210
 
213
211
  function onHttp2SessionError (err) {
214
- assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID')
215
-
216
212
  this[kSocket][kError] = err
217
213
  this[kClient][kOnError](err)
218
214
  }
@@ -107,9 +107,10 @@ class Client extends DispatcherBase {
107
107
  // h2
108
108
  maxConcurrentStreams,
109
109
  allowH2,
110
- webSocket
110
+ webSocket,
111
+ eventSource
111
112
  } = {}) {
112
- super({ webSocket })
113
+ super({ webSocket, eventSource })
113
114
 
114
115
  if (keepAlive !== undefined) {
115
116
  throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead')
@@ -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,
@@ -12,6 +13,7 @@ const kOnDestroyed = Symbol('onDestroyed')
12
13
  const kOnClosed = Symbol('onClosed')
13
14
  const kInterceptedDispatch = Symbol('Intercepted Dispatch')
14
15
  const kWebSocketOptions = Symbol('webSocketOptions')
16
+ const kEventSourceOptions = Symbol('eventSourceOptions')
15
17
 
16
18
  class DispatcherBase extends Dispatcher {
17
19
  constructor (opts) {
@@ -22,6 +24,7 @@ class DispatcherBase extends Dispatcher {
22
24
  this[kClosed] = false
23
25
  this[kOnClosed] = []
24
26
  this[kWebSocketOptions] = opts?.webSocket ?? {}
27
+ this[kEventSourceOptions] = opts?.eventSource ?? {}
25
28
  }
26
29
 
27
30
  get webSocketOptions () {
@@ -31,6 +34,12 @@ class DispatcherBase extends Dispatcher {
31
34
  }
32
35
  }
33
36
 
37
+ get eventSourceOptions () {
38
+ return {
39
+ maxEventSize: this[kEventSourceOptions].maxEventSize ?? buffer.kStringMaxLength
40
+ }
41
+ }
42
+
34
43
  get destroyed () {
35
44
  return this[kDestroyed]
36
45
  }
@@ -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,11 +24,19 @@ 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')
29
32
  const RETRY = Buffer.from('retry')
30
33
 
34
+ // Each data field creates a string node, and each unconsumed input chunk keeps
35
+ // a Buffer wrapper. The absolute ceiling protects the large default byte limit.
36
+ const minParserItems = 1024
37
+ const maxParserItems = 131072
38
+ const bytesPerParserItem = 128
39
+
31
40
  function isASCIINumberBytes (buffer, start) {
32
41
  if (start >= buffer.length) {
33
42
  return false
@@ -66,6 +75,12 @@ function isFieldName (line, length, field) {
66
75
  return true
67
76
  }
68
77
 
78
+ function createMaxEventSizeExceededError () {
79
+ const error = new Error('EventSource message size exceeded')
80
+ error.aborted = false
81
+ return error
82
+ }
83
+
69
84
  /**
70
85
  * @typedef {object} EventSourceStreamEvent
71
86
  * @type {object}
@@ -114,6 +129,13 @@ class EventSourceStream extends Transform {
114
129
  pos = 0
115
130
  lineChunkIndex = 0
116
131
  linePos = 0
132
+ lineLength = 0
133
+ eventSize = 0
134
+ eventFieldSizes = { data: 0, event: 0, id: 0, retry: 0 }
135
+ dataLineCount = 0
136
+ maxEventSize
137
+ maxDataLines
138
+ maxBufferedChunks
117
139
 
118
140
  event = {
119
141
  data: undefined,
@@ -124,6 +146,7 @@ class EventSourceStream extends Transform {
124
146
 
125
147
  /**
126
148
  * @param {object} options
149
+ * @param {number} [options.maxEventSize]
127
150
  * @param {eventSourceSettings} options.eventSourceSettings
128
151
  * @param {Function} [options.push]
129
152
  */
@@ -135,6 +158,12 @@ class EventSourceStream extends Transform {
135
158
  super(options)
136
159
 
137
160
  this.state = options.eventSourceSettings || {}
161
+ this.maxEventSize = options.maxEventSize ?? defaultMaxEventSize
162
+ const parserItemLimit = this.maxEventSize > 0
163
+ ? Math.min(maxParserItems, Math.max(minParserItems, Math.floor(this.maxEventSize / bytesPerParserItem)))
164
+ : 0
165
+ this.maxDataLines = parserItemLimit
166
+ this.maxBufferedChunks = parserItemLimit
138
167
  if (options.push) {
139
168
  this.push = options.push
140
169
  }
@@ -152,6 +181,14 @@ class EventSourceStream extends Transform {
152
181
  return
153
182
  }
154
183
 
184
+ // A leading line ending can immediately release the currently buffered
185
+ // line, even when it uses the final allowed chunk slot.
186
+ if (this.maxEventSize > 0 &&
187
+ this.chunks.length >= this.maxBufferedChunks &&
188
+ chunk[0] !== LF && chunk[0] !== CR) {
189
+ callback(createMaxEventSizeExceededError())
190
+ return
191
+ }
155
192
  this.chunks.push(chunk)
156
193
 
157
194
  // Strip leading byte-order-mark if we opened the stream and started
@@ -230,7 +267,12 @@ class EventSourceStream extends Transform {
230
267
 
231
268
  // In any case, we can process the line as we reached an
232
269
  // end-of-line character
233
- this.parseLine(this.readLine(), this.event)
270
+ try {
271
+ this.parseLine(this.readLine(), this.event)
272
+ } catch (error) {
273
+ callback(error)
274
+ return
275
+ }
234
276
  this.consumeCurrentByte()
235
277
  // A line was processed and this could be the end of the event. We need
236
278
  // to check if the next line is empty to determine if the event is
@@ -239,15 +281,28 @@ class EventSourceStream extends Transform {
239
281
  continue
240
282
  }
241
283
 
284
+ // readLine() is only called after CR or LF. Reject an unfinished line
285
+ // before it can retain an unbounded number of decoded chunks. The seven
286
+ // extra bytes allow the longest field prefix ("event: " or "retry: ").
287
+ if (this.maxEventSize > 0 && ++this.lineLength > this.maxEventSize + 7) {
288
+ callback(createMaxEventSizeExceededError())
289
+ return
290
+ }
242
291
  this.advanceCursor()
243
292
  }
244
293
 
245
294
  callback()
246
295
  }
247
296
 
297
+ _destroy (error, callback) {
298
+ this.chunks.length = 0
299
+ this.clearEvent()
300
+ super._destroy(error, callback)
301
+ }
302
+
248
303
  /**
249
304
  * @param {Buffer} line
250
- * @param {EventStreamEvent} event
305
+ * @param {EventSourceStreamEvent} event
251
306
  */
252
307
  parseLine (line, event) {
253
308
  // If the line is empty (a blank line)
@@ -281,6 +336,13 @@ class EventSourceStream extends Transform {
281
336
  }
282
337
 
283
338
  if (isFieldName(line, fieldLength, DATA)) {
339
+ if (this.maxEventSize > 0 && this.dataLineCount >= this.maxDataLines) {
340
+ throw createMaxEventSizeExceededError()
341
+ }
342
+ const valueBytes = line.length - valueStart
343
+ const dataSize = this.eventFieldSizes.data + (event.data === undefined ? 0 : 1) + valueBytes
344
+ this.setEventFieldSize('data', dataSize)
345
+
284
346
  const value = line.toString('utf8', valueStart)
285
347
 
286
348
  if (event.data === undefined) {
@@ -288,11 +350,13 @@ class EventSourceStream extends Transform {
288
350
  } else {
289
351
  event.data += `\n${value}`
290
352
  }
353
+ this.dataLineCount++
291
354
  return
292
355
  }
293
356
 
294
357
  if (isFieldName(line, fieldLength, RETRY)) {
295
358
  if (isASCIINumberBytes(line, valueStart)) {
359
+ this.setEventFieldSize('retry', line.length - valueStart)
296
360
  event.retry = line.toString('utf8', valueStart)
297
361
  }
298
362
  return
@@ -300,20 +364,29 @@ class EventSourceStream extends Transform {
300
364
 
301
365
  if (isFieldName(line, fieldLength, ID)) {
302
366
  if (isValidLastEventIdBytes(line, valueStart)) {
367
+ this.setEventFieldSize('id', line.length - valueStart)
303
368
  event.id = line.toString('utf8', valueStart)
304
369
  }
305
370
  return
306
371
  }
307
372
 
308
373
  if (isFieldName(line, fieldLength, EVENT)) {
309
- const value = line.toString('utf8', valueStart)
310
-
311
- if (value.length > 0) {
312
- event.event = value
374
+ if (valueStart < line.length) {
375
+ this.setEventFieldSize('event', line.length - valueStart)
376
+ event.event = line.toString('utf8', valueStart)
313
377
  }
314
378
  }
315
379
  }
316
380
 
381
+ setEventFieldSize (field, size) {
382
+ const eventSize = this.eventSize - this.eventFieldSizes[field] + size
383
+ if (this.maxEventSize > 0 && eventSize > this.maxEventSize) {
384
+ throw createMaxEventSizeExceededError()
385
+ }
386
+ this.eventSize = eventSize
387
+ this.eventFieldSizes[field] = size
388
+ }
389
+
317
390
  /**
318
391
  * @param {EventSourceStreamEvent} event
319
392
  */
@@ -344,6 +417,12 @@ class EventSourceStream extends Transform {
344
417
  this.event.event = undefined
345
418
  this.event.id = undefined
346
419
  this.event.retry = undefined
420
+ this.eventSize = 0
421
+ this.dataLineCount = 0
422
+ this.eventFieldSizes.data = 0
423
+ this.eventFieldSizes.event = 0
424
+ this.eventFieldSizes.id = 0
425
+ this.eventFieldSizes.retry = 0
347
426
  }
348
427
 
349
428
  hasPendingEvent () {
@@ -379,14 +458,16 @@ class EventSourceStream extends Transform {
379
458
  syncLineStartToCursor () {
380
459
  this.lineChunkIndex = this.chunkIndex
381
460
  this.linePos = this.pos
461
+ this.lineLength = 0
382
462
  this.dropConsumedChunks()
383
463
  }
384
464
 
385
465
  dropConsumedChunks () {
386
- while (this.lineChunkIndex > 0) {
387
- this.chunks.shift()
388
- this.lineChunkIndex--
389
- this.chunkIndex--
466
+ if (this.lineChunkIndex > 0) {
467
+ const consumed = this.lineChunkIndex
468
+ this.chunks.splice(0, consumed)
469
+ this.lineChunkIndex = 0
470
+ this.chunkIndex -= consumed
390
471
  }
391
472
 
392
473
  if (this.chunkIndex === this.chunks.length) {
@@ -11,6 +11,7 @@ const { isNetworkError } = require('../fetch/response')
11
11
  const { delay } = require('./util')
12
12
  const { kEnumerableProperty } = require('../../core/util')
13
13
  const { environmentSettingsObject } = require('../fetch/util')
14
+ const { getGlobalDispatcher } = require('../../global')
14
15
 
15
16
  let experimentalWarned = false
16
17
 
@@ -292,6 +293,7 @@ class EventSource extends EventTarget {
292
293
 
293
294
  const eventSourceStream = new EventSourceStream({
294
295
  eventSourceSettings: this.#state,
296
+ maxEventSize: this.#dispatcher.eventSourceOptions?.maxEventSize,
295
297
  push: (event) => {
296
298
  this.dispatchEvent(createFastMessageEvent(
297
299
  event.type,
@@ -470,7 +472,8 @@ webidl.converters.EventSourceInitDict = webidl.dictionaryConverter([
470
472
  },
471
473
  {
472
474
  key: 'dispatcher', // undici only
473
- converter: webidl.converters.any
475
+ converter: webidl.converters.any,
476
+ defaultValue: () => getGlobalDispatcher()
474
477
  }
475
478
  ])
476
479
 
@@ -1950,8 +1950,10 @@ async function httpNetworkFetch (
1950
1950
  // 19. Run these steps in parallel:
1951
1951
 
1952
1952
  // 1. Run these steps, but abort when fetchParams is canceled:
1953
- fetchParams.controller.onAborted = onAborted
1954
- fetchParams.controller.on('terminated', onAborted)
1953
+ if (!fetchParams.controller.resume) {
1954
+ fetchParams.controller.on('terminated', onAborted)
1955
+ }
1956
+
1955
1957
  fetchParams.controller.resume = async () => {
1956
1958
  // 1. While true
1957
1959
  while (true) {
@@ -2120,8 +2122,6 @@ async function httpNetworkFetch (
2120
2122
  }
2121
2123
  location = headersList.get('location', true)
2122
2124
 
2123
- this.body = new Readable({ read: resume })
2124
-
2125
2125
  const decoders = []
2126
2126
 
2127
2127
  const willFollow = location && request.redirect === 'follow' &&
@@ -2139,7 +2139,7 @@ async function httpNetworkFetch (
2139
2139
  // CVE fix similar to urllib3 (GHSA-gm62-xv2j-4w53) and curl (CVE-2022-32206).
2140
2140
  const maxContentEncodings = 5
2141
2141
  if (codings.length > maxContentEncodings) {
2142
- reject(new Error(`too many content-encodings in response: ${codings.length}, maximum allowed is ${maxContentEncodings}`))
2142
+ this.abort(new Error(`too many content-encodings in response: ${codings.length}, maximum allowed is ${maxContentEncodings}`))
2143
2143
  return true
2144
2144
  }
2145
2145
 
@@ -2172,6 +2172,8 @@ async function httpNetworkFetch (
2172
2172
  }
2173
2173
  }
2174
2174
 
2175
+ this.body = new Readable({ read: resume })
2176
+
2175
2177
  const onError = this.onError.bind(this)
2176
2178
 
2177
2179
  resolve({
@@ -2218,10 +2220,6 @@ async function httpNetworkFetch (
2218
2220
  fetchParams.controller.off('terminated', this.abort)
2219
2221
  }
2220
2222
 
2221
- if (fetchParams.controller.onAborted) {
2222
- fetchParams.controller.off('terminated', fetchParams.controller.onAborted)
2223
- }
2224
-
2225
2223
  fetchParams.controller.ended = true
2226
2224
 
2227
2225
  this.body.push(null)
@@ -249,6 +249,20 @@ class ByteParser extends Writable {
249
249
  if (isControlFrame(this.#info.opcode)) {
250
250
  this.#loop = this.parseControlFrame(body)
251
251
  this.#state = parserStates.INFO
252
+
253
+ // If the automatic PONG/CLOSE reply filled the socket's write buffer,
254
+ // stop parsing and resume on drain so a PING flood cannot grow the
255
+ // outbound queue without bound.
256
+ const socket = this.ws[kResponse].socket
257
+ if (this.#loop && socket.writableNeedDrain) {
258
+ this.#loop = false
259
+ socket.once('drain', () => {
260
+ if (this.destroyed) return
261
+ this.#loop = true
262
+ this.run(callback)
263
+ })
264
+ return
265
+ }
252
266
  } else {
253
267
  if (!this.#info.compressed) {
254
268
  if (!this.writeFragments(body)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "undici",
3
- "version": "6.29.0",
3
+ "version": "6.29.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": {
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. */
@@ -120,6 +122,18 @@ export declare namespace Client {
120
122
  */
121
123
  maxPayloadSize?: number;
122
124
  }
125
+ export interface EventSourceOptions {
126
+ /**
127
+ * Maximum total bytes of data and metadata field values retained for a
128
+ * pending EventSource event. Each unfinished line is separately bounded
129
+ * to this value plus seven syntax bytes. The number of data lines per
130
+ * event and buffered input chunks are each limited to
131
+ * min(131072, max(1024, floor(maxEventSize / 128))).
132
+ * Set to 0 to disable these limits.
133
+ * @default buffer.kStringMaxLength
134
+ */
135
+ maxEventSize?: number;
136
+ }
123
137
  }
124
138
 
125
139
  export default Client;