undici 7.15.0 → 7.17.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.
Files changed (93) hide show
  1. package/README.md +48 -2
  2. package/docs/docs/api/Agent.md +1 -0
  3. package/docs/docs/api/Client.md +1 -0
  4. package/docs/docs/api/DiagnosticsChannel.md +57 -0
  5. package/docs/docs/api/Dispatcher.md +86 -0
  6. package/docs/docs/api/Errors.md +0 -1
  7. package/docs/docs/api/RoundRobinPool.md +145 -0
  8. package/docs/docs/api/WebSocket.md +21 -0
  9. package/docs/docs/best-practices/crawling.md +58 -0
  10. package/index-fetch.js +2 -2
  11. package/index.js +8 -9
  12. package/lib/api/api-request.js +22 -8
  13. package/lib/api/api-upgrade.js +2 -1
  14. package/lib/api/readable.js +7 -5
  15. package/lib/core/connect.js +4 -1
  16. package/lib/core/diagnostics.js +28 -1
  17. package/lib/core/errors.js +217 -13
  18. package/lib/core/request.js +5 -1
  19. package/lib/core/symbols.js +3 -0
  20. package/lib/core/util.js +61 -41
  21. package/lib/dispatcher/agent.js +19 -7
  22. package/lib/dispatcher/balanced-pool.js +10 -0
  23. package/lib/dispatcher/client-h1.js +18 -23
  24. package/lib/dispatcher/client-h2.js +166 -26
  25. package/lib/dispatcher/client.js +64 -59
  26. package/lib/dispatcher/dispatcher-base.js +20 -16
  27. package/lib/dispatcher/env-http-proxy-agent.js +12 -16
  28. package/lib/dispatcher/fixed-queue.js +15 -39
  29. package/lib/dispatcher/h2c-client.js +7 -78
  30. package/lib/dispatcher/pool-base.js +60 -43
  31. package/lib/dispatcher/pool.js +2 -2
  32. package/lib/dispatcher/proxy-agent.js +27 -11
  33. package/lib/dispatcher/round-robin-pool.js +137 -0
  34. package/lib/encoding/index.js +33 -0
  35. package/lib/global.js +19 -1
  36. package/lib/handler/cache-handler.js +84 -27
  37. package/lib/handler/deduplication-handler.js +216 -0
  38. package/lib/handler/retry-handler.js +0 -2
  39. package/lib/interceptor/cache.js +94 -15
  40. package/lib/interceptor/decompress.js +2 -1
  41. package/lib/interceptor/deduplicate.js +109 -0
  42. package/lib/interceptor/dns.js +55 -13
  43. package/lib/mock/mock-agent.js +4 -4
  44. package/lib/mock/mock-errors.js +10 -0
  45. package/lib/mock/mock-utils.js +13 -12
  46. package/lib/mock/snapshot-agent.js +11 -5
  47. package/lib/mock/snapshot-recorder.js +12 -4
  48. package/lib/mock/snapshot-utils.js +4 -4
  49. package/lib/util/cache.js +29 -1
  50. package/lib/util/date.js +534 -140
  51. package/lib/util/runtime-features.js +124 -0
  52. package/lib/web/cookies/index.js +1 -1
  53. package/lib/web/cookies/parse.js +1 -1
  54. package/lib/web/eventsource/eventsource-stream.js +2 -2
  55. package/lib/web/eventsource/eventsource.js +34 -29
  56. package/lib/web/eventsource/util.js +1 -9
  57. package/lib/web/fetch/body.js +45 -61
  58. package/lib/web/fetch/data-url.js +12 -160
  59. package/lib/web/fetch/formdata-parser.js +204 -127
  60. package/lib/web/fetch/index.js +21 -19
  61. package/lib/web/fetch/request.js +6 -0
  62. package/lib/web/fetch/response.js +4 -7
  63. package/lib/web/fetch/util.js +10 -79
  64. package/lib/web/infra/index.js +229 -0
  65. package/lib/web/subresource-integrity/subresource-integrity.js +6 -5
  66. package/lib/web/webidl/index.js +207 -44
  67. package/lib/web/websocket/connection.js +33 -22
  68. package/lib/web/websocket/events.js +1 -1
  69. package/lib/web/websocket/frame.js +9 -15
  70. package/lib/web/websocket/stream/websocketerror.js +22 -1
  71. package/lib/web/websocket/stream/websocketstream.js +17 -8
  72. package/lib/web/websocket/util.js +2 -1
  73. package/lib/web/websocket/websocket.js +32 -42
  74. package/package.json +9 -7
  75. package/types/agent.d.ts +2 -1
  76. package/types/api.d.ts +2 -2
  77. package/types/balanced-pool.d.ts +2 -1
  78. package/types/cache-interceptor.d.ts +1 -0
  79. package/types/client.d.ts +1 -1
  80. package/types/connector.d.ts +2 -2
  81. package/types/diagnostics-channel.d.ts +2 -2
  82. package/types/dispatcher.d.ts +12 -12
  83. package/types/errors.d.ts +5 -15
  84. package/types/fetch.d.ts +4 -4
  85. package/types/formdata.d.ts +1 -1
  86. package/types/h2c-client.d.ts +1 -1
  87. package/types/index.d.ts +9 -1
  88. package/types/interceptors.d.ts +36 -2
  89. package/types/pool.d.ts +1 -1
  90. package/types/readable.d.ts +2 -2
  91. package/types/round-robin-pool.d.ts +41 -0
  92. package/types/webidl.d.ts +82 -21
  93. package/types/websocket.d.ts +9 -9
