undici 6.7.1 → 6.9.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.
@@ -29,7 +29,8 @@ Returns: `Client`
29
29
  * **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
30
  * **connect** `ConnectOptions | Function | null` (optional) - Default: `null`.
31
31
  * **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.
32
- * **interceptors** `{ Client: DispatchInterceptor[] }` - Default: `[RedirectInterceptor]` - A list of interceptors that are applied to the dispatch method. Additional logic can be applied (such as, but not limited to: 302 status code handling, authentication, cookies, compression and caching). Note that the behavior of interceptors is Experimental and might change at any given time.
32
+ <!-- TODO: Remove once we drop its support -->
33
+ * **interceptors** `{ Client: DispatchInterceptor[] }` - Default: `[RedirectInterceptor]` - A list of interceptors that are applied to the dispatch method. Additional logic can be applied (such as, but not limited to: 302 status code handling, authentication, cookies, compression and caching). Note that the behavior of interceptors is Experimental and might change at any given time. **Note: this is deprecated in favor of [Dispatcher#compose](./Dispatcher.md#dispatcher). Support will be droped in next major.**
33
34
  * **autoSelectFamily**: `boolean` (optional) - Default: depends on local Node version, on Node 18.13.0 and above is `false`. Enables a family autodetection algorithm that loosely implements section 5 of [RFC 8305](https://tools.ietf.org/html/rfc8305#section-5). See [here](https://nodejs.org/api/net.html#socketconnectoptions-connectlistener) for more details. This option is ignored if not supported by the current Node version.
34
35
  * **autoSelectFamilyAttemptTimeout**: `number` - Default: depends on local Node version, on Node 18.13.0 and above is `250`. The amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the `autoSelectFamily` option. See [here](https://nodejs.org/api/net.html#socketconnectoptions-connectlistener) for more details.
35
36
  * **allowH2**: `boolean` - Default: `false`. Enables support for H2 if the server has assigned bigger priority to it through ALPN negotiation.
@@ -817,6 +817,141 @@ try {
817
817
  }
818
818
  ```
819
819
 
820
+ ### `Dispatcher.compose(interceptors[, interceptor])`
821
+
822
+ Compose a new dispatcher from the current dispatcher and the given interceptors.
823
+
824
+ > _Notes_:
825
+ > - The order of the interceptors matters. The first interceptor will be the first to be called.
826
+ > - It is important to note that the `interceptor` function should return a function that follows the `Dispatcher.dispatch` signature.
827
+ > - Any fork of the chain of `interceptors` can lead to unexpected results.
828
+
829
+ Arguments:
830
+
831
+ * **interceptors** `Interceptor[interceptor[]]`: It is an array of `Interceptor` functions passed as only argument, or several interceptors passed as separate arguments.
832
+
833
+ Returns: `Dispatcher`.
834
+
835
+ #### Parameter: `Interceptor`
836
+
837
+ A function that takes a `dispatch` method and returns a `dispatch`-like function.
838
+
839
+ #### Example 1 - Basic Compose
840
+
841
+ ```js
842
+ const { Client, RedirectHandler } = require('undici')
843
+
844
+ const redirectInterceptor = dispatch => {
845
+ return (opts, handler) => {
846
+ const { maxRedirections } = opts
847
+
848
+ if (!maxRedirections) {
849
+ return dispatch(opts, handler)
850
+ }
851
+
852
+ const redirectHandler = new RedirectHandler(
853
+ dispatch,
854
+ maxRedirections,
855
+ opts,
856
+ handler
857
+ )
858
+ opts = { ...opts, maxRedirections: 0 } // Stop sub dispatcher from also redirecting.
859
+ return dispatch(opts, redirectHandler)
860
+ }
861
+ }
862
+
863
+ const client = new Client('http://localhost:3000')
864
+ .compose(redirectInterceptor)
865
+
866
+ await client.request({ path: '/', method: 'GET' })
867
+ ```
868
+
869
+ #### Example 2 - Chained Compose
870
+
871
+ ```js
872
+ const { Client, RedirectHandler, RetryHandler } = require('undici')
873
+
874
+ const redirectInterceptor = dispatch => {
875
+ return (opts, handler) => {
876
+ const { maxRedirections } = opts
877
+
878
+ if (!maxRedirections) {
879
+ return dispatch(opts, handler)
880
+ }
881
+
882
+ const redirectHandler = new RedirectHandler(
883
+ dispatch,
884
+ maxRedirections,
885
+ opts,
886
+ handler
887
+ )
888
+ opts = { ...opts, maxRedirections: 0 }
889
+ return dispatch(opts, redirectHandler)
890
+ }
891
+ }
892
+
893
+ const retryInterceptor = dispatch => {
894
+ return function retryInterceptor (opts, handler) {
895
+ return dispatch(
896
+ opts,
897
+ new RetryHandler(opts, {
898
+ handler,
899
+ dispatch
900
+ })
901
+ )
902
+ }
903
+ }
904
+
905
+ const client = new Client('http://localhost:3000')
906
+ .compose(redirectInterceptor)
907
+ .compose(retryInterceptor)
908
+
909
+ await client.request({ path: '/', method: 'GET' })
910
+ ```
911
+
912
+ #### Pre-built interceptors
913
+
914
+ ##### `redirect`
915
+
916
+ The `redirect` interceptor allows you to customize the way your dispatcher handles redirects.
917
+
918
+ It accepts the same arguments as the [`RedirectHandler` constructor](./RedirectHandler.md).
919
+
920
+ **Example - Basic Redirect Interceptor**
921
+
922
+ ```js
923
+ const { Client, interceptors } = require("undici");
924
+ const { redirect } = interceptors;
925
+
926
+ const client = new Client("http://example.com").compose(
927
+ redirect({ maxRedirections: 3, throwOnMaxRedirects: true })
928
+ );
929
+ client.request({ path: "/" })
930
+ ```
931
+
932
+ ##### `retry`
933
+
934
+ The `retry` interceptor allows you to customize the way your dispatcher handles retries.
935
+
936
+ It accepts the same arguments as the [`RetryHandler` constructor](./RetryHandler.md).
937
+
938
+ **Example - Basic Redirect Interceptor**
939
+
940
+ ```js
941
+ const { Client, interceptors } = require("undici");
942
+ const { retry } = interceptors;
943
+
944
+ const client = new Client("http://example.com").compose(
945
+ retry({
946
+ maxRetries: 3,
947
+ minTimeout: 1000,
948
+ maxTimeout: 10000,
949
+ timeoutFactor: 2,
950
+ retryAfter: true,
951
+ })
952
+ );
953
+ ```
954
+
820
955
  ## Instance Events
821
956
 
822
957
  ### Event: `'connect'`
@@ -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
 
package/index.js CHANGED
@@ -36,6 +36,10 @@ module.exports.RetryHandler = RetryHandler
36
36
  module.exports.DecoratorHandler = DecoratorHandler
37
37
  module.exports.RedirectHandler = RedirectHandler
38
38
  module.exports.createRedirectInterceptor = createRedirectInterceptor
39
+ module.exports.interceptors = {
40
+ redirect: require('./lib/interceptor/redirect'),
41
+ retry: require('./lib/interceptor/retry')
42
+ }
39
43
 
40
44
  module.exports.buildConnector = buildConnector
41
45
  module.exports.errors = errors
@@ -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,
@@ -59,6 +59,7 @@ const {
59
59
  } = require('../core/symbols.js')
60
60
  const connectH1 = require('./client-h1.js')
61
61
  const connectH2 = require('./client-h2.js')
62
+ let deprecatedInterceptorWarned = false
62
63
 
63
64
  const kClosedResolve = Symbol('kClosedResolve')
64
65
 
@@ -207,9 +208,18 @@ class Client extends DispatcherBase {
207
208
  })
208
209
  }
209
210
 
210
- this[kInterceptors] = interceptors?.Client && Array.isArray(interceptors.Client)
211
- ? interceptors.Client
212
- : [createRedirectInterceptor({ maxRedirections })]
211
+ if (interceptors?.Client && Array.isArray(interceptors.Client)) {
212
+ this[kInterceptors] = interceptors.Client
213
+ if (!deprecatedInterceptorWarned) {
214
+ deprecatedInterceptorWarned = true
215
+ process.emitWarning('Client.Options#interceptor is deprecated. Use Dispatcher#compose instead.', {
216
+ code: 'UNDICI-CLIENT-INTERCEPTOR-DEPRECATED'
217
+ })
218
+ }
219
+ } else {
220
+ this[kInterceptors] = [createRedirectInterceptor({ maxRedirections })]
221
+ }
222
+
213
223
  this[kUrl] = util.parseOrigin(url)
214
224
  this[kConnector] = connect
215
225
  this[kPipelining] = pipelining != null ? pipelining : 1
@@ -1,5 +1,4 @@
1
1
  'use strict'
2
-
3
2
  const EventEmitter = require('node:events')
4
3
 
5
4
  class Dispatcher extends EventEmitter {
@@ -14,6 +13,53 @@ class Dispatcher extends EventEmitter {
14
13
  destroy () {
15
14
  throw new Error('not implemented')
16
15
  }
16
+
17
+ compose (...args) {
18
+ // So we handle [interceptor1, interceptor2] or interceptor1, interceptor2, ...
19
+ const interceptors = Array.isArray(args[0]) ? args[0] : args
20
+ let dispatch = this.dispatch.bind(this)
21
+
22
+ for (const interceptor of interceptors) {
23
+ if (interceptor == null) {
24
+ continue
25
+ }
26
+
27
+ if (typeof interceptor !== 'function') {
28
+ throw new TypeError(`invalid interceptor, expected function received ${typeof interceptor}`)
29
+ }
30
+
31
+ dispatch = interceptor(dispatch)
32
+
33
+ if (dispatch == null || typeof dispatch !== 'function' || dispatch.length !== 2) {
34
+ throw new TypeError('invalid interceptor')
35
+ }
36
+ }
37
+
38
+ return new ComposedDispatcher(this, dispatch)
39
+ }
40
+ }
41
+
42
+ class ComposedDispatcher extends Dispatcher {
43
+ #dispatcher = null
44
+ #dispatch = null
45
+
46
+ constructor (dispatcher, dispatch) {
47
+ super()
48
+ this.#dispatcher = dispatcher
49
+ this.#dispatch = dispatch
50
+ }
51
+
52
+ dispatch (...args) {
53
+ this.#dispatch(...args)
54
+ }
55
+
56
+ close (...args) {
57
+ return this.#dispatcher.close(...args)
58
+ }
59
+
60
+ destroy (...args) {
61
+ return this.#dispatcher.destroy(...args)
62
+ }
17
63
  }
18
64
 
19
65
  module.exports = Dispatcher
@@ -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
  })
@@ -0,0 +1,24 @@
1
+ 'use strict'
2
+ const RedirectHandler = require('../handler/redirect-handler')
3
+
4
+ module.exports = opts => {
5
+ const globalMaxRedirections = opts?.maxRedirections
6
+ return dispatch => {
7
+ return function redirectInterceptor (opts, handler) {
8
+ const { maxRedirections = globalMaxRedirections, ...baseOpts } = opts
9
+
10
+ if (!maxRedirections) {
11
+ return dispatch(opts, handler)
12
+ }
13
+
14
+ const redirectHandler = new RedirectHandler(
15
+ dispatch,
16
+ maxRedirections,
17
+ opts,
18
+ handler
19
+ )
20
+
21
+ return dispatch(baseOpts, redirectHandler)
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,19 @@
1
+ 'use strict'
2
+ const RetryHandler = require('../handler/retry-handler')
3
+
4
+ module.exports = globalOpts => {
5
+ return dispatch => {
6
+ return function retryInterceptor (opts, handler) {
7
+ return dispatch(
8
+ opts,
9
+ new RetryHandler(
10
+ { ...opts, retryOptions: { ...globalOpts, ...opts.retryOptions } },
11
+ {
12
+ handler,
13
+ dispatch
14
+ }
15
+ )
16
+ )
17
+ }
18
+ }
19
+ }
@@ -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
@@ -427,16 +444,13 @@ function parseMultipartFormDataName (input, position) {
427
444
  * @param {{ position: number }} position
428
445
  */
429
446
  function collectASequenceOfBytes (condition, input, position) {
430
- const result = []
431
- let index = 0
432
-
433
- while (position.position < input.length && condition(input[position.position])) {
434
- result[index++] = input[position.position]
447
+ let start = position.position
435
448
 
436
- position.position++
449
+ while (start < input.length && condition(input[start])) {
450
+ ++start
437
451
  }
438
452
 
439
- return Buffer.from(result, result.length)
453
+ return input.subarray(position.position, (position.position = start))
440
454
  }
441
455
 
442
456
  /**
@@ -6,6 +6,7 @@ const { kEnumerableProperty } = require('../../core/util')
6
6
  const { File: UndiciFile, FileLike, isFileLike } = require('./file')
7
7
  const { webidl } = require('./webidl')
8
8
  const { File: NativeFile } = require('node:buffer')
9
+ const nodeUtil = require('node:util')
9
10
 
10
11
  /** @type {globalThis['File']} */
11
12
  const File = NativeFile ?? UndiciFile
@@ -154,6 +155,15 @@ class FormData {
154
155
  this[kState].push(entry)
155
156
  }
156
157
  }
158
+
159
+ [nodeUtil.inspect.custom] (depth, options) {
160
+ let output = 'FormData:\n'
161
+ this[kState].forEach(entry => {
162
+ output += `${entry.name}: ${entry.value}\n`
163
+ })
164
+
165
+ return output
166
+ }
157
167
  }
158
168
 
159
169
  iteratorMixin('FormData', FormData, kState, 'name', 'value')
@@ -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, {
@@ -6,6 +6,7 @@ const { extractBody, mixinBody, cloneBody } = require('./body')
6
6
  const { Headers, fill: fillHeaders, HeadersList } = require('./headers')
7
7
  const { FinalizationRegistry } = require('./dispatcher-weakref')()
8
8
  const util = require('../../core/util')
9
+ const nodeUtil = require('node:util')
9
10
  const {
10
11
  isValidHTTPToken,
11
12
  sameOrigin,
@@ -771,6 +772,32 @@ class Request {
771
772
  // 4. Return clonedRequestObject.
772
773
  return fromInnerRequest(clonedRequest, ac.signal, this[kHeaders][kGuard], this[kRealm])
773
774
  }
775
+
776
+ [nodeUtil.inspect.custom] (depth, options) {
777
+ if (options.depth === null) {
778
+ options.depth = 2
779
+ }
780
+
781
+ const properties = {
782
+ method: this.method,
783
+ url: this.url,
784
+ headers: this.headers,
785
+ destination: this.destination,
786
+ referrer: this.referrer,
787
+ referrerPolicy: this.referrerPolicy,
788
+ mode: this.mode,
789
+ credentials: this.credentials,
790
+ cache: this.cache,
791
+ redirect: this.redirect,
792
+ integrity: this.integrity,
793
+ keepalive: this.keepalive,
794
+ isReloadNavigation: this.isReloadNavigation,
795
+ isHistoryNavigation: this.isHistoryNavigation,
796
+ signal: this.signal
797
+ }
798
+
799
+ return nodeUtil.formatWithOptions(options, { ...properties })
800
+ }
774
801
  }
775
802
 
776
803
  mixinBody(Request)
@@ -3,6 +3,7 @@
3
3
  const { Headers, HeadersList, fill } = require('./headers')
4
4
  const { extractBody, cloneBody, mixinBody } = require('./body')
5
5
  const util = require('../../core/util')
6
+ const nodeUtil = require('node:util')
6
7
  const { kEnumerableProperty } = util
7
8
  const {
8
9
  isValidReasonPhrase,
@@ -252,6 +253,26 @@ class Response {
252
253
  // clonedResponse, this’s headers’s guard, and this’s relevant Realm.
253
254
  return fromInnerResponse(clonedResponse, this[kHeaders][kGuard], this[kRealm])
254
255
  }
256
+
257
+ [nodeUtil.inspect.custom] (depth, options) {
258
+ if (options.depth === null) {
259
+ options.depth = 2
260
+ }
261
+
262
+ const properties = {
263
+ status: this.status,
264
+ statusText: this.statusText,
265
+ headers: this.headers,
266
+ body: this.body,
267
+ bodyUsed: this.bodyUsed,
268
+ ok: this.ok,
269
+ redirected: this.redirected,
270
+ type: this.type,
271
+ url: this.url
272
+ }
273
+
274
+ return nodeUtil.formatWithOptions(options, `Response ${nodeUtil.inspect(properties)}`)
275
+ }
255
276
  }
256
277
 
257
278
  mixinBody(Response)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "undici",
3
- "version": "6.7.1",
3
+ "version": "6.9.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",
package/types/fetch.d.ts CHANGED
@@ -122,7 +122,7 @@ export interface RequestInit {
122
122
  method?: string
123
123
  keepalive?: boolean
124
124
  headers?: HeadersInit
125
- body?: BodyInit
125
+ body?: BodyInit | null
126
126
  redirect?: RequestRedirect
127
127
  integrity?: string
128
128
  signal?: AbortSignal | null