undici 6.7.1 → 6.8.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,7 @@ import { errors } from 'undici'
26
26
  | `ResponseContentLengthMismatchError` | `UND_ERR_RES_CONTENT_LENGTH_MISMATCH` | response body does not match content-length header |
27
27
  | `InformationalError` | `UND_ERR_INFO` | expected error with reason |
28
28
  | `ResponseExceededMaxSizeError` | `UND_ERR_RES_EXCEEDED_MAX_SIZE` | response body exceed the max size allowed |
29
+ | `SecureProxyConnectionError` | `UND_ERR_PRX_TLS` | tls connection to a proxy failed |
29
30
 
30
31
  ### `SocketError`
31
32
 
@@ -91,7 +91,8 @@ class RequestHandler extends AsyncResource {
91
91
 
92
92
  const parsedHeaders = responseHeaders === 'raw' ? util.parseHeaders(rawHeaders) : headers
93
93
  const contentType = parsedHeaders['content-type']
94
- const body = new Readable({ resume, abort, contentType, highWaterMark })
94
+ const contentLength = parsedHeaders['content-length']
95
+ const body = new Readable({ resume, abort, contentType, contentLength, highWaterMark })
95
96
 
96
97
  this.callback = null
97
98
  this.res = body
@@ -11,8 +11,9 @@ const { ReadableStreamFrom } = require('../core/util')
11
11
  const kConsume = Symbol('kConsume')
12
12
  const kReading = Symbol('kReading')
13
13
  const kBody = Symbol('kBody')
14
- const kAbort = Symbol('abort')
14
+ const kAbort = Symbol('kAbort')
15
15
  const kContentType = Symbol('kContentType')
16
+ const kContentLength = Symbol('kContentLength')
16
17
 
17
18
  const noop = () => {}
18
19
 
@@ -21,6 +22,7 @@ class BodyReadable extends Readable {
21
22
  resume,
22
23
  abort,
23
24
  contentType = '',
25
+ contentLength,
24
26
  highWaterMark = 64 * 1024 // Same as nodejs fs streams.
25
27
  }) {
26
28
  super({
@@ -35,6 +37,7 @@ class BodyReadable extends Readable {
35
37
  this[kConsume] = null
36
38
  this[kBody] = null
37
39
  this[kContentType] = contentType
40
+ this[kContentLength] = contentLength
38
41
 
39
42
  // Is stream being consumed through Readable API?
40
43
  // This is an optimization so that we avoid checking
@@ -146,7 +149,7 @@ class BodyReadable extends Readable {
146
149
  }
147
150
 
148
151
  async dump (opts) {
149
- let limit = Number.isFinite(opts?.limit) ? opts.limit : 262144
152
+ let limit = Number.isFinite(opts?.limit) ? opts.limit : 128 * 1024
150
153
  const signal = opts?.signal
151
154
 
152
155
  if (signal != null && (typeof signal !== 'object' || !('aborted' in signal))) {
@@ -160,6 +163,10 @@ class BodyReadable extends Readable {
160
163
  }
161
164
 
162
165
  return await new Promise((resolve, reject) => {
166
+ if (this[kContentLength] > limit) {
167
+ this.destroy(new AbortError())
168
+ }
169
+
163
170
  const onAbort = () => {
164
171
  this.destroy(signal.reason ?? new AbortError())
165
172
  }
package/lib/api/util.js CHANGED
@@ -21,28 +21,66 @@ async function getResolveErrorBodyCallback ({ callback, body, contentType, statu
21
21
  }
22
22
  }
23
23
 
24
+ const message = `Response status code ${statusCode}${statusMessage ? `: ${statusMessage}` : ''}`
25
+
24
26
  if (statusCode === 204 || !contentType || !chunks) {
25
- process.nextTick(callback, new ResponseStatusCodeError(`Response status code ${statusCode}${statusMessage ? `: ${statusMessage}` : ''}`, statusCode, headers))
27
+ queueMicrotask(() => callback(new ResponseStatusCodeError(message, statusCode, headers)))
26
28
  return
27
29
  }
28
30
 
29
- try {
30
- if (contentType.startsWith('application/json')) {
31
- const payload = JSON.parse(chunksDecode(chunks, length))
32
- process.nextTick(callback, new ResponseStatusCodeError(`Response status code ${statusCode}${statusMessage ? `: ${statusMessage}` : ''}`, statusCode, headers, payload))
33
- return
34
- }
31
+ const stackTraceLimit = Error.stackTraceLimit
32
+ Error.stackTraceLimit = 0
33
+ let payload
35
34
 
36
- if (contentType.startsWith('text/')) {
37
- const payload = chunksDecode(chunks, length)
38
- process.nextTick(callback, new ResponseStatusCodeError(`Response status code ${statusCode}${statusMessage ? `: ${statusMessage}` : ''}`, statusCode, headers, payload))
39
- return
35
+ try {
36
+ if (isContentTypeApplicationJson(contentType)) {
37
+ payload = JSON.parse(chunksDecode(chunks, length))
38
+ } else if (isContentTypeText(contentType)) {
39
+ payload = chunksDecode(chunks, length)
40
40
  }
41
- } catch (err) {
42
- // Process in a fallback if error
41
+ } catch {
42
+ // process in a callback to avoid throwing in the microtask queue
43
+ } finally {
44
+ Error.stackTraceLimit = stackTraceLimit
43
45
  }
46
+ queueMicrotask(() => callback(new ResponseStatusCodeError(message, statusCode, headers, payload)))
47
+ }
48
+
49
+ const isContentTypeApplicationJson = (contentType) => {
50
+ return (
51
+ contentType.length > 15 &&
52
+ contentType[11] === '/' &&
53
+ contentType[0] === 'a' &&
54
+ contentType[1] === 'p' &&
55
+ contentType[2] === 'p' &&
56
+ contentType[3] === 'l' &&
57
+ contentType[4] === 'i' &&
58
+ contentType[5] === 'c' &&
59
+ contentType[6] === 'a' &&
60
+ contentType[7] === 't' &&
61
+ contentType[8] === 'i' &&
62
+ contentType[9] === 'o' &&
63
+ contentType[10] === 'n' &&
64
+ contentType[12] === 'j' &&
65
+ contentType[13] === 's' &&
66
+ contentType[14] === 'o' &&
67
+ contentType[15] === 'n'
68
+ )
69
+ }
44
70
 
45
- process.nextTick(callback, new ResponseStatusCodeError(`Response status code ${statusCode}${statusMessage ? `: ${statusMessage}` : ''}`, statusCode, headers))
71
+ const isContentTypeText = (contentType) => {
72
+ return (
73
+ contentType.length > 4 &&
74
+ contentType[4] === '/' &&
75
+ contentType[0] === 't' &&
76
+ contentType[1] === 'e' &&
77
+ contentType[2] === 'x' &&
78
+ contentType[3] === 't'
79
+ )
46
80
  }
47
81
 
48
- module.exports = { getResolveErrorBodyCallback }
82
+ module.exports = {
83
+ getResolveErrorBodyCallback,
84
+ isContentTypeApplicationJson,
85
+ isContentTypeText
86
+ }
@@ -195,6 +195,16 @@ class RequestRetryError extends UndiciError {
195
195
  }
196
196
  }
197
197
 
198
+ class SecureProxyConnectionError extends UndiciError {
199
+ constructor (cause, message, options) {
200
+ super(message, { cause, ...(options ?? {}) })
201
+ this.name = 'SecureProxyConnectionError'
202
+ this.message = message || 'Secure Proxy Connection failed'
203
+ this.code = 'UND_ERR_PRX_TLS'
204
+ this.cause = cause
205
+ }
206
+ }
207
+
198
208
  module.exports = {
199
209
  AbortError,
200
210
  HTTPParserError,
@@ -216,5 +226,6 @@ module.exports = {
216
226
  ResponseContentLengthMismatchError,
217
227
  BalancedPoolMissingUpstreamError,
218
228
  ResponseExceededMaxSizeError,
219
- RequestRetryError
229
+ RequestRetryError,
230
+ SecureProxyConnectionError
220
231
  }
@@ -40,7 +40,8 @@ class Request {
40
40
  bodyTimeout,
41
41
  reset,
42
42
  throwOnError,
43
- expectContinue
43
+ expectContinue,
44
+ servername
44
45
  }, handler) {
45
46
  if (typeof path !== 'string') {
46
47
  throw new InvalidArgumentError('path must be a string')
@@ -181,7 +182,7 @@ class Request {
181
182
 
182
183
  validateHandler(handler, method, upgrade)
183
184
 
184
- this.servername = getServerName(this.host)
185
+ this.servername = servername || getServerName(this.host)
185
186
 
186
187
  this[kHandler] = handler
187
188
 
package/lib/core/util.js CHANGED
@@ -440,7 +440,8 @@ function addAbortListener (signal, listener) {
440
440
  return () => signal.removeListener('abort', listener)
441
441
  }
442
442
 
443
- const hasToWellFormed = !!String.prototype.toWellFormed
443
+ const hasToWellFormed = typeof String.prototype.toWellFormed === 'function'
444
+ const hasIsWellFormed = typeof String.prototype.isWellFormed === 'function'
444
445
 
445
446
  /**
446
447
  * @param {string} val
@@ -449,6 +450,14 @@ function toUSVString (val) {
449
450
  return hasToWellFormed ? `${val}`.toWellFormed() : nodeUtil.toUSVString(val)
450
451
  }
451
452
 
453
+ /**
454
+ * @param {string} val
455
+ */
456
+ // TODO: move this to webidl
457
+ function isUSVString (val) {
458
+ return hasIsWellFormed ? `${val}`.isWellFormed() : toUSVString(val) === `${val}`
459
+ }
460
+
452
461
  /**
453
462
  * @see https://tools.ietf.org/html/rfc7230#section-3.2.6
454
463
  * @param {number} c
@@ -538,6 +547,7 @@ module.exports = {
538
547
  isErrored,
539
548
  isReadable,
540
549
  toUSVString,
550
+ isUSVString,
541
551
  isReadableAborted,
542
552
  isBlobLike,
543
553
  parseOrigin,
@@ -5,7 +5,7 @@ const { URL } = require('node:url')
5
5
  const Agent = require('./agent')
6
6
  const Pool = require('./pool')
7
7
  const DispatcherBase = require('./dispatcher-base')
8
- const { InvalidArgumentError, RequestAbortedError } = require('../core/errors')
8
+ const { InvalidArgumentError, RequestAbortedError, SecureProxyConnectionError } = require('../core/errors')
9
9
  const buildConnector = require('../core/connect')
10
10
 
11
11
  const kAgent = Symbol('proxy agent')
@@ -37,10 +37,9 @@ class ProxyAgent extends DispatcherBase {
37
37
  }
38
38
 
39
39
  const url = this.#getUrl(opts)
40
- const { href, origin, port, protocol, username, password } = url
40
+ const { href, origin, port, protocol, username, password, hostname: proxyHostname } = url
41
41
 
42
42
  this[kProxy] = { uri: href, protocol }
43
- this[kAgent] = new Agent(opts)
44
43
  this[kInterceptors] = opts.interceptors?.ProxyAgent && Array.isArray(opts.interceptors.ProxyAgent)
45
44
  ? opts.interceptors.ProxyAgent
46
45
  : []
@@ -78,7 +77,8 @@ class ProxyAgent extends DispatcherBase {
78
77
  headers: {
79
78
  ...this[kProxyHeaders],
80
79
  host: requestedHost
81
- }
80
+ },
81
+ servername: this[kProxyTls]?.servername || proxyHostname
82
82
  })
83
83
  if (statusCode !== 200) {
84
84
  socket.on('error', () => {}).destroy()
@@ -96,7 +96,12 @@ class ProxyAgent extends DispatcherBase {
96
96
  }
97
97
  this[kConnectEndpoint]({ ...opts, servername, httpSocket: socket }, callback)
98
98
  } catch (err) {
99
- callback(err)
99
+ if (err.code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
100
+ // Throw a custom error to avoid loop in client.js#connect
101
+ callback(new SecureProxyConnectionError(err))
102
+ } else {
103
+ callback(err)
104
+ }
100
105
  }
101
106
  }
102
107
  })
@@ -1,12 +1,12 @@
1
1
  'use strict'
2
2
 
3
- const { webidl } = require('./webidl')
3
+ const { toUSVString, isUSVString, bufferToLowerCasedHeaderName } = require('../../core/util')
4
4
  const { utf8DecodeBytes } = require('./util')
5
5
  const { HTTP_TOKEN_CODEPOINTS, isomorphicDecode } = require('./data-url')
6
6
  const { isFileLike, File: UndiciFile } = require('./file')
7
7
  const { makeEntry } = require('./formdata')
8
8
  const assert = require('node:assert')
9
- const { isAscii, File: NodeFile } = require('node:buffer')
9
+ const { File: NodeFile } = require('node:buffer')
10
10
 
11
11
  const File = globalThis.File ?? NodeFile ?? UndiciFile
12
12
 
@@ -15,6 +15,18 @@ const filenameBuffer = Buffer.from('; filename')
15
15
  const dd = Buffer.from('--')
16
16
  const ddcrlf = Buffer.from('--\r\n')
17
17
 
18
+ /**
19
+ * @param {string} chars
20
+ */
21
+ function isAsciiString (chars) {
22
+ for (let i = 0; i < chars.length; ++i) {
23
+ if ((chars.charCodeAt(i) & ~0x7F) !== 0) {
24
+ return false
25
+ }
26
+ }
27
+ return true
28
+ }
29
+
18
30
  /**
19
31
  * @see https://andreubotella.github.io/multipart-form-data/#multipart-form-data-boundary
20
32
  * @param {string} boundary
@@ -30,7 +42,7 @@ function validateBoundary (boundary) {
30
42
  // - it is composed by bytes in the ranges 0x30 to 0x39, 0x41 to 0x5A, or
31
43
  // 0x61 to 0x7A, inclusive (ASCII alphanumeric), or which are 0x27 ('),
32
44
  // 0x2D (-) or 0x5F (_).
33
- for (let i = 0; i < boundary.length; i++) {
45
+ for (let i = 0; i < length; ++i) {
34
46
  const cp = boundary.charCodeAt(i)
35
47
 
36
48
  if (!(
@@ -58,12 +70,12 @@ function escapeFormDataName (name, encoding = 'utf-8', isFilename = false) {
58
70
  // 1. If isFilename is true:
59
71
  if (isFilename) {
60
72
  // 1.1. Set name to the result of converting name into a scalar value string.
61
- name = webidl.converters.USVString(name)
73
+ name = toUSVString(name)
62
74
  } else {
63
75
  // 2. Otherwise:
64
76
 
65
77
  // 2.1. Assert: name is a scalar value string.
66
- assert(name === webidl.converters.USVString(name))
78
+ assert(isUSVString(name))
67
79
 
68
80
  // 2.2. Replace every occurrence of U+000D (CR) not followed by U+000A (LF),
69
81
  // and every occurrence of U+000A (LF) not preceded by U+000D (CR), in
@@ -94,14 +106,16 @@ function multipartFormDataParser (input, mimeType) {
94
106
  // 1. Assert: mimeType’s essence is "multipart/form-data".
95
107
  assert(mimeType !== 'failure' && mimeType.essence === 'multipart/form-data')
96
108
 
109
+ const boundaryString = mimeType.parameters.get('boundary')
110
+
97
111
  // 2. If mimeType’s parameters["boundary"] does not exist, return failure.
98
112
  // Otherwise, let boundary be the result of UTF-8 decoding mimeType’s
99
113
  // parameters["boundary"].
100
- if (!mimeType.parameters.has('boundary')) {
114
+ if (boundaryString === undefined) {
101
115
  return 'failure'
102
116
  }
103
117
 
104
- const boundary = Buffer.from(`--${mimeType.parameters.get('boundary')}`, 'utf8')
118
+ const boundary = Buffer.from(`--${boundaryString}`, 'utf8')
105
119
 
106
120
  // 3. Let entry list be an empty entry list.
107
121
  const entryList = []
@@ -200,7 +214,10 @@ function multipartFormDataParser (input, mimeType) {
200
214
  contentType ??= 'text/plain'
201
215
 
202
216
  // 5.10.2. If contentType is not an ASCII string, set contentType to the empty string.
203
- if (!isAscii(Buffer.from(contentType))) {
217
+
218
+ // Note: `buffer.isAscii` can be used at zero-cost, but converting a string to a buffer is a high overhead.
219
+ // Content-Type is a relatively small string, so it is faster to use `String#charCodeAt`.
220
+ if (!isAsciiString(contentType)) {
204
221
  contentType = ''
205
222
  }
206
223
 
@@ -214,8 +231,8 @@ function multipartFormDataParser (input, mimeType) {
214
231
  }
215
232
 
216
233
  // 5.12. Assert: name is a scalar value string and value is either a scalar value string or a File object.
217
- assert(name === webidl.converters.USVString(name))
218
- assert((typeof value === 'string' && value === webidl.converters.USVString(value)) || isFileLike(value))
234
+ assert(isUSVString(name))
235
+ assert((typeof value === 'string' && isUSVString(value)) || isFileLike(value))
219
236
 
220
237
  // 5.13. Create an entry with name and value, and append it to entry list.
221
238
  entryList.push(makeEntry(name, value, filename))
@@ -280,7 +297,7 @@ function parseMultipartFormDataHeaders (input, position) {
280
297
  )
281
298
 
282
299
  // 2.8. Byte-lowercase header name and switch on the result:
283
- switch (new TextDecoder().decode(headerName).toLowerCase()) {
300
+ switch (bufferToLowerCasedHeaderName(headerName)) {
284
301
  case 'content-disposition': {
285
302
  // 1. Set name and filename to null.
286
303
  name = filename = null
@@ -428,10 +445,9 @@ function parseMultipartFormDataName (input, position) {
428
445
  */
429
446
  function collectASequenceOfBytes (condition, input, position) {
430
447
  const result = []
431
- let index = 0
432
448
 
433
449
  while (position.position < input.length && condition(input[position.position])) {
434
- result[index++] = input[position.position]
450
+ result.push(input[position.position])
435
451
 
436
452
  position.position++
437
453
  }
@@ -12,6 +12,7 @@ const {
12
12
  } = require('./util')
13
13
  const { webidl } = require('./webidl')
14
14
  const assert = require('node:assert')
15
+ const util = require('util')
15
16
 
16
17
  const kHeadersMap = Symbol('headers map')
17
18
  const kHeadersSortedMap = Symbol('headers map sorted')
@@ -576,8 +577,18 @@ class Headers {
576
577
 
577
578
  return this[kHeadersList]
578
579
  }
580
+
581
+ [util.inspect.custom] (depth, options) {
582
+ const inspected = util.inspect(this[kHeadersList].entries)
583
+
584
+ return `Headers ${inspected}`
585
+ }
579
586
  }
580
587
 
588
+ Object.defineProperty(Headers.prototype, util.inspect.custom, {
589
+ enumerable: false
590
+ })
591
+
581
592
  iteratorMixin('Headers', Headers, kHeadersSortedMap, 0, 1)
582
593
 
583
594
  Object.defineProperties(Headers.prototype, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "undici",
3
- "version": "6.7.1",
3
+ "version": "6.8.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": {
@@ -107,6 +107,7 @@
107
107
  "jest": "^29.0.2",
108
108
  "jsdom": "^24.0.0",
109
109
  "jsfuzz": "^1.0.15",
110
+ "node-forge": "^1.3.1",
110
111
  "pre-commit": "^1.2.2",
111
112
  "proxy": "^2.1.1",
112
113
  "snazzy": "^9.0.0",