@@ -0,0 +1,216 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * @typedef {import('../../types/dispatcher.d.ts').default.DispatchHandler} DispatchHandler
5
+ */
6
+
7
+ /**
8
+ * Handler that buffers response data and notifies multiple waiting handlers.
9
+ * Used for request deduplication.
10
+ *
11
+ * @implements {DispatchHandler}
12
+ */
13
+ class DeduplicationHandler {
14
+ /**
15
+ * @type {DispatchHandler}
16
+ */
17
+ #primaryHandler
18
+
19
+ /**
20
+ * @type {DispatchHandler[]}
21
+ */
22
+ #waitingHandlers = []
23
+
24
+ /**
25
+ * @type {Buffer[]}
26
+ */
27
+ #chunks = []
28
+
29
+ /**
30
+ * @type {number}
31
+ */
32
+ #statusCode = 0
33
+
34
+ /**
35
+ * @type {Record<string, string | string[]>}
36
+ */
37
+ #headers = {}
38
+
39
+ /**
40
+ * @type {string}
41
+ */
42
+ #statusMessage = ''
43
+
44
+ /**
45
+ * @type {boolean}
46
+ */
47
+ #aborted = false
48
+
49
+ /**
50
+ * @type {import('../../types/dispatcher.d.ts').default.DispatchController | null}
51
+ */
52
+ #controller = null
53
+
54
+ /**
55
+ * @type {(() => void) | null}
56
+ */
57
+ #onComplete = null
58
+
59
+ /**
60
+ * @param {DispatchHandler} primaryHandler The primary handler
61
+ * @param {() => void} onComplete Callback when request completes
62
+ */
63
+ constructor (primaryHandler, onComplete) {
64
+ this.#primaryHandler = primaryHandler
65
+ this.#onComplete = onComplete
66
+ }
67
+
68
+ /**
69
+ * Add a waiting handler that will receive the buffered response
70
+ * @param {DispatchHandler} handler
71
+ */
72
+ addWaitingHandler (handler) {
73
+ this.#waitingHandlers.push(handler)
74
+ }
75
+
76
+ /**
77
+ * @param {() => void} abort
78
+ * @param {any} context
79
+ */
80
+ onRequestStart (controller, context) {
81
+ this.#controller = controller
82
+ this.#primaryHandler.onRequestStart?.(controller, context)
83
+ }
84
+
85
+ /**
86
+ * @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller
87
+ * @param {number} statusCode
88
+ * @param {import('../../types/header.d.ts').IncomingHttpHeaders} headers
89
+ * @param {Socket} socket
90
+ */
91
+ onRequestUpgrade (controller, statusCode, headers, socket) {
92
+ this.#primaryHandler.onRequestUpgrade?.(controller, statusCode, headers, socket)
93
+ }
94
+
95
+ /**
96
+ * @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller
97
+ * @param {number} statusCode
98
+ * @param {Record<string, string | string[]>} headers
99
+ * @param {string} statusMessage
100
+ */
101
+ onResponseStart (controller, statusCode, headers, statusMessage) {
102
+ this.#statusCode = statusCode
103
+ this.#headers = headers
104
+ this.#statusMessage = statusMessage
105
+ this.#primaryHandler.onResponseStart?.(controller, statusCode, headers, statusMessage)
106
+ }
107
+
108
+ /**
109
+ * @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller
110
+ * @param {Buffer} chunk
111
+ */
112
+ onResponseData (controller, chunk) {
113
+ // Buffer the chunk for waiting handlers
114
+ this.#chunks.push(Buffer.from(chunk))
115
+ this.#primaryHandler.onResponseData?.(controller, chunk)
116
+ }
117
+
118
+ /**
119
+ * @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller
120
+ * @param {object} trailers
121
+ */
122
+ onResponseEnd (controller, trailers) {
123
+ this.#primaryHandler.onResponseEnd?.(controller, trailers)
124
+ this.#notifyWaitingHandlers()
125
+ this.#onComplete?.()
126
+ }
127
+
128
+ /**
129
+ * @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller
130
+ * @param {Error} err
131
+ */
132
+ onResponseError (controller, err) {
133
+ this.#aborted = true
134
+ this.#primaryHandler.onResponseError?.(controller, err)
135
+ this.#notifyWaitingHandlersError(err)
136
+ this.#onComplete?.()
137
+ }
138
+
139
+ /**
140
+ * Notify all waiting handlers with the buffered response
141
+ */
142
+ #notifyWaitingHandlers () {
143
+ const body = Buffer.concat(this.#chunks)
144
+
145
+ for (const handler of this.#waitingHandlers) {
146
+ // Create a simple controller for each waiting handler
147
+ const waitingController = {
148
+ resume () {},
149
+ pause () {},
150
+ get paused () { return false },
151
+ get aborted () { return false },
152
+ get reason () { return null },
153
+ abort () {}
154
+ }
155
+
156
+ try {
157
+ handler.onRequestStart?.(waitingController, null)
158
+
159
+ if (waitingController.aborted) {
160
+ continue
161
+ }
162
+
163
+ handler.onResponseStart?.(
164
+ waitingController,
165
+ this.#statusCode,
166
+ this.#headers,
167
+ this.#statusMessage
168
+ )
169
+
170
+ if (waitingController.aborted) {
171
+ continue
172
+ }
173
+
174
+ if (body.length > 0) {
175
+ handler.onResponseData?.(waitingController, body)
176
+ }
177
+
178
+ handler.onResponseEnd?.(waitingController, {})
179
+ } catch {
180
+ // Ignore errors from waiting handlers
181
+ }
182
+ }
183
+
184
+ this.#waitingHandlers = []
185
+ this.#chunks = []
186
+ }
187
+
188
+ /**
189
+ * Notify all waiting handlers of an error
190
+ * @param {Error} err
191
+ */
192
+ #notifyWaitingHandlersError (err) {
193
+ for (const handler of this.#waitingHandlers) {
194
+ const waitingController = {
195
+ resume () {},
196
+ pause () {},
197
+ get paused () { return false },
198
+ get aborted () { return true },
199
+ get reason () { return err },
200
+ abort () {}
201
+ }
202
+
203
+ try {
204
+ handler.onRequestStart?.(waitingController, null)
205
+ handler.onResponseError?.(waitingController, err)
206
+ } catch {
207
+ // Ignore errors from waiting handlers
208
+ }
209
+ }
210
+
211
+ this.#waitingHandlers = []
212
+ this.#chunks = []
213
+ }
214
+ }
215
+
216
+ module.exports = DeduplicationHandler
@@ -92,8 +92,6 @@ class RetryHandler {
92
92
 
93
93
  function shouldRetry (passedErr) {
94
94
  if (passedErr) {
95
- this.headersSent = true
96
-
97
95
  this.headersSent = true
98
96
  this.handler.onResponseStart?.(controller, statusCode, headers, statusMessage)
99
97
  controller.resume()
@@ -9,6 +9,8 @@ const CacheRevalidationHandler = require('../handler/cache-revalidation-handler'
9
9
  const { assertCacheStore, assertCacheMethods, makeCacheKey, normalizeHeaders, parseCacheControlHeader } = require('../util/cache.js')
10
10
  const { AbortError } = require('../core/errors.js')
11
11
 
12
+ const nop = () => {}
13
+
12
14
  /**
13
15
  * @typedef {(options: import('../../types/dispatcher.d.ts').default.DispatchOptions, handler: import('../../types/dispatcher.d.ts').default.DispatchHandler) => void} DispatchFn
14
16
  */
@@ -16,19 +18,34 @@ const { AbortError } = require('../core/errors.js')
16
18
  /**
17
19
  * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result
18
20
  * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives | undefined} cacheControlDirectives
21
+ * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} opts
19
22
  * @returns {boolean}
20
23
  */
21
- function needsRevalidation (result, cacheControlDirectives) {
24
+ function needsRevalidation (result, cacheControlDirectives, { headers = {} }) {
25
+ // Always revalidate requests with the no-cache request directive.
22
26
  if (cacheControlDirectives?.['no-cache']) {
23
- // Always revalidate requests with the no-cache request directive
24
27
  return true
25
28
  }
26
29
 
30
+ // Always revalidate requests with unqualified no-cache response directive.
27
31
  if (result.cacheControlDirectives?.['no-cache'] && !Array.isArray(result.cacheControlDirectives['no-cache'])) {
28
- // Always revalidate requests with unqualified no-cache response directive
29
32
  return true
30
33
  }
31
34
 
35
+ // Always revalidate requests with conditional headers.
36
+ if (headers['if-modified-since'] || headers['if-none-match']) {
37
+ return true
38
+ }
39
+
40
+ return false
41
+ }
42
+
43
+ /**
44
+ * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result
45
+ * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives | undefined} cacheControlDirectives
46
+ * @returns {boolean}
47
+ */
48
+ function isStale (result, cacheControlDirectives) {
32
49
  const now = Date.now()
33
50
  if (now > result.staleAt) {
34
51
  // Response is stale
@@ -56,6 +73,22 @@ function needsRevalidation (result, cacheControlDirectives) {
56
73
  return false
57
74
  }
58
75
 
76
+ /**
77
+ * Check if we're within the stale-while-revalidate window for a stale response
78
+ * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result
79
+ * @returns {boolean}
80
+ */
81
+ function withinStaleWhileRevalidateWindow (result) {
82
+ const staleWhileRevalidate = result.cacheControlDirectives?.['stale-while-revalidate']
83
+ if (!staleWhileRevalidate) {
84
+ return false
85
+ }
86
+
87
+ const now = Date.now()
88
+ const staleWhileRevalidateExpiry = result.staleAt + (staleWhileRevalidate * 1000)
89
+ return now <= staleWhileRevalidateExpiry
90
+ }
91
+
59
92
  /**
60
93
  * @param {DispatchFn} dispatch
61
94
  * @param {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions} globalOpts
@@ -86,7 +119,7 @@ function handleUncachedResponse (
86
119
  }
87
120
 
88
121
  if (typeof handler.onHeaders === 'function') {
89
- handler.onHeaders(504, [], () => {}, 'Gateway Timeout')
122
+ handler.onHeaders(504, [], nop, 'Gateway Timeout')
90
123
  if (aborted) {
91
124
  return
92
125
  }
@@ -223,14 +256,62 @@ function handleResult (
223
256
  return dispatch(opts, handler)
224
257
  }
225
258
 
259
+ const stale = isStale(result, reqCacheControl)
260
+ const revalidate = needsRevalidation(result, reqCacheControl, opts)
261
+
226
262
  // Check if the response is stale
227
- if (needsRevalidation(result, reqCacheControl)) {
263
+ if (stale || revalidate) {
228
264
  if (util.isStream(opts.body) && util.bodyLength(opts.body) !== 0) {
229
265
  // If body is a stream we can't revalidate...
230
266
  // TODO (fix): This could be less strict...
231
267
  return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler))
232
268
  }
233
269
 
270
+ // RFC 5861: If we're within stale-while-revalidate window, serve stale immediately
271
+ // and revalidate in background, unless immediate revalidation is necessary
272
+ if (!revalidate && withinStaleWhileRevalidateWindow(result)) {
273
+ // Serve stale response immediately
274
+ sendCachedValue(handler, opts, result, age, null, true)
275
+
276
+ // Start background revalidation (fire-and-forget)
277
+ queueMicrotask(() => {
278
+ let headers = {
279
+ ...opts.headers,
280
+ 'if-modified-since': new Date(result.cachedAt).toUTCString()
281
+ }
282
+
283
+ if (result.etag) {
284
+ headers['if-none-match'] = result.etag
285
+ }
286
+
287
+ if (result.vary) {
288
+ headers = {
289
+ ...headers,
290
+ ...result.vary
291
+ }
292
+ }
293
+
294
+ // Background revalidation - update cache if we get new data
295
+ dispatch(
296
+ {
297
+ ...opts,
298
+ headers
299
+ },
300
+ new CacheHandler(globalOpts, cacheKey, {
301
+ // Silent handler that just updates the cache
302
+ onRequestStart () {},
303
+ onRequestUpgrade () {},
304
+ onResponseStart () {},
305
+ onResponseData () {},
306
+ onResponseEnd () {},
307
+ onResponseError () {}
308
+ })
309
+ )
310
+ })
311
+
312
+ return true
313
+ }
314
+
234
315
  let withinStaleIfErrorThreshold = false
235
316
  const staleIfErrorExpiry = result.cacheControlDirectives['stale-if-error'] ?? reqCacheControl?.['stale-if-error']
236
317
  if (staleIfErrorExpiry) {
@@ -262,9 +343,10 @@ function handleResult (
262
343
  new CacheRevalidationHandler(
263
344
  (success, context) => {
264
345
  if (success) {
265
- sendCachedValue(handler, opts, result, age, context, true)
346
+ // TODO: successful revalidation should be considered fresh (not give stale warning).
347
+ sendCachedValue(handler, opts, result, age, context, stale)
266
348
  } else if (util.isStream(result.body)) {
267
- result.body.on('error', () => {}).destroy()
349
+ result.body.on('error', nop).destroy()
268
350
  }
269
351
  },
270
352
  new CacheHandler(globalOpts, cacheKey, handler),
@@ -275,7 +357,7 @@ function handleResult (
275
357
 
276
358
  // Dump request body.
277
359
  if (util.isStream(opts.body)) {
278
- opts.body.on('error', () => {}).destroy()
360
+ opts.body.on('error', nop).destroy()
279
361
  }
280
362
 
281
363
  sendCachedValue(handler, opts, result, age, null, false)
@@ -344,18 +426,17 @@ module.exports = (opts = {}) => {
344
426
  const result = store.get(cacheKey)
345
427
 
346
428
  if (result && typeof result.then === 'function') {
347
- result.then(result => {
348
- handleResult(dispatch,
429
+ return result
430
+ .then(result => handleResult(dispatch,
349
431
  globalOpts,
350
432
  cacheKey,
351
433
  handler,
352
434
  opts,
353
435
  reqCacheControl,
354
436
  result
355
- )
356
- })
437
+ ))
357
438
  } else {
358
- handleResult(
439
+ return handleResult(
359
440
  dispatch,
360
441
  globalOpts,
361
442
  cacheKey,
@@ -365,8 +446,6 @@ module.exports = (opts = {}) => {
365
446
  result
366
447
  )
367
448
  }
368
-
369
- return true
370
449
  }
371
450
  }
372
451
  }
@@ -3,6 +3,7 @@
3
3
  const { createInflate, createGunzip, createBrotliDecompress, createZstdDecompress } = require('node:zlib')
4
4
  const { pipeline } = require('node:stream')
5
5
  const DecoratorHandler = require('../handler/decorator-handler')
6
+ const { runtimeFeatures } = require('../util/runtime-features')
6
7
 
7
8
  /** @typedef {import('node:stream').Transform} Transform */
8
9
  /** @typedef {import('node:stream').Transform} Controller */
@@ -16,7 +17,7 @@ const supportedEncodings = {
16
17
  deflate: createInflate,
17
18
  compress: createInflate,
18
19
  'x-compress': createInflate,
19
- ...(createZstdDecompress ? { zstd: createZstdDecompress } : {})
20
+ ...(runtimeFeatures.has('zstd') ? { zstd: createZstdDecompress } : {})
20
21
  }
21
22
 
22
23
  const defaultSkipStatusCodes = /** @type {const} */ ([204, 304])
@@ -0,0 +1,109 @@
1
+ 'use strict'
2
+
3
+ const diagnosticsChannel = require('node:diagnostics_channel')
4
+ const util = require('../core/util')
5
+ const DeduplicationHandler = require('../handler/deduplication-handler')
6
+ const { normalizeHeaders, makeCacheKey, makeDeduplicationKey } = require('../util/cache.js')
7
+
8
+ const pendingRequestsChannel = diagnosticsChannel.channel('undici:request:pending-requests')
9
+
10
+ /**
11
+ * @param {import('../../types/interceptors.d.ts').default.DeduplicateInterceptorOpts} [opts]
12
+ * @returns {import('../../types/dispatcher.d.ts').default.DispatcherComposeInterceptor}
13
+ */
14
+ module.exports = (opts = {}) => {
15
+ const {
16
+ methods = ['GET'],
17
+ skipHeaderNames = [],
18
+ excludeHeaderNames = []
19
+ } = opts
20
+
21
+ if (typeof opts !== 'object' || opts === null) {
22
+ throw new TypeError(`expected type of opts to be an Object, got ${opts === null ? 'null' : typeof opts}`)
23
+ }
24
+
25
+ if (!Array.isArray(methods)) {
26
+ throw new TypeError(`expected opts.methods to be an array, got ${typeof methods}`)
27
+ }
28
+
29
+ for (const method of methods) {
30
+ if (!util.safeHTTPMethods.includes(method)) {
31
+ throw new TypeError(`expected opts.methods to only contain safe HTTP methods, got ${method}`)
32
+ }
33
+ }
34
+
35
+ if (!Array.isArray(skipHeaderNames)) {
36
+ throw new TypeError(`expected opts.skipHeaderNames to be an array, got ${typeof skipHeaderNames}`)
37
+ }
38
+
39
+ if (!Array.isArray(excludeHeaderNames)) {
40
+ throw new TypeError(`expected opts.excludeHeaderNames to be an array, got ${typeof excludeHeaderNames}`)
41
+ }
42
+
43
+ // Convert to lowercase Set for case-insensitive header matching
44
+ const skipHeaderNamesSet = new Set(skipHeaderNames.map(name => name.toLowerCase()))
45
+
46
+ // Convert to lowercase Set for case-insensitive header exclusion from deduplication key
47
+ const excludeHeaderNamesSet = new Set(excludeHeaderNames.map(name => name.toLowerCase()))
48
+
49
+ const safeMethodsToNotDeduplicate = util.safeHTTPMethods.filter(method => methods.includes(method) === false)
50
+
51
+ /**
52
+ * Map of pending requests for deduplication
53
+ * @type {Map<string, DeduplicationHandler>}
54
+ */
55
+ const pendingRequests = new Map()
56
+
57
+ return dispatch => {
58
+ return (opts, handler) => {
59
+ if (!opts.origin || safeMethodsToNotDeduplicate.includes(opts.method)) {
60
+ return dispatch(opts, handler)
61
+ }
62
+
63
+ opts = {
64
+ ...opts,
65
+ headers: normalizeHeaders(opts)
66
+ }
67
+
68
+ // Skip deduplication if request contains any of the specified headers
69
+ if (skipHeaderNamesSet.size > 0) {
70
+ for (const headerName of Object.keys(opts.headers)) {
71
+ if (skipHeaderNamesSet.has(headerName.toLowerCase())) {
72
+ return dispatch(opts, handler)
73
+ }
74
+ }
75
+ }
76
+
77
+ const cacheKey = makeCacheKey(opts)
78
+ const dedupeKey = makeDeduplicationKey(cacheKey, excludeHeaderNamesSet)
79
+
80
+ // Check if there's already a pending request for this key
81
+ const pendingHandler = pendingRequests.get(dedupeKey)
82
+ if (pendingHandler) {
83
+ // Add this handler to the waiting list
84
+ pendingHandler.addWaitingHandler(handler)
85
+ return true
86
+ }
87
+
88
+ // Create a new deduplication handler
89
+ const deduplicationHandler = new DeduplicationHandler(
90
+ handler,
91
+ () => {
92
+ // Clean up when request completes
93
+ pendingRequests.delete(dedupeKey)
94
+ if (pendingRequestsChannel.hasSubscribers) {
95
+ pendingRequestsChannel.publish({ size: pendingRequests.size, key: dedupeKey, type: 'removed' })
96
+ }
97
+ }
98
+ )
99
+
100
+ // Register the pending request
101
+ pendingRequests.set(dedupeKey, deduplicationHandler)
102
+ if (pendingRequestsChannel.hasSubscribers) {
103
+ pendingRequestsChannel.publish({ size: pendingRequests.size, key: dedupeKey, type: 'added' })
104
+ }
105
+
106
+ return dispatch(opts, deduplicationHandler)
107
+ }
108
+ }
109
+ }
@@ -5,14 +5,44 @@ const DecoratorHandler = require('../handler/decorator-handler')
5
5
  const { InvalidArgumentError, InformationalError } = require('../core/errors')
6
6
  const maxInt = Math.pow(2, 31) - 1
7
7
 
8
+ class DNSStorage {
9
+ #maxItems = 0
10
+ #records = new Map()
11
+
12
+ constructor (opts) {
13
+ this.#maxItems = opts.maxItems
14
+ }
15
+
16
+ get size () {
17
+ return this.#records.size
18
+ }
19
+
20
+ get (hostname) {
21
+ return this.#records.get(hostname) ?? null
22
+ }
23
+
24
+ set (hostname, records) {
25
+ this.#records.set(hostname, records)
26
+ }
27
+
28
+ delete (hostname) {
29
+ this.#records.delete(hostname)
30
+ }
31
+
32
+ // Delegate to storage decide can we do more lookups or not
33
+ full () {
34
+ return this.size >= this.#maxItems
35
+ }
36
+ }
37
+
8
38
  class DNSInstance {
9
39
  #maxTTL = 0
10
40
  #maxItems = 0
11
- #records = new Map()
12
41
  dualStack = true
13
42
  affinity = null
14
43
  lookup = null
15
44
  pick = null
45
+ storage = null
16
46
 
17
47
  constructor (opts) {
18
48
  this.#maxTTL = opts.maxTTL
@@ -21,17 +51,14 @@ class DNSInstance {
21
51
  this.affinity = opts.affinity
22
52
  this.lookup = opts.lookup ?? this.#defaultLookup
23
53
  this.pick = opts.pick ?? this.#defaultPick
24
- }
25
-
26
- get full () {
27
- return this.#records.size === this.#maxItems
54
+ this.storage = opts.storage ?? new DNSStorage(opts)
28
55
  }
29
56
 
30
57
  runLookup (origin, opts, cb) {
31
- const ips = this.#records.get(origin.hostname)
58
+ const ips = this.storage.get(origin.hostname)
32
59
 
33
60
  // If full, we just return the origin
34
- if (ips == null && this.full) {
61
+ if (ips == null && this.storage.full()) {
35
62
  cb(null, origin)
36
63
  return
37
64
  }
@@ -55,7 +82,7 @@ class DNSInstance {
55
82
  }
56
83
 
57
84
  this.setRecords(origin, addresses)
58
- const records = this.#records.get(origin.hostname)
85
+ const records = this.storage.get(origin.hostname)
59
86
 
60
87
  const ip = this.pick(
61
88
  origin,
@@ -89,7 +116,7 @@ class DNSInstance {
89
116
 
90
117
  // If no IPs we lookup - deleting old records
91
118
  if (ip == null) {
92
- this.#records.delete(origin.hostname)
119
+ this.storage.delete(origin.hostname)
93
120
  this.runLookup(origin, opts, cb)
94
121
  return
95
122
  }
@@ -193,7 +220,7 @@ class DNSInstance {
193
220
  }
194
221
 
195
222
  pickFamily (origin, ipFamily) {
196
- const records = this.#records.get(origin.hostname)?.records
223
+ const records = this.storage.get(origin.hostname)?.records
197
224
  if (!records) {
198
225
  return null
199
226
  }
@@ -227,11 +254,13 @@ class DNSInstance {
227
254
  setRecords (origin, addresses) {
228
255
  const timestamp = Date.now()
229
256
  const records = { records: { 4: null, 6: null } }
257
+ let minTTL = this.#maxTTL
230
258
  for (const record of addresses) {
231
259
  record.timestamp = timestamp
232
260
  if (typeof record.ttl === 'number') {
233
261
  // The record TTL is expected to be in ms
234
262
  record.ttl = Math.min(record.ttl, this.#maxTTL)
263
+ minTTL = Math.min(minTTL, record.ttl)
235
264
  } else {
236
265
  record.ttl = this.#maxTTL
237
266
  }
@@ -242,11 +271,12 @@ class DNSInstance {
242
271
  records.records[record.family] = familyRecords
243
272
  }
244
273
 
245
- this.#records.set(origin.hostname, records)
274
+ // We provide a default TTL if external storage will be used without TTL per record-level support
275
+ this.storage.set(origin.hostname, records, { ttl: minTTL })
246
276
  }
247
277
 
248
278
  deleteRecords (origin) {
249
- this.#records.delete(origin.hostname)
279
+ this.storage.delete(origin.hostname)
250
280
  }
251
281
 
252
282
  getHandler (meta, opts) {
@@ -372,6 +402,17 @@ module.exports = interceptorOpts => {
372
402
  throw new InvalidArgumentError('Invalid pick. Must be a function')
373
403
  }
374
404
 
405
+ if (
406
+ interceptorOpts?.storage != null &&
407
+ (typeof interceptorOpts?.storage?.get !== 'function' ||
408
+ typeof interceptorOpts?.storage?.set !== 'function' ||
409
+ typeof interceptorOpts?.storage?.full !== 'function' ||
410
+ typeof interceptorOpts?.storage?.delete !== 'function'
411
+ )
412
+ ) {
413
+ throw new InvalidArgumentError('Invalid storage. Must be a object with methods: { get, set, full, delete }')
414
+ }
415
+
375
416
  const dualStack = interceptorOpts?.dualStack ?? true
376
417
  let affinity
377
418
  if (dualStack) {
@@ -386,7 +427,8 @@ module.exports = interceptorOpts => {
386
427
  pick: interceptorOpts?.pick ?? null,
387
428
  dualStack,
388
429
  affinity,
389
- maxItems: interceptorOpts?.maxItems ?? Infinity
430
+ maxItems: interceptorOpts?.maxItems ?? Infinity,
431
+ storage: interceptorOpts?.storage
390
432
  }
391
433
 
392
434
  const instance = new DNSInstance(opts)