undici 6.21.0 → 6.21.2

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.
@@ -986,6 +986,57 @@ client.dispatch(
986
986
  );
987
987
  ```
988
988
 
989
+ ##### `dns`
990
+
991
+ The `dns` interceptor enables you to cache DNS lookups for a given duration, per origin.
992
+
993
+ >It is well suited for scenarios where you want to cache DNS lookups to avoid the overhead of resolving the same domain multiple times
994
+
995
+ **Options**
996
+ - `maxTTL` - The maximum time-to-live (in milliseconds) of the DNS cache. It should be a positive integer. Default: `10000`.
997
+ - Set `0` to disable TTL.
998
+ - `maxItems` - The maximum number of items to cache. It should be a positive integer. Default: `Infinity`.
999
+ - `dualStack` - Whether to resolve both IPv4 and IPv6 addresses. Default: `true`.
1000
+ - It will also attempt a happy-eyeballs-like approach to connect to the available addresses in case of a connection failure.
1001
+ - `affinity` - Whether to use IPv4 or IPv6 addresses. Default: `4`.
1002
+ - It can be either `'4` or `6`.
1003
+ - It will only take effect if `dualStack` is `false`.
1004
+ - `lookup: (hostname: string, options: LookupOptions, callback: (err: NodeJS.ErrnoException | null, addresses: DNSInterceptorRecord[]) => void) => void` - Custom lookup function. Default: `dns.lookup`.
1005
+ - For more info see [dns.lookup](https://nodejs.org/api/dns.html#dns_dns_lookup_hostname_options_callback).
1006
+ - `pick: (origin: URL, records: DNSInterceptorRecords, affinity: 4 | 6) => DNSInterceptorRecord` - Custom pick function. Default: `RoundRobin`.
1007
+ - The function should return a single record from the records array.
1008
+ - By default a simplified version of Round Robin is used.
1009
+ - The `records` property can be mutated to store the state of the balancing algorithm.
1010
+
1011
+ > The `Dispatcher#options` also gets extended with the options `dns.affinity`, `dns.dualStack`, `dns.lookup` and `dns.pick` which can be used to configure the interceptor at a request-per-request basis.
1012
+
1013
+
1014
+ **DNSInterceptorRecord**
1015
+ It represents a DNS record.
1016
+ - `family` - (`number`) The IP family of the address. It can be either `4` or `6`.
1017
+ - `address` - (`string`) The IP address.
1018
+
1019
+ **DNSInterceptorOriginRecords**
1020
+ It represents a map of DNS IP addresses records for a single origin.
1021
+ - `4.ips` - (`DNSInterceptorRecord[] | null`) The IPv4 addresses.
1022
+ - `6.ips` - (`DNSInterceptorRecord[] | null`) The IPv6 addresses.
1023
+
1024
+ **Example - Basic DNS Interceptor**
1025
+
1026
+ ```js
1027
+ const { Client, interceptors } = require("undici");
1028
+ const { dns } = interceptors;
1029
+
1030
+ const client = new Agent().compose([
1031
+ dns({ ...opts })
1032
+ ])
1033
+
1034
+ const response = await client.request({
1035
+ origin: `http://localhost:3030`,
1036
+ ...requestOpts
1037
+ })
1038
+ ```
1039
+
989
1040
  ##### `Response Error Interceptor`
990
1041
 
991
1042
  **Introduction**
package/index.js CHANGED
@@ -41,7 +41,8 @@ module.exports.createRedirectInterceptor = createRedirectInterceptor
41
41
  module.exports.interceptors = {
42
42
  redirect: require('./lib/interceptor/redirect'),
43
43
  retry: require('./lib/interceptor/retry'),
44
- dump: require('./lib/interceptor/dump')
44
+ dump: require('./lib/interceptor/dump'),
45
+ dns: require('./lib/interceptor/dns')
45
46
  }
46
47
 
47
48
  module.exports.buildConnector = buildConnector
@@ -73,7 +73,7 @@ class RequestHandler extends AsyncResource {
73
73
  this.removeAbortListener = util.addAbortListener(this.signal, () => {
74
74
  this.reason = this.signal.reason ?? new RequestAbortedError()
75
75
  if (this.res) {
76
- util.destroy(this.res, this.reason)
76
+ util.destroy(this.res.on('error', util.nop), this.reason)
77
77
  } else if (this.abort) {
78
78
  this.abort(this.reason)
79
79
  }
@@ -31,6 +31,8 @@ const {
31
31
 
32
32
  const kOpenStreams = Symbol('open streams')
33
33
 
34
+ let extractBody
35
+
34
36
  // Experimental
35
37
  let h2ExperimentalWarned = false
36
38
 
@@ -240,11 +242,12 @@ function onHTTP2GoAway (code) {
240
242
  util.destroy(this[kSocket], err)
241
243
 
242
244
  // Fail head of pipeline.
243
- const request = client[kQueue][client[kRunningIdx]]
244
- client[kQueue][client[kRunningIdx]++] = null
245
- util.errorRequest(client, request, err)
246
-
247
- client[kPendingIdx] = client[kRunningIdx]
245
+ if (client[kRunningIdx] < client[kQueue].length) {
246
+ const request = client[kQueue][client[kRunningIdx]]
247
+ client[kQueue][client[kRunningIdx]++] = null
248
+ util.errorRequest(client, request, err)
249
+ client[kPendingIdx] = client[kRunningIdx]
250
+ }
248
251
 
249
252
  assert(client[kRunning] === 0)
250
253
 
@@ -260,7 +263,8 @@ function shouldSendContentLength (method) {
260
263
 
261
264
  function writeH2 (client, request) {
262
265
  const session = client[kHTTP2Session]
263
- const { body, method, path, host, upgrade, expectContinue, signal, headers: reqHeaders } = request
266
+ const { method, path, host, upgrade, expectContinue, signal, headers: reqHeaders } = request
267
+ let { body } = request
264
268
 
265
269
  if (upgrade) {
266
270
  util.errorRequest(client, request, new Error('Upgrade not supported for H2'))
@@ -381,6 +385,16 @@ function writeH2 (client, request) {
381
385
 
382
386
  let contentLength = util.bodyLength(body)
383
387
 
388
+ if (util.isFormDataLike(body)) {
389
+ extractBody ??= require('../web/fetch/body.js').extractBody
390
+
391
+ const [bodyStream, contentType] = extractBody(body)
392
+ headers['content-type'] = contentType
393
+
394
+ body = bodyStream.stream
395
+ contentLength = bodyStream.length
396
+ }
397
+
384
398
  if (contentLength == null) {
385
399
  contentLength = request.contentLength
386
400
  }
@@ -73,6 +73,20 @@ class Pool extends PoolBase {
73
73
  ? { ...options.interceptors }
74
74
  : undefined
75
75
  this[kFactory] = factory
76
+
77
+ this.on('connectionError', (origin, targets, error) => {
78
+ // If a connection error occurs, we remove the client from the pool,
79
+ // and emit a connectionError event. They will not be re-used.
80
+ // Fixes https://github.com/nodejs/undici/issues/3895
81
+ for (const target of targets) {
82
+ // Do not use kRemoveClient here, as it will close the client,
83
+ // but the client cannot be closed in this state.
84
+ const idx = this[kClients].indexOf(target)
85
+ if (idx !== -1) {
86
+ this[kClients].splice(idx, 1)
87
+ }
88
+ }
89
+ })
76
90
  }
77
91
 
78
92
  [kGetDispatcher] () {
@@ -0,0 +1,375 @@
1
+ 'use strict'
2
+ const { isIP } = require('node:net')
3
+ const { lookup } = require('node:dns')
4
+ const DecoratorHandler = require('../handler/decorator-handler')
5
+ const { InvalidArgumentError, InformationalError } = require('../core/errors')
6
+ const maxInt = Math.pow(2, 31) - 1
7
+
8
+ class DNSInstance {
9
+ #maxTTL = 0
10
+ #maxItems = 0
11
+ #records = new Map()
12
+ dualStack = true
13
+ affinity = null
14
+ lookup = null
15
+ pick = null
16
+
17
+ constructor (opts) {
18
+ this.#maxTTL = opts.maxTTL
19
+ this.#maxItems = opts.maxItems
20
+ this.dualStack = opts.dualStack
21
+ this.affinity = opts.affinity
22
+ this.lookup = opts.lookup ?? this.#defaultLookup
23
+ this.pick = opts.pick ?? this.#defaultPick
24
+ }
25
+
26
+ get full () {
27
+ return this.#records.size === this.#maxItems
28
+ }
29
+
30
+ runLookup (origin, opts, cb) {
31
+ const ips = this.#records.get(origin.hostname)
32
+
33
+ // If full, we just return the origin
34
+ if (ips == null && this.full) {
35
+ cb(null, origin.origin)
36
+ return
37
+ }
38
+
39
+ const newOpts = {
40
+ affinity: this.affinity,
41
+ dualStack: this.dualStack,
42
+ lookup: this.lookup,
43
+ pick: this.pick,
44
+ ...opts.dns,
45
+ maxTTL: this.#maxTTL,
46
+ maxItems: this.#maxItems
47
+ }
48
+
49
+ // If no IPs we lookup
50
+ if (ips == null) {
51
+ this.lookup(origin, newOpts, (err, addresses) => {
52
+ if (err || addresses == null || addresses.length === 0) {
53
+ cb(err ?? new InformationalError('No DNS entries found'))
54
+ return
55
+ }
56
+
57
+ this.setRecords(origin, addresses)
58
+ const records = this.#records.get(origin.hostname)
59
+
60
+ const ip = this.pick(
61
+ origin,
62
+ records,
63
+ newOpts.affinity
64
+ )
65
+
66
+ let port
67
+ if (typeof ip.port === 'number') {
68
+ port = `:${ip.port}`
69
+ } else if (origin.port !== '') {
70
+ port = `:${origin.port}`
71
+ } else {
72
+ port = ''
73
+ }
74
+
75
+ cb(
76
+ null,
77
+ `${origin.protocol}//${
78
+ ip.family === 6 ? `[${ip.address}]` : ip.address
79
+ }${port}`
80
+ )
81
+ })
82
+ } else {
83
+ // If there's IPs we pick
84
+ const ip = this.pick(
85
+ origin,
86
+ ips,
87
+ newOpts.affinity
88
+ )
89
+
90
+ // If no IPs we lookup - deleting old records
91
+ if (ip == null) {
92
+ this.#records.delete(origin.hostname)
93
+ this.runLookup(origin, opts, cb)
94
+ return
95
+ }
96
+
97
+ let port
98
+ if (typeof ip.port === 'number') {
99
+ port = `:${ip.port}`
100
+ } else if (origin.port !== '') {
101
+ port = `:${origin.port}`
102
+ } else {
103
+ port = ''
104
+ }
105
+
106
+ cb(
107
+ null,
108
+ `${origin.protocol}//${
109
+ ip.family === 6 ? `[${ip.address}]` : ip.address
110
+ }${port}`
111
+ )
112
+ }
113
+ }
114
+
115
+ #defaultLookup (origin, opts, cb) {
116
+ lookup(
117
+ origin.hostname,
118
+ {
119
+ all: true,
120
+ family: this.dualStack === false ? this.affinity : 0,
121
+ order: 'ipv4first'
122
+ },
123
+ (err, addresses) => {
124
+ if (err) {
125
+ return cb(err)
126
+ }
127
+
128
+ const results = new Map()
129
+
130
+ for (const addr of addresses) {
131
+ // On linux we found duplicates, we attempt to remove them with
132
+ // the latest record
133
+ results.set(`${addr.address}:${addr.family}`, addr)
134
+ }
135
+
136
+ cb(null, results.values())
137
+ }
138
+ )
139
+ }
140
+
141
+ #defaultPick (origin, hostnameRecords, affinity) {
142
+ let ip = null
143
+ const { records, offset } = hostnameRecords
144
+
145
+ let family
146
+ if (this.dualStack) {
147
+ if (affinity == null) {
148
+ // Balance between ip families
149
+ if (offset == null || offset === maxInt) {
150
+ hostnameRecords.offset = 0
151
+ affinity = 4
152
+ } else {
153
+ hostnameRecords.offset++
154
+ affinity = (hostnameRecords.offset & 1) === 1 ? 6 : 4
155
+ }
156
+ }
157
+
158
+ if (records[affinity] != null && records[affinity].ips.length > 0) {
159
+ family = records[affinity]
160
+ } else {
161
+ family = records[affinity === 4 ? 6 : 4]
162
+ }
163
+ } else {
164
+ family = records[affinity]
165
+ }
166
+
167
+ // If no IPs we return null
168
+ if (family == null || family.ips.length === 0) {
169
+ return ip
170
+ }
171
+
172
+ if (family.offset == null || family.offset === maxInt) {
173
+ family.offset = 0
174
+ } else {
175
+ family.offset++
176
+ }
177
+
178
+ const position = family.offset % family.ips.length
179
+ ip = family.ips[position] ?? null
180
+
181
+ if (ip == null) {
182
+ return ip
183
+ }
184
+
185
+ if (Date.now() - ip.timestamp > ip.ttl) { // record TTL is already in ms
186
+ // We delete expired records
187
+ // It is possible that they have different TTL, so we manage them individually
188
+ family.ips.splice(position, 1)
189
+ return this.pick(origin, hostnameRecords, affinity)
190
+ }
191
+
192
+ return ip
193
+ }
194
+
195
+ setRecords (origin, addresses) {
196
+ const timestamp = Date.now()
197
+ const records = { records: { 4: null, 6: null } }
198
+ for (const record of addresses) {
199
+ record.timestamp = timestamp
200
+ if (typeof record.ttl === 'number') {
201
+ // The record TTL is expected to be in ms
202
+ record.ttl = Math.min(record.ttl, this.#maxTTL)
203
+ } else {
204
+ record.ttl = this.#maxTTL
205
+ }
206
+
207
+ const familyRecords = records.records[record.family] ?? { ips: [] }
208
+
209
+ familyRecords.ips.push(record)
210
+ records.records[record.family] = familyRecords
211
+ }
212
+
213
+ this.#records.set(origin.hostname, records)
214
+ }
215
+
216
+ getHandler (meta, opts) {
217
+ return new DNSDispatchHandler(this, meta, opts)
218
+ }
219
+ }
220
+
221
+ class DNSDispatchHandler extends DecoratorHandler {
222
+ #state = null
223
+ #opts = null
224
+ #dispatch = null
225
+ #handler = null
226
+ #origin = null
227
+
228
+ constructor (state, { origin, handler, dispatch }, opts) {
229
+ super(handler)
230
+ this.#origin = origin
231
+ this.#handler = handler
232
+ this.#opts = { ...opts }
233
+ this.#state = state
234
+ this.#dispatch = dispatch
235
+ }
236
+
237
+ onError (err) {
238
+ switch (err.code) {
239
+ case 'ETIMEDOUT':
240
+ case 'ECONNREFUSED': {
241
+ if (this.#state.dualStack) {
242
+ // We delete the record and retry
243
+ this.#state.runLookup(this.#origin, this.#opts, (err, newOrigin) => {
244
+ if (err) {
245
+ return this.#handler.onError(err)
246
+ }
247
+
248
+ const dispatchOpts = {
249
+ ...this.#opts,
250
+ origin: newOrigin
251
+ }
252
+
253
+ this.#dispatch(dispatchOpts, this)
254
+ })
255
+
256
+ // if dual-stack disabled, we error out
257
+ return
258
+ }
259
+
260
+ this.#handler.onError(err)
261
+ return
262
+ }
263
+ case 'ENOTFOUND':
264
+ this.#state.deleteRecord(this.#origin)
265
+ // eslint-disable-next-line no-fallthrough
266
+ default:
267
+ this.#handler.onError(err)
268
+ break
269
+ }
270
+ }
271
+ }
272
+
273
+ module.exports = interceptorOpts => {
274
+ if (
275
+ interceptorOpts?.maxTTL != null &&
276
+ (typeof interceptorOpts?.maxTTL !== 'number' || interceptorOpts?.maxTTL < 0)
277
+ ) {
278
+ throw new InvalidArgumentError('Invalid maxTTL. Must be a positive number')
279
+ }
280
+
281
+ if (
282
+ interceptorOpts?.maxItems != null &&
283
+ (typeof interceptorOpts?.maxItems !== 'number' ||
284
+ interceptorOpts?.maxItems < 1)
285
+ ) {
286
+ throw new InvalidArgumentError(
287
+ 'Invalid maxItems. Must be a positive number and greater than zero'
288
+ )
289
+ }
290
+
291
+ if (
292
+ interceptorOpts?.affinity != null &&
293
+ interceptorOpts?.affinity !== 4 &&
294
+ interceptorOpts?.affinity !== 6
295
+ ) {
296
+ throw new InvalidArgumentError('Invalid affinity. Must be either 4 or 6')
297
+ }
298
+
299
+ if (
300
+ interceptorOpts?.dualStack != null &&
301
+ typeof interceptorOpts?.dualStack !== 'boolean'
302
+ ) {
303
+ throw new InvalidArgumentError('Invalid dualStack. Must be a boolean')
304
+ }
305
+
306
+ if (
307
+ interceptorOpts?.lookup != null &&
308
+ typeof interceptorOpts?.lookup !== 'function'
309
+ ) {
310
+ throw new InvalidArgumentError('Invalid lookup. Must be a function')
311
+ }
312
+
313
+ if (
314
+ interceptorOpts?.pick != null &&
315
+ typeof interceptorOpts?.pick !== 'function'
316
+ ) {
317
+ throw new InvalidArgumentError('Invalid pick. Must be a function')
318
+ }
319
+
320
+ const dualStack = interceptorOpts?.dualStack ?? true
321
+ let affinity
322
+ if (dualStack) {
323
+ affinity = interceptorOpts?.affinity ?? null
324
+ } else {
325
+ affinity = interceptorOpts?.affinity ?? 4
326
+ }
327
+
328
+ const opts = {
329
+ maxTTL: interceptorOpts?.maxTTL ?? 10e3, // Expressed in ms
330
+ lookup: interceptorOpts?.lookup ?? null,
331
+ pick: interceptorOpts?.pick ?? null,
332
+ dualStack,
333
+ affinity,
334
+ maxItems: interceptorOpts?.maxItems ?? Infinity
335
+ }
336
+
337
+ const instance = new DNSInstance(opts)
338
+
339
+ return dispatch => {
340
+ return function dnsInterceptor (origDispatchOpts, handler) {
341
+ const origin =
342
+ origDispatchOpts.origin.constructor === URL
343
+ ? origDispatchOpts.origin
344
+ : new URL(origDispatchOpts.origin)
345
+
346
+ if (isIP(origin.hostname) !== 0) {
347
+ return dispatch(origDispatchOpts, handler)
348
+ }
349
+
350
+ instance.runLookup(origin, origDispatchOpts, (err, newOrigin) => {
351
+ if (err) {
352
+ return handler.onError(err)
353
+ }
354
+
355
+ let dispatchOpts = null
356
+ dispatchOpts = {
357
+ ...origDispatchOpts,
358
+ servername: origin.hostname, // For SNI on TLS
359
+ origin: newOrigin,
360
+ headers: {
361
+ host: origin.hostname,
362
+ ...origDispatchOpts.headers
363
+ }
364
+ }
365
+
366
+ dispatch(
367
+ dispatchOpts,
368
+ instance.getHandler({ origin, dispatch, handler }, origDispatchOpts)
369
+ )
370
+ })
371
+
372
+ return true
373
+ }
374
+ }
375
+ }
@@ -20,6 +20,14 @@ const { isErrored, isDisturbed } = require('node:stream')
20
20
  const { isArrayBuffer } = require('node:util/types')
21
21
  const { serializeAMimeType } = require('./data-url')
22
22
  const { multipartFormDataParser } = require('./formdata-parser')
23
+ let random
24
+
25
+ try {
26
+ const crypto = require('node:crypto')
27
+ random = (max) => crypto.randomInt(0, max)
28
+ } catch {
29
+ random = (max) => Math.floor(Math.random(max))
30
+ }
23
31
 
24
32
  const textEncoder = new TextEncoder()
25
33
  function noop () {}
@@ -113,7 +121,7 @@ function extractBody (object, keepalive = false) {
113
121
  // Set source to a copy of the bytes held by object.
114
122
  source = new Uint8Array(object.buffer.slice(object.byteOffset, object.byteOffset + object.byteLength))
115
123
  } else if (util.isFormDataLike(object)) {
116
- const boundary = `----formdata-undici-0${`${Math.floor(Math.random() * 1e11)}`.padStart(11, '0')}`
124
+ const boundary = `----formdata-undici-0${`${random(1e11)}`.padStart(11, '0')}`
117
125
  const prefix = `--${boundary}\r\nContent-Disposition: form-data`
118
126
 
119
127
  /*! formdata-polyfill. MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "undici",
3
- "version": "6.21.0",
3
+ "version": "6.21.2",
4
4
  "description": "An HTTP/1.1 client, written from scratch for Node.js",
5
5
  "homepage": "https://undici.nodejs.org",
6
6
  "bugs": {
@@ -1,3 +1,5 @@
1
+ import { LookupOptions } from 'node:dns'
2
+
1
3
  import Dispatcher from "./dispatcher";
2
4
  import RetryHandler from "./retry-handler";
3
5
 
@@ -9,9 +11,22 @@ declare namespace Interceptors {
9
11
  export type RedirectInterceptorOpts = { maxRedirections?: number }
10
12
  export type ResponseErrorInterceptorOpts = { throwOnError: boolean }
11
13
 
14
+ // DNS interceptor
15
+ export type DNSInterceptorRecord = { address: string, ttl: number, family: 4 | 6 }
16
+ export type DNSInterceptorOriginRecords = { 4: { ips: DNSInterceptorRecord[] } | null, 6: { ips: DNSInterceptorRecord[] } | null }
17
+ export type DNSInterceptorOpts = {
18
+ maxTTL?: number
19
+ maxItems?: number
20
+ lookup?: (hostname: string, options: LookupOptions, callback: (err: NodeJS.ErrnoException | null, addresses: DNSInterceptorRecord[]) => void) => void
21
+ pick?: (origin: URL, records: DNSInterceptorOriginRecords, affinity: 4 | 6) => DNSInterceptorRecord
22
+ dualStack?: boolean
23
+ affinity?: 4 | 6
24
+ }
25
+
12
26
  export function createRedirectInterceptor(opts: RedirectInterceptorOpts): Dispatcher.DispatcherComposeInterceptor
13
27
  export function dump(opts?: DumpInterceptorOpts): Dispatcher.DispatcherComposeInterceptor
14
28
  export function retry(opts?: RetryInterceptorOpts): Dispatcher.DispatcherComposeInterceptor
15
29
  export function redirect(opts?: RedirectInterceptorOpts): Dispatcher.DispatcherComposeInterceptor
16
30
  export function responseError(opts?: ResponseErrorInterceptorOpts): Dispatcher.DispatcherComposeInterceptor
31
+ export function dns (opts?: DNSInterceptorOpts): Dispatcher.DispatcherComposeInterceptor
17
32
  }
@@ -32,7 +32,7 @@ declare namespace RetryHandler {
32
32
  };
33
33
  },
34
34
  callback: OnRetryCallback
35
- ) => number | null;
35
+ ) => void
36
36
 
37
37
  export interface RetryOptions {
38
38
  /**