undici 7.0.0-alpha.2 → 7.0.0-alpha.4

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 (56) hide show
  1. package/README.md +3 -2
  2. package/docs/docs/api/BalancedPool.md +1 -1
  3. package/docs/docs/api/CacheStore.md +100 -0
  4. package/docs/docs/api/Dispatcher.md +32 -2
  5. package/docs/docs/api/MockClient.md +1 -1
  6. package/docs/docs/api/Pool.md +1 -1
  7. package/docs/docs/api/api-lifecycle.md +2 -2
  8. package/docs/docs/best-practices/mocking-request.md +2 -2
  9. package/docs/docs/best-practices/proxy.md +1 -1
  10. package/index.d.ts +1 -1
  11. package/index.js +8 -2
  12. package/lib/api/api-request.js +2 -2
  13. package/lib/api/readable.js +6 -6
  14. package/lib/cache/memory-cache-store.js +325 -0
  15. package/lib/core/connect.js +5 -0
  16. package/lib/core/constants.js +24 -1
  17. package/lib/core/request.js +2 -2
  18. package/lib/core/util.js +13 -1
  19. package/lib/dispatcher/client-h1.js +100 -87
  20. package/lib/dispatcher/client-h2.js +168 -96
  21. package/lib/dispatcher/pool-base.js +3 -3
  22. package/lib/handler/cache-handler.js +389 -0
  23. package/lib/handler/cache-revalidation-handler.js +151 -0
  24. package/lib/handler/redirect-handler.js +5 -3
  25. package/lib/handler/retry-handler.js +3 -3
  26. package/lib/interceptor/cache.js +192 -0
  27. package/lib/interceptor/dns.js +71 -48
  28. package/lib/util/cache.js +249 -0
  29. package/lib/web/cache/cache.js +1 -0
  30. package/lib/web/cache/cachestorage.js +2 -0
  31. package/lib/web/cookies/index.js +12 -1
  32. package/lib/web/cookies/parse.js +6 -1
  33. package/lib/web/eventsource/eventsource.js +2 -0
  34. package/lib/web/fetch/body.js +1 -5
  35. package/lib/web/fetch/constants.js +12 -5
  36. package/lib/web/fetch/data-url.js +2 -2
  37. package/lib/web/fetch/formdata-parser.js +70 -43
  38. package/lib/web/fetch/formdata.js +3 -1
  39. package/lib/web/fetch/headers.js +3 -1
  40. package/lib/web/fetch/index.js +4 -6
  41. package/lib/web/fetch/request.js +3 -1
  42. package/lib/web/fetch/response.js +3 -1
  43. package/lib/web/fetch/util.js +171 -47
  44. package/lib/web/fetch/webidl.js +28 -16
  45. package/lib/web/websocket/constants.js +67 -6
  46. package/lib/web/websocket/events.js +4 -0
  47. package/lib/web/websocket/stream/websocketerror.js +1 -1
  48. package/lib/web/websocket/websocket.js +2 -0
  49. package/package.json +8 -5
  50. package/types/cache-interceptor.d.ts +101 -0
  51. package/types/cookies.d.ts +2 -0
  52. package/types/dispatcher.d.ts +1 -1
  53. package/types/fetch.d.ts +9 -8
  54. package/types/index.d.ts +3 -1
  55. package/types/interceptors.d.ts +4 -1
  56. package/types/webidl.d.ts +7 -1
