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.
- package/README.md +48 -2
- package/docs/docs/api/Agent.md +1 -0
- package/docs/docs/api/Client.md +1 -0
- package/docs/docs/api/DiagnosticsChannel.md +57 -0
- package/docs/docs/api/Dispatcher.md +86 -0
- package/docs/docs/api/Errors.md +0 -1
- package/docs/docs/api/RoundRobinPool.md +145 -0
- package/docs/docs/api/WebSocket.md +21 -0
- package/docs/docs/best-practices/crawling.md +58 -0
- package/index-fetch.js +2 -2
- package/index.js +8 -9
- package/lib/api/api-request.js +22 -8
- package/lib/api/api-upgrade.js +2 -1
- package/lib/api/readable.js +7 -5
- package/lib/core/connect.js +4 -1
- package/lib/core/diagnostics.js +28 -1
- package/lib/core/errors.js +217 -13
- package/lib/core/request.js +5 -1
- package/lib/core/symbols.js +3 -0
- package/lib/core/util.js +61 -41
- package/lib/dispatcher/agent.js +19 -7
- package/lib/dispatcher/balanced-pool.js +10 -0
- package/lib/dispatcher/client-h1.js +18 -23
- package/lib/dispatcher/client-h2.js +166 -26
- package/lib/dispatcher/client.js +64 -59
- package/lib/dispatcher/dispatcher-base.js +20 -16
- package/lib/dispatcher/env-http-proxy-agent.js +12 -16
- package/lib/dispatcher/fixed-queue.js +15 -39
- package/lib/dispatcher/h2c-client.js +7 -78
- package/lib/dispatcher/pool-base.js +60 -43
- package/lib/dispatcher/pool.js +2 -2
- package/lib/dispatcher/proxy-agent.js +27 -11
- package/lib/dispatcher/round-robin-pool.js +137 -0
- package/lib/encoding/index.js +33 -0
- package/lib/global.js +19 -1
- package/lib/handler/cache-handler.js +84 -27
- package/lib/handler/deduplication-handler.js +216 -0
- package/lib/handler/retry-handler.js +0 -2
- package/lib/interceptor/cache.js +94 -15
- package/lib/interceptor/decompress.js +2 -1
- package/lib/interceptor/deduplicate.js +109 -0
- package/lib/interceptor/dns.js +55 -13
- package/lib/mock/mock-agent.js +4 -4
- package/lib/mock/mock-errors.js +10 -0
- package/lib/mock/mock-utils.js +13 -12
- package/lib/mock/snapshot-agent.js +11 -5
- package/lib/mock/snapshot-recorder.js +12 -4
- package/lib/mock/snapshot-utils.js +4 -4
- package/lib/util/cache.js +29 -1
- package/lib/util/date.js +534 -140
- package/lib/util/runtime-features.js +124 -0
- package/lib/web/cookies/index.js +1 -1
- package/lib/web/cookies/parse.js +1 -1
- package/lib/web/eventsource/eventsource-stream.js +2 -2
- package/lib/web/eventsource/eventsource.js +34 -29
- package/lib/web/eventsource/util.js +1 -9
- package/lib/web/fetch/body.js +45 -61
- package/lib/web/fetch/data-url.js +12 -160
- package/lib/web/fetch/formdata-parser.js +204 -127
- package/lib/web/fetch/index.js +21 -19
- package/lib/web/fetch/request.js +6 -0
- package/lib/web/fetch/response.js +4 -7
- package/lib/web/fetch/util.js +10 -79
- package/lib/web/infra/index.js +229 -0
- package/lib/web/subresource-integrity/subresource-integrity.js +6 -5
- package/lib/web/webidl/index.js +207 -44
- package/lib/web/websocket/connection.js +33 -22
- package/lib/web/websocket/events.js +1 -1
- package/lib/web/websocket/frame.js +9 -15
- package/lib/web/websocket/stream/websocketerror.js +22 -1
- package/lib/web/websocket/stream/websocketstream.js +17 -8
- package/lib/web/websocket/util.js +2 -1
- package/lib/web/websocket/websocket.js +32 -42
- package/package.json +9 -7
- package/types/agent.d.ts +2 -1
- package/types/api.d.ts +2 -2
- package/types/balanced-pool.d.ts +2 -1
- package/types/cache-interceptor.d.ts +1 -0
- package/types/client.d.ts +1 -1
- package/types/connector.d.ts +2 -2
- package/types/diagnostics-channel.d.ts +2 -2
- package/types/dispatcher.d.ts +12 -12
- package/types/errors.d.ts +5 -15
- package/types/fetch.d.ts +4 -4
- package/types/formdata.d.ts +1 -1
- package/types/h2c-client.d.ts +1 -1
- package/types/index.d.ts +9 -1
- package/types/interceptors.d.ts +36 -2
- package/types/pool.d.ts +1 -1
- package/types/readable.d.ts +2 -2
- package/types/round-robin-pool.d.ts +41 -0
- package/types/webidl.d.ts +82 -21
- 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
|
package/lib/interceptor/cache.js
CHANGED
|
@@ -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, [],
|
|
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 (
|
|
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
|
-
|
|
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',
|
|
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',
|
|
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
|
|
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
|
-
...(
|
|
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
|
+
}
|
package/lib/interceptor/dns.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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)
|