undici 6.24.1 → 6.26.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.
@@ -26,6 +26,8 @@ Returns: `Client`
26
26
  * **keepAliveTimeoutThreshold** `number | null` (optional) - Default: `2e3` - A number of milliseconds subtracted from server *keep-alive* hints when overriding `keepAliveTimeout` to account for timing inaccuracies caused by e.g. transport latency. Defaults to 2 seconds.
27
27
  * **maxHeaderSize** `number | null` (optional) - Default: `--max-http-header-size` or `16384` - The maximum length of request headers in bytes. Defaults to Node.js' --max-http-header-size or 16KiB.
28
28
  * **maxResponseSize** `number | null` (optional) - Default: `-1` - The maximum length of response body in bytes. Set to `-1` to disable.
29
+ * **webSocket** `WebSocketOptions` (optional) - WebSocket-specific configuration options.
30
+ * **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.
29
31
  * **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.
30
32
  * **connect** `ConnectOptions | Function | null` (optional) - Default: `null`.
31
33
  * **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.
@@ -24,8 +24,6 @@ function defaultFactory (origin, opts) {
24
24
 
25
25
  class Agent extends DispatcherBase {
26
26
  constructor ({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) {
27
- super()
28
-
29
27
  if (typeof factory !== 'function') {
30
28
  throw new InvalidArgumentError('factory must be a function.')
31
29
  }
@@ -38,6 +36,8 @@ class Agent extends DispatcherBase {
38
36
  throw new InvalidArgumentError('maxRedirections must be a positive number')
39
37
  }
40
38
 
39
+ super(options)
40
+
41
41
  if (connect && typeof connect !== 'function') {
42
42
  connect = { ...connect }
43
43
  }
@@ -279,29 +279,71 @@ class Parser {
279
279
 
280
280
  const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr
281
281
 
282
- if (ret === constants.ERROR.PAUSED_UPGRADE) {
283
- this.onUpgrade(data.slice(offset))
284
- } else if (ret === constants.ERROR.PAUSED) {
285
- this.paused = true
286
- socket.unshift(data.slice(offset))
287
- } else if (ret !== constants.ERROR.OK) {
288
- const ptr = llhttp.llhttp_get_error_reason(this.ptr)
289
- let message = ''
290
- /* istanbul ignore else: difficult to make a test case for */
291
- if (ptr) {
292
- const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0)
293
- message =
294
- 'Response does not match the HTTP/1.1 protocol (' +
295
- Buffer.from(llhttp.memory.buffer, ptr, len).toString() +
296
- ')'
282
+ if (ret !== constants.ERROR.OK) {
283
+ const body = data.subarray(offset)
284
+
285
+ if (ret === constants.ERROR.PAUSED_UPGRADE) {
286
+ this.onUpgrade(body)
287
+ } else if (ret === constants.ERROR.PAUSED) {
288
+ this.paused = true
289
+ socket.unshift(body)
290
+ } else {
291
+ throw this.createError(ret, body)
297
292
  }
298
- throw new HTTPParserError(message, constants.ERROR[ret], data.slice(offset))
299
293
  }
300
294
  } catch (err) {
301
295
  util.destroy(socket, err)
302
296
  }
303
297
  }
304
298
 
299
+ finish () {
300
+ assert(currentParser === null)
301
+ assert(this.ptr != null)
302
+ assert(!this.paused)
303
+
304
+ const { llhttp } = this
305
+
306
+ let ret
307
+
308
+ try {
309
+ currentParser = this
310
+ ret = llhttp.llhttp_finish(this.ptr)
311
+ } finally {
312
+ currentParser = null
313
+ }
314
+
315
+ if (ret === constants.ERROR.OK) {
316
+ return null
317
+ }
318
+
319
+ if (ret === constants.ERROR.PAUSED || ret === constants.ERROR.PAUSED_UPGRADE) {
320
+ this.paused = true
321
+ return null
322
+ }
323
+
324
+ return this.createError(ret, EMPTY_BUF)
325
+ }
326
+
327
+ createError (ret, data) {
328
+ const { llhttp, contentLength, bytesRead } = this
329
+
330
+ if (contentLength && bytesRead !== parseInt(contentLength, 10)) {
331
+ return new ResponseContentLengthMismatchError()
332
+ }
333
+
334
+ const ptr = llhttp.llhttp_get_error_reason(this.ptr)
335
+ let message = ''
336
+ if (ptr) {
337
+ const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0)
338
+ message =
339
+ 'Response does not match the HTTP/1.1 protocol (' +
340
+ Buffer.from(llhttp.memory.buffer, ptr, len).toString() +
341
+ ')'
342
+ }
343
+
344
+ return new HTTPParserError(message, constants.ERROR[ret], data)
345
+ }
346
+
305
347
  destroy () {
306
348
  assert(this.ptr != null)
307
349
  assert(currentParser == null)
@@ -673,8 +715,11 @@ async function connectH1 (client, socket) {
673
715
  // On Mac OS, we get an ECONNRESET even if there is a full body to be forwarded
674
716
  // to the user.
675
717
  if (err.code === 'ECONNRESET' && parser.statusCode && !parser.shouldKeepAlive) {
676
- // We treat all incoming data so for as a valid response.
677
- parser.onMessageComplete()
718
+ const parserErr = parser.finish()
719
+ if (parserErr) {
720
+ this[kError] = parserErr
721
+ this[kClient][kOnError](parserErr)
722
+ }
678
723
  return
679
724
  }
680
725
 
@@ -693,8 +738,10 @@ async function connectH1 (client, socket) {
693
738
  const parser = this[kParser]
694
739
 
695
740
  if (parser.statusCode && !parser.shouldKeepAlive) {
696
- // We treat all incoming data so far as a valid response.
697
- parser.onMessageComplete()
741
+ const parserErr = parser.finish()
742
+ if (parserErr) {
743
+ util.destroy(this, parserErr)
744
+ }
698
745
  return
699
746
  }
700
747
 
@@ -706,8 +753,7 @@ async function connectH1 (client, socket) {
706
753
 
707
754
  if (parser) {
708
755
  if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) {
709
- // We treat all incoming data so far as a valid response.
710
- parser.onMessageComplete()
756
+ this[kError] = parser.finish() || this[kError]
711
757
  }
712
758
 
713
759
  this[kParser].destroy()
@@ -106,9 +106,10 @@ class Client extends DispatcherBase {
106
106
  autoSelectFamilyAttemptTimeout,
107
107
  // h2
108
108
  maxConcurrentStreams,
109
- allowH2
109
+ allowH2,
110
+ webSocket
110
111
  } = {}) {
111
- super()
112
+ super({ webSocket })
112
113
 
113
114
  if (keepAlive !== undefined) {
114
115
  throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead')
@@ -11,15 +11,23 @@ const { kDestroy, kClose, kClosed, kDestroyed, kDispatch, kInterceptors } = requ
11
11
  const kOnDestroyed = Symbol('onDestroyed')
12
12
  const kOnClosed = Symbol('onClosed')
13
13
  const kInterceptedDispatch = Symbol('Intercepted Dispatch')
14
+ const kWebSocketOptions = Symbol('webSocketOptions')
14
15
 
15
16
  class DispatcherBase extends Dispatcher {
16
- constructor () {
17
+ constructor (opts) {
17
18
  super()
18
19
 
19
20
  this[kDestroyed] = false
20
21
  this[kOnDestroyed] = null
21
22
  this[kClosed] = false
22
23
  this[kOnClosed] = []
24
+ this[kWebSocketOptions] = opts?.webSocket ?? {}
25
+ }
26
+
27
+ get webSocketOptions () {
28
+ return {
29
+ maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024
30
+ }
23
31
  }
24
32
 
25
33
  get destroyed () {
@@ -19,8 +19,8 @@ const kRemoveClient = Symbol('remove client')
19
19
  const kStats = Symbol('stats')
20
20
 
21
21
  class PoolBase extends DispatcherBase {
22
- constructor () {
23
- super()
22
+ constructor (opts) {
23
+ super(opts)
24
24
 
25
25
  this[kQueue] = new FixedQueue()
26
26
  this[kClients] = []
@@ -37,8 +37,6 @@ class Pool extends PoolBase {
37
37
  allowH2,
38
38
  ...options
39
39
  } = {}) {
40
- super()
41
-
42
40
  if (connections != null && (!Number.isFinite(connections) || connections < 0)) {
43
41
  throw new InvalidArgumentError('invalid connections')
44
42
  }
@@ -63,6 +61,8 @@ class Pool extends PoolBase {
63
61
  })
64
62
  }
65
63
 
64
+ super(options)
65
+
66
66
  this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool)
67
67
  ? options.interceptors.Pool
68
68
  : []
@@ -8,40 +8,35 @@ const tail = Buffer.from([0x00, 0x00, 0xff, 0xff])
8
8
  const kBuffer = Symbol('kBuffer')
9
9
  const kLength = Symbol('kLength')
10
10
 
11
- // Default maximum decompressed message size: 4 MB
12
- const kDefaultMaxDecompressedSize = 4 * 1024 * 1024
13
-
14
11
  class PerMessageDeflate {
15
12
  /** @type {import('node:zlib').InflateRaw} */
16
13
  #inflate
17
14
 
18
15
  #options = {}
19
16
 
20
- /** @type {boolean} */
21
- #aborted = false
22
-
23
- /** @type {Function|null} */
24
- #currentCallback = null
17
+ #maxPayloadSize = 0
25
18
 
26
19
  /**
27
20
  * @param {Map<string, string>} extensions
28
21
  */
29
- constructor (extensions) {
22
+ constructor (extensions, options) {
30
23
  this.#options.serverNoContextTakeover = extensions.has('server_no_context_takeover')
31
24
  this.#options.serverMaxWindowBits = extensions.get('server_max_window_bits')
25
+
26
+ this.#maxPayloadSize = options.maxPayloadSize
32
27
  }
33
28
 
29
+ /**
30
+ * Decompress a compressed payload.
31
+ * @param {Buffer} chunk Compressed data
32
+ * @param {boolean} fin Final fragment flag
33
+ * @param {Function} callback Callback function
34
+ */
34
35
  decompress (chunk, fin, callback) {
35
36
  // An endpoint uses the following algorithm to decompress a message.
36
37
  // 1. Append 4 octets of 0x00 0x00 0xff 0xff to the tail end of the
37
38
  // payload of the message.
38
39
  // 2. Decompress the resulting data using DEFLATE.
39
-
40
- if (this.#aborted) {
41
- callback(new MessageSizeExceededError())
42
- return
43
- }
44
-
45
40
  if (!this.#inflate) {
46
41
  let windowBits = Z_DEFAULT_WINDOWBITS
47
42
 
@@ -64,23 +59,12 @@ class PerMessageDeflate {
64
59
  this.#inflate[kLength] = 0
65
60
 
66
61
  this.#inflate.on('data', (data) => {
67
- if (this.#aborted) {
68
- return
69
- }
70
-
71
62
  this.#inflate[kLength] += data.length
72
63
 
73
- if (this.#inflate[kLength] > kDefaultMaxDecompressedSize) {
74
- this.#aborted = true
64
+ if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) {
65
+ callback(new MessageSizeExceededError())
75
66
  this.#inflate.removeAllListeners()
76
- this.#inflate.destroy()
77
67
  this.#inflate = null
78
-
79
- if (this.#currentCallback) {
80
- const cb = this.#currentCallback
81
- this.#currentCallback = null
82
- cb(new MessageSizeExceededError())
83
- }
84
68
  return
85
69
  }
86
70
 
@@ -93,14 +77,13 @@ class PerMessageDeflate {
93
77
  })
94
78
  }
95
79
 
96
- this.#currentCallback = callback
97
80
  this.#inflate.write(chunk)
98
81
  if (fin) {
99
82
  this.#inflate.write(tail)
100
83
  }
101
84
 
102
85
  this.#inflate.flush(() => {
103
- if (this.#aborted || !this.#inflate) {
86
+ if (!this.#inflate) {
104
87
  return
105
88
  }
106
89
 
@@ -108,7 +91,6 @@ class PerMessageDeflate {
108
91
 
109
92
  this.#inflate[kBuffer].length = 0
110
93
  this.#inflate[kLength] = 0
111
- this.#currentCallback = null
112
94
 
113
95
  callback(null, full)
114
96
  })
@@ -18,6 +18,7 @@ const {
18
18
  const { WebsocketFrameSend } = require('./frame')
19
19
  const { closeWebSocketConnection } = require('./connection')
20
20
  const { PerMessageDeflate } = require('./permessage-deflate')
21
+ const { MessageSizeExceededError } = require('../../core/errors')
21
22
 
22
23
  // This code was influenced by ws released under the MIT license.
23
24
  // Copyright (c) 2011 Einar Otto Stangvik <einaros@gmail.com>
@@ -26,6 +27,7 @@ const { PerMessageDeflate } = require('./permessage-deflate')
26
27
 
27
28
  class ByteParser extends Writable {
28
29
  #buffers = []
30
+ #fragmentsBytes = 0
29
31
  #byteOffset = 0
30
32
  #loop = false
31
33
 
@@ -37,18 +39,23 @@ class ByteParser extends Writable {
37
39
  /** @type {Map<string, PerMessageDeflate>} */
38
40
  #extensions
39
41
 
42
+ /** @type {number} */
43
+ #maxPayloadSize
44
+
40
45
  /**
41
46
  * @param {import('./websocket').WebSocket} ws
42
47
  * @param {Map<string, string>|null} extensions
48
+ * @param {{ maxPayloadSize?: number }} [options]
43
49
  */
44
- constructor (ws, extensions) {
50
+ constructor (ws, extensions, options = {}) {
45
51
  super()
46
52
 
47
53
  this.ws = ws
48
54
  this.#extensions = extensions == null ? new Map() : extensions
55
+ this.#maxPayloadSize = options.maxPayloadSize ?? 0
49
56
 
50
57
  if (this.#extensions.has('permessage-deflate')) {
51
- this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions))
58
+ this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions, options))
52
59
  }
53
60
  }
54
61
 
@@ -64,6 +71,19 @@ class ByteParser extends Writable {
64
71
  this.run(callback)
65
72
  }
66
73
 
74
+ #validatePayloadLength () {
75
+ if (
76
+ this.#maxPayloadSize > 0 &&
77
+ !isControlFrame(this.#info.opcode) &&
78
+ this.#info.payloadLength > this.#maxPayloadSize
79
+ ) {
80
+ failWebsocketConnection(this.ws, 'Payload size exceeds maximum allowed size')
81
+ return false
82
+ }
83
+
84
+ return true
85
+ }
86
+
67
87
  /**
68
88
  * Runs whenever a new chunk is received.
69
89
  * Callback is called whenever there are no more chunks buffering,
@@ -152,6 +172,10 @@ class ByteParser extends Writable {
152
172
  if (payloadLength <= 125) {
153
173
  this.#info.payloadLength = payloadLength
154
174
  this.#state = parserStates.READ_DATA
175
+
176
+ if (!this.#validatePayloadLength()) {
177
+ return
178
+ }
155
179
  } else if (payloadLength === 126) {
156
180
  this.#state = parserStates.PAYLOADLENGTH_16
157
181
  } else if (payloadLength === 127) {
@@ -176,6 +200,10 @@ class ByteParser extends Writable {
176
200
 
177
201
  this.#info.payloadLength = buffer.readUInt16BE(0)
178
202
  this.#state = parserStates.READ_DATA
203
+
204
+ if (!this.#validatePayloadLength()) {
205
+ return
206
+ }
179
207
  } else if (this.#state === parserStates.PAYLOADLENGTH_64) {
180
208
  if (this.#byteOffset < 8) {
181
209
  return callback()
@@ -198,6 +226,10 @@ class ByteParser extends Writable {
198
226
 
199
227
  this.#info.payloadLength = lower
200
228
  this.#state = parserStates.READ_DATA
229
+
230
+ if (!this.#validatePayloadLength()) {
231
+ return
232
+ }
201
233
  } else if (this.#state === parserStates.READ_DATA) {
202
234
  if (this.#byteOffset < this.#info.payloadLength) {
203
235
  return callback()
@@ -210,42 +242,53 @@ class ByteParser extends Writable {
210
242
  this.#state = parserStates.INFO
211
243
  } else {
212
244
  if (!this.#info.compressed) {
213
- this.#fragments.push(body)
245
+ this.writeFragments(body)
246
+
247
+ if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) {
248
+ failWebsocketConnection(this.ws, new MessageSizeExceededError().message)
249
+ return
250
+ }
214
251
 
215
252
  // If the frame is not fragmented, a message has been received.
216
253
  // If the frame is fragmented, it will terminate with a fin bit set
217
254
  // and an opcode of 0 (continuation), therefore we handle that when
218
255
  // parsing continuation frames, not here.
219
256
  if (!this.#info.fragmented && this.#info.fin) {
220
- const fullMessage = Buffer.concat(this.#fragments)
221
- websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage)
222
- this.#fragments.length = 0
257
+ websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments())
223
258
  }
224
259
 
225
260
  this.#state = parserStates.INFO
226
261
  } else {
227
- this.#extensions.get('permessage-deflate').decompress(body, this.#info.fin, (error, data) => {
228
- if (error) {
229
- failWebsocketConnection(this.ws, error.message)
230
- return
231
- }
262
+ this.#extensions.get('permessage-deflate').decompress(
263
+ body,
264
+ this.#info.fin,
265
+ (error, data) => {
266
+ if (error) {
267
+ failWebsocketConnection(this.ws, error.message)
268
+ return
269
+ }
270
+
271
+ this.writeFragments(data)
272
+
273
+ if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) {
274
+ failWebsocketConnection(this.ws, new MessageSizeExceededError().message)
275
+ return
276
+ }
277
+
278
+ if (!this.#info.fin) {
279
+ this.#state = parserStates.INFO
280
+ this.#loop = true
281
+ this.run(callback)
282
+ return
283
+ }
284
+
285
+ websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments())
232
286
 
233
- this.#fragments.push(data)
234
-
235
- if (!this.#info.fin) {
236
- this.#state = parserStates.INFO
237
287
  this.#loop = true
288
+ this.#state = parserStates.INFO
238
289
  this.run(callback)
239
- return
240
290
  }
241
-
242
- websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments))
243
-
244
- this.#loop = true
245
- this.#state = parserStates.INFO
246
- this.#fragments.length = 0
247
- this.run(callback)
248
- })
291
+ )
249
292
 
250
293
  this.#loop = false
251
294
  break
@@ -297,6 +340,26 @@ class ByteParser extends Writable {
297
340
  return buffer
298
341
  }
299
342
 
343
+ writeFragments (fragment) {
344
+ this.#fragmentsBytes += fragment.length
345
+ this.#fragments.push(fragment)
346
+ }
347
+
348
+ consumeFragments () {
349
+ const fragments = this.#fragments
350
+
351
+ if (fragments.length === 1) {
352
+ this.#fragmentsBytes = 0
353
+ return fragments.shift()
354
+ }
355
+
356
+ const output = Buffer.concat(fragments, this.#fragmentsBytes)
357
+ this.#fragments = []
358
+ this.#fragmentsBytes = 0
359
+
360
+ return output
361
+ }
362
+
300
363
  parseCloseBody (data) {
301
364
  assert(data.length !== 1)
302
365
 
@@ -435,7 +435,11 @@ class WebSocket extends EventTarget {
435
435
  // once this happens, the connection is open
436
436
  this[kResponse] = response
437
437
 
438
- const parser = new ByteParser(this, parsedExtensions)
438
+ const maxPayloadSize = this[kController]?.dispatcher?.webSocketOptions?.maxPayloadSize
439
+
440
+ const parser = new ByteParser(this, parsedExtensions, {
441
+ maxPayloadSize
442
+ })
439
443
  parser.on('drain', onParserDrain)
440
444
  parser.on('error', onParserError.bind(this))
441
445
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "undici",
3
- "version": "6.24.1",
3
+ "version": "6.26.0",
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
@@ -78,6 +78,8 @@ export declare namespace Client {
78
78
  localAddress?: string;
79
79
  /** Max response body size in bytes, -1 is disabled */
80
80
  maxResponseSize?: number;
81
+ /** WebSocket-specific options */
82
+ webSocket?: Client.WebSocketOptions;
81
83
  /** Enables a family autodetection algorithm that loosely implements section 5 of RFC 8305. */
82
84
  autoSelectFamily?: boolean;
83
85
  /** The amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the `autoSelectFamily` option. */
@@ -103,6 +105,15 @@ export declare namespace Client {
103
105
  bytesWritten?: number
104
106
  bytesRead?: number
105
107
  }
108
+ export interface WebSocketOptions {
109
+ /**
110
+ * Maximum allowed payload size in bytes for WebSocket messages.
111
+ * Applied to uncompressed messages, compressed frame payloads, and decompressed (permessage-deflate) messages.
112
+ * Set to 0 to disable the limit.
113
+ * @default 134217728 (128 MB)
114
+ */
115
+ maxPayloadSize?: number;
116
+ }
106
117
  }
107
118
 
108
119
  export default Client;