@@ -0,0 +1,389 @@
1
+ 'use strict'
2
+
3
+ const util = require('../core/util')
4
+ const DecoratorHandler = require('../handler/decorator-handler')
5
+ const {
6
+ parseCacheControlHeader,
7
+ parseVaryHeader
8
+ } = require('../util/cache')
9
+
10
+ function noop () {}
11
+
12
+ /**
13
+ * Writes a response to a CacheStore and then passes it on to the next handler
14
+ */
15
+ class CacheHandler extends DecoratorHandler {
16
+ /**
17
+ * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey}
18
+ */
19
+ #cacheKey
20
+
21
+ /**
22
+ * @type {import('../../types/cache-interceptor.d.ts').default.CacheStore}
23
+ */
24
+ #store
25
+
26
+ /**
27
+ * @type {import('../../types/dispatcher.d.ts').default.DispatchHandlers}
28
+ */
29
+ #handler
30
+
31
+ /**
32
+ * @type {import('node:stream').Writable | undefined}
33
+ */
34
+ #writeStream
35
+
36
+ /**
37
+ * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions} opts
38
+ * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey
39
+ * @param {import('../../types/dispatcher.d.ts').default.DispatchHandlers} handler
40
+ */
41
+ constructor (opts, cacheKey, handler) {
42
+ const { store } = opts
43
+
44
+ super(handler)
45
+
46
+ this.#store = store
47
+ this.#cacheKey = cacheKey
48
+ this.#handler = handler
49
+ }
50
+
51
+ onConnect (abort) {
52
+ if (this.#writeStream) {
53
+ this.#writeStream.destroy()
54
+ this.#writeStream = undefined
55
+ }
56
+
57
+ if (typeof this.#handler.onConnect === 'function') {
58
+ this.#handler.onConnect(abort)
59
+ }
60
+ }
61
+
62
+ /**
63
+ * @see {DispatchHandlers.onHeaders}
64
+ *
65
+ * @param {number} statusCode
66
+ * @param {Buffer[]} rawHeaders
67
+ * @param {() => void} resume
68
+ * @param {string} statusMessage
69
+ * @returns {boolean}
70
+ */
71
+ onHeaders (
72
+ statusCode,
73
+ rawHeaders,
74
+ resume,
75
+ statusMessage
76
+ ) {
77
+ const downstreamOnHeaders = () => {
78
+ if (typeof this.#handler.onHeaders === 'function') {
79
+ return this.#handler.onHeaders(
80
+ statusCode,
81
+ rawHeaders,
82
+ resume,
83
+ statusMessage
84
+ )
85
+ } else {
86
+ return true
87
+ }
88
+ }
89
+
90
+ if (
91
+ !util.safeHTTPMethods.includes(this.#cacheKey.method) &&
92
+ statusCode >= 200 &&
93
+ statusCode <= 399
94
+ ) {
95
+ // https://www.rfc-editor.org/rfc/rfc9111.html#name-invalidating-stored-response
96
+ try {
97
+ this.#store.delete(this.#cacheKey).catch?.(noop)
98
+ } catch {
99
+ // Fail silently
100
+ }
101
+ return downstreamOnHeaders()
102
+ }
103
+
104
+ const parsedRawHeaders = util.parseRawHeaders(rawHeaders)
105
+ const headers = util.parseHeaders(parsedRawHeaders)
106
+
107
+ const cacheControlHeader = headers['cache-control']
108
+ const isCacheFull = typeof this.#store.isFull !== 'undefined'
109
+ ? this.#store.isFull
110
+ : false
111
+
112
+ if (
113
+ !cacheControlHeader ||
114
+ isCacheFull
115
+ ) {
116
+ // Don't have the cache control header or the cache is full
117
+ return downstreamOnHeaders()
118
+ }
119
+ const cacheControlDirectives = parseCacheControlHeader(cacheControlHeader)
120
+ if (!canCacheResponse(statusCode, headers, cacheControlDirectives)) {
121
+ return downstreamOnHeaders()
122
+ }
123
+
124
+ const now = Date.now()
125
+ const staleAt = determineStaleAt(now, headers, cacheControlDirectives)
126
+ if (staleAt) {
127
+ const varyDirectives = this.#cacheKey.headers && headers.vary
128
+ ? parseVaryHeader(headers.vary, this.#cacheKey.headers)
129
+ : undefined
130
+ const deleteAt = determineDeleteAt(now, cacheControlDirectives, staleAt)
131
+
132
+ const strippedHeaders = stripNecessaryHeaders(
133
+ rawHeaders,
134
+ parsedRawHeaders,
135
+ cacheControlDirectives
136
+ )
137
+
138
+ this.#writeStream = this.#store.createWriteStream(this.#cacheKey, {
139
+ statusCode,
140
+ statusMessage,
141
+ rawHeaders: strippedHeaders,
142
+ vary: varyDirectives,
143
+ cachedAt: now,
144
+ staleAt,
145
+ deleteAt
146
+ })
147
+
148
+ if (this.#writeStream) {
149
+ const handler = this
150
+ this.#writeStream
151
+ .on('drain', resume)
152
+ .on('error', function () {
153
+ // TODO (fix): Make error somehow observable?
154
+ })
155
+ .on('close', function () {
156
+ if (handler.#writeStream === this) {
157
+ handler.#writeStream = undefined
158
+ }
159
+
160
+ // TODO (fix): Should we resume even if was paused downstream?
161
+ resume()
162
+ })
163
+ }
164
+ }
165
+
166
+ return downstreamOnHeaders()
167
+ }
168
+
169
+ /**
170
+ * @see {DispatchHandlers.onData}
171
+ *
172
+ * @param {Buffer} chunk
173
+ * @returns {boolean}
174
+ */
175
+ onData (chunk) {
176
+ let paused = false
177
+
178
+ if (this.#writeStream) {
179
+ paused ||= this.#writeStream.write(chunk) === false
180
+ }
181
+
182
+ if (typeof this.#handler.onData === 'function') {
183
+ paused ||= this.#handler.onData(chunk) === false
184
+ }
185
+
186
+ return !paused
187
+ }
188
+
189
+ /**
190
+ * @see {DispatchHandlers.onComplete}
191
+ *
192
+ * @param {string[] | null} rawTrailers
193
+ */
194
+ onComplete (rawTrailers) {
195
+ if (this.#writeStream) {
196
+ this.#writeStream.end()
197
+ }
198
+
199
+ if (typeof this.#handler.onComplete === 'function') {
200
+ return this.#handler.onComplete(rawTrailers)
201
+ }
202
+ }
203
+
204
+ /**
205
+ * @see {DispatchHandlers.onError}
206
+ *
207
+ * @param {Error} err
208
+ */
209
+ onError (err) {
210
+ if (this.#writeStream) {
211
+ this.#writeStream.destroy(err)
212
+ this.#writeStream = undefined
213
+ }
214
+
215
+ if (typeof this.#handler.onError === 'function') {
216
+ this.#handler.onError(err)
217
+ }
218
+ }
219
+ }
220
+
221
+ /**
222
+ * @see https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen
223
+ *
224
+ * @param {number} statusCode
225
+ * @param {Record<string, string | string[]>} headers
226
+ * @param {import('../util/cache.js').CacheControlDirectives} cacheControlDirectives
227
+ */
228
+ function canCacheResponse (statusCode, headers, cacheControlDirectives) {
229
+ if (
230
+ statusCode !== 200 &&
231
+ statusCode !== 307
232
+ ) {
233
+ return false
234
+ }
235
+
236
+ if (
237
+ cacheControlDirectives.private === true ||
238
+ cacheControlDirectives['no-cache'] === true ||
239
+ cacheControlDirectives['no-store']
240
+ ) {
241
+ return false
242
+ }
243
+
244
+ // https://www.rfc-editor.org/rfc/rfc9111.html#section-4.1-5
245
+ if (headers.vary === '*') {
246
+ return false
247
+ }
248
+
249
+ // https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen
250
+ if (headers.authorization) {
251
+ if (!cacheControlDirectives.public || typeof headers.authorization !== 'string') {
252
+ return false
253
+ }
254
+
255
+ if (
256
+ Array.isArray(cacheControlDirectives['no-cache']) &&
257
+ cacheControlDirectives['no-cache'].includes('authorization')
258
+ ) {
259
+ return false
260
+ }
261
+
262
+ if (
263
+ Array.isArray(cacheControlDirectives['private']) &&
264
+ cacheControlDirectives['private'].includes('authorization')
265
+ ) {
266
+ return false
267
+ }
268
+ }
269
+
270
+ return true
271
+ }
272
+
273
+ /**
274
+ * @param {number} now
275
+ * @param {Record<string, string | string[]>} headers
276
+ * @param {import('../util/cache.js').CacheControlDirectives} cacheControlDirectives
277
+ *
278
+ * @returns {number | undefined} time that the value is stale at or undefined if it shouldn't be cached
279
+ */
280
+ function determineStaleAt (now, headers, cacheControlDirectives) {
281
+ // Prioritize s-maxage since we're a shared cache
282
+ // s-maxage > max-age > Expire
283
+ // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.10-3
284
+ const sMaxAge = cacheControlDirectives['s-maxage']
285
+ if (sMaxAge) {
286
+ return now + (sMaxAge * 1000)
287
+ }
288
+
289
+ if (cacheControlDirectives.immutable) {
290
+ // https://www.rfc-editor.org/rfc/rfc8246.html#section-2.2
291
+ return now + 31536000
292
+ }
293
+
294
+ const maxAge = cacheControlDirectives['max-age']
295
+ if (maxAge) {
296
+ return now + (maxAge * 1000)
297
+ }
298
+
299
+ if (headers.expire && typeof headers.expire === 'string') {
300
+ // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3
301
+ const expiresDate = new Date(headers.expire)
302
+ if (expiresDate instanceof Date && !isNaN(expiresDate)) {
303
+ return now + (Date.now() - expiresDate.getTime())
304
+ }
305
+ }
306
+
307
+ return undefined
308
+ }
309
+
310
+ /**
311
+ * @param {number} now
312
+ * @param {import('../util/cache.js').CacheControlDirectives} cacheControlDirectives
313
+ * @param {number} staleAt
314
+ */
315
+ function determineDeleteAt (now, cacheControlDirectives, staleAt) {
316
+ if (cacheControlDirectives['stale-while-revalidate']) {
317
+ return now + (cacheControlDirectives['stale-while-revalidate'] * 1000)
318
+ }
319
+
320
+ return staleAt
321
+ }
322
+
323
+ /**
324
+ * Strips headers required to be removed in cached responses
325
+ * @param {Buffer[]} rawHeaders
326
+ * @param {string[]} parsedRawHeaders
327
+ * @param {import('../util/cache.js').CacheControlDirectives} cacheControlDirectives
328
+ * @returns {Buffer[]}
329
+ */
330
+ function stripNecessaryHeaders (rawHeaders, parsedRawHeaders, cacheControlDirectives) {
331
+ const headersToRemove = ['connection']
332
+
333
+ if (Array.isArray(cacheControlDirectives['no-cache'])) {
334
+ headersToRemove.push(...cacheControlDirectives['no-cache'])
335
+ }
336
+
337
+ if (Array.isArray(cacheControlDirectives['private'])) {
338
+ headersToRemove.push(...cacheControlDirectives['private'])
339
+ }
340
+
341
+ let strippedHeaders
342
+
343
+ let offset = 0
344
+ for (let i = 0; i < parsedRawHeaders.length; i += 2) {
345
+ const headerName = parsedRawHeaders[i]
346
+
347
+ if (headersToRemove.includes(headerName)) {
348
+ // We have at least one header we want to remove
349
+ if (!strippedHeaders) {
350
+ // This is the first header we want to remove, let's create the array
351
+ // Since we're stripping headers, this will over allocate. We'll trim
352
+ // it later.
353
+ strippedHeaders = new Array(parsedRawHeaders.length)
354
+
355
+ // Backfill the previous headers into it
356
+ for (let j = 0; j < i; j += 2) {
357
+ strippedHeaders[j] = parsedRawHeaders[j]
358
+ strippedHeaders[j + 1] = parsedRawHeaders[j + 1]
359
+ }
360
+ }
361
+
362
+ // We can't map indices 1:1 from stripped headers to rawHeaders without
363
+ // creating holes (if we skip a header, we now have two holes where at
364
+ // element should be). So, let's keep an offset to keep strippedHeaders
365
+ // flattened. We can also use this at the end for trimming the empty
366
+ // elements off of strippedHeaders.
367
+ offset += 2
368
+
369
+ continue
370
+ }
371
+
372
+ // We want to keep this header. Let's add it to strippedHeaders if it exists
373
+ if (strippedHeaders) {
374
+ strippedHeaders[i - offset] = parsedRawHeaders[i]
375
+ strippedHeaders[i + 1 - offset] = parsedRawHeaders[i + 1]
376
+ }
377
+ }
378
+
379
+ if (strippedHeaders) {
380
+ // Trim off the empty values at the end
381
+ strippedHeaders.length -= offset
382
+ }
383
+
384
+ return strippedHeaders
385
+ ? util.encodeRawHeaders(strippedHeaders)
386
+ : rawHeaders
387
+ }
388
+
389
+ module.exports = CacheHandler
@@ -0,0 +1,151 @@
1
+ 'use strict'
2
+
3
+ const assert = require('node:assert')
4
+ const DecoratorHandler = require('../handler/decorator-handler')
5
+
6
+ /**
7
+ * This takes care of revalidation requests we send to the origin. If we get
8
+ * a response indicating that what we have is cached (via a HTTP 304), we can
9
+ * continue using the cached value. Otherwise, we'll receive the new response
10
+ * here, which we then just pass on to the next handler (most likely a
11
+ * CacheHandler). Note that this assumes the proper headers were already
12
+ * included in the request to tell the origin that we want to revalidate the
13
+ * response (i.e. if-modified-since).
14
+ *
15
+ * @see https://www.rfc-editor.org/rfc/rfc9111.html#name-validation
16
+ *
17
+ * @typedef {import('../../types/dispatcher.d.ts').default.DispatchHandlers} DispatchHandlers
18
+ * @implements {DispatchHandlers}
19
+ */
20
+ class CacheRevalidationHandler extends DecoratorHandler {
21
+ #successful = false
22
+ /**
23
+ * @type {((boolean) => void) | null}
24
+ */
25
+ #callback
26
+ /**
27
+ * @type {(import('../../types/dispatcher.d.ts').default.DispatchHandlers)}
28
+ */
29
+ #handler
30
+
31
+ #abort
32
+
33
+ /**
34
+ * @param {(boolean) => void} callback Function to call if the cached value is valid
35
+ * @param {import('../../types/dispatcher.d.ts').default.DispatchHandlers} handler
36
+ */
37
+ constructor (callback, handler) {
38
+ if (typeof callback !== 'function') {
39
+ throw new TypeError('callback must be a function')
40
+ }
41
+
42
+ super(handler)
43
+
44
+ this.#callback = callback
45
+ this.#handler = handler
46
+ }
47
+
48
+ onConnect (abort) {
49
+ this.#successful = false
50
+ this.#abort = abort
51
+ }
52
+
53
+ /**
54
+ * @see {DispatchHandlers.onHeaders}
55
+ *
56
+ * @param {number} statusCode
57
+ * @param {Buffer[]} rawHeaders
58
+ * @param {() => void} resume
59
+ * @param {string} statusMessage
60
+ * @returns {boolean}
61
+ */
62
+ onHeaders (
63
+ statusCode,
64
+ rawHeaders,
65
+ resume,
66
+ statusMessage
67
+ ) {
68
+ assert(this.#callback != null)
69
+
70
+ // https://www.rfc-editor.org/rfc/rfc9111.html#name-handling-a-validation-respo
71
+ this.#successful = statusCode === 304
72
+ this.#callback(this.#successful)
73
+ this.#callback = null
74
+
75
+ if (this.#successful) {
76
+ return true
77
+ }
78
+
79
+ if (typeof this.#handler.onConnect === 'function') {
80
+ this.#handler.onConnect(this.#abort)
81
+ }
82
+
83
+ if (typeof this.#handler.onHeaders === 'function') {
84
+ return this.#handler.onHeaders(
85
+ statusCode,
86
+ rawHeaders,
87
+ resume,
88
+ statusMessage
89
+ )
90
+ }
91
+
92
+ return true
93
+ }
94
+
95
+ /**
96
+ * @see {DispatchHandlers.onData}
97
+ *
98
+ * @param {Buffer} chunk
99
+ * @returns {boolean}
100
+ */
101
+ onData (chunk) {
102
+ if (this.#successful) {
103
+ return true
104
+ }
105
+
106
+ if (typeof this.#handler.onData === 'function') {
107
+ return this.#handler.onData(chunk)
108
+ }
109
+
110
+ return true
111
+ }
112
+
113
+ /**
114
+ * @see {DispatchHandlers.onComplete}
115
+ *
116
+ * @param {string[] | null} rawTrailers
117
+ */
118
+ onComplete (rawTrailers) {
119
+ if (this.#successful) {
120
+ return
121
+ }
122
+
123
+ if (typeof this.#handler.onComplete === 'function') {
124
+ this.#handler.onComplete(rawTrailers)
125
+ }
126
+ }
127
+
128
+ /**
129
+ * @see {DispatchHandlers.onError}
130
+ *
131
+ * @param {Error} err
132
+ */
133
+ onError (err) {
134
+ if (this.#successful) {
135
+ return
136
+ }
137
+
138
+ if (this.#callback) {
139
+ this.#callback(false)
140
+ this.#callback = null
141
+ }
142
+
143
+ if (typeof this.#handler.onError === 'function') {
144
+ this.#handler.onError(err)
145
+ } else {
146
+ throw err
147
+ }
148
+ }
149
+ }
150
+
151
+ module.exports = CacheRevalidationHandler
@@ -75,7 +75,8 @@ class RedirectHandler {
75
75
  this.opts.body &&
76
76
  typeof this.opts.body !== 'string' &&
77
77
  !ArrayBuffer.isView(this.opts.body) &&
78
- util.isIterable(this.opts.body)
78
+ util.isIterable(this.opts.body) &&
79
+ !util.isFormDataLike(this.opts.body)
79
80
  ) {
80
81
  // TODO: Should we allow re-using iterable if !this.opts.idempotent
81
82
  // or through some other flag?
@@ -227,9 +228,10 @@ function cleanRequestHeaders (headers, removeContent, unknownOrigin) {
227
228
  }
228
229
  }
229
230
  } else if (headers && typeof headers === 'object') {
230
- for (const key of Object.keys(headers)) {
231
+ const entries = typeof headers[Symbol.iterator] === 'function' ? headers : Object.entries(headers)
232
+ for (const [key, value] of entries) {
231
233
  if (!shouldRemoveHeader(key, removeContent, unknownOrigin)) {
232
- ret.push(key, headers[key])
234
+ ret.push(key, value)
233
235
  }
234
236
  }
235
237
  } else {
@@ -229,7 +229,7 @@ class RetryHandler {
229
229
  return false
230
230
  }
231
231
 
232
- const { start, size, end = size } = contentRange
232
+ const { start, size, end = size - 1 } = contentRange
233
233
 
234
234
  assert(this.start === start, 'content-range mismatch')
235
235
  assert(this.end == null || this.end === end, 'content-range mismatch')
@@ -252,7 +252,7 @@ class RetryHandler {
252
252
  )
253
253
  }
254
254
 
255
- const { start, size, end = size } = range
255
+ const { start, size, end = size - 1 } = range
256
256
  assert(
257
257
  start != null && Number.isFinite(start),
258
258
  'content-range mismatch'
@@ -266,7 +266,7 @@ class RetryHandler {
266
266
  // We make our best to checkpoint the body for further range headers
267
267
  if (this.end == null) {
268
268
  const contentLength = headers['content-length']
269
- this.end = contentLength != null ? Number(contentLength) : null
269
+ this.end = contentLength != null ? Number(contentLength) - 1 : null
270
270
  }
271
271
 
272
272
  assert(Number.isFinite(this.start))