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
@@ -4,6 +4,7 @@ const { maxNameValuePairSize, maxAttributeValueSize } = require('./constants')
4
4
  const { isCTLExcludingHtab } = require('./util')
5
5
  const { collectASequenceOfCodePointsFast } = require('../fetch/data-url')
6
6
  const assert = require('node:assert')
7
+ const { unescape } = require('node:querystring')
7
8
 
8
9
  /**
9
10
  * @description Parses the field-value attributes of a set-cookie header string.
@@ -76,8 +77,12 @@ function parseSetCookie (header) {
76
77
 
77
78
  // 6. The cookie-name is the name string, and the cookie-value is the
78
79
  // value string.
80
+ // https://datatracker.ietf.org/doc/html/rfc6265
81
+ // To maximize compatibility with user agents, servers that wish to
82
+ // store arbitrary data in a cookie-value SHOULD encode that data, for
83
+ // example, using Base64 [RFC4648].
79
84
  return {
80
- name, value, ...parseUnparsedAttributes(unparsedAttributes)
85
+ name, value: unescape(value), ...parseUnparsedAttributes(unparsedAttributes)
81
86
  }
82
87
  }
83
88
 
@@ -109,6 +109,8 @@ class EventSource extends EventTarget {
109
109
  // 1. Let ev be a new EventSource object.
110
110
  super()
111
111
 
112
+ webidl.util.markAsUncloneable(this)
113
+
112
114
  const prefix = 'EventSource constructor'
113
115
  webidl.argumentLengthCheck(arguments, 1, prefix)
114
116
 
@@ -364,12 +364,8 @@ function bodyMixinMethods (instance, getInternalState) {
364
364
  switch (mimeType.essence) {
365
365
  case 'multipart/form-data': {
366
366
  // 1. ... [long step]
367
- const parsed = multipartFormDataParser(value, mimeType)
368
-
369
367
  // 2. If that fails for some reason, then throw a TypeError.
370
- if (parsed === 'failure') {
371
- throw new TypeError('Failed to parse body as FormData.')
372
- }
368
+ const parsed = multipartFormDataParser(value, mimeType)
373
369
 
374
370
  // 3. Return a new FormData object, appending each entry,
375
371
  // resulting from the parsing operation, to its entry list.
@@ -22,10 +22,9 @@ const badPorts = /** @type {const} */ ([
22
22
  const badPortsSet = new Set(badPorts)
23
23
 
24
24
  /**
25
- * @see https://w3c.github.io/webappsec-referrer-policy/#referrer-policies
25
+ * @see https://w3c.github.io/webappsec-referrer-policy/#referrer-policy-header
26
26
  */
27
- const referrerPolicy = /** @type {const} */ ([
28
- '',
27
+ const referrerPolicyTokens = /** @type {const} */ ([
29
28
  'no-referrer',
30
29
  'no-referrer-when-downgrade',
31
30
  'same-origin',
@@ -35,7 +34,15 @@ const referrerPolicy = /** @type {const} */ ([
35
34
  'strict-origin-when-cross-origin',
36
35
  'unsafe-url'
37
36
  ])
38
- const referrerPolicySet = new Set(referrerPolicy)
37
+
38
+ /**
39
+ * @see https://w3c.github.io/webappsec-referrer-policy/#referrer-policies
40
+ */
41
+ const referrerPolicy = /** @type {const} */ ([
42
+ '',
43
+ ...referrerPolicyTokens
44
+ ])
45
+ const referrerPolicyTokensSet = new Set(referrerPolicyTokens)
39
46
 
40
47
  const requestRedirect = /** @type {const} */ (['follow', 'manual', 'error'])
41
48
 
@@ -120,5 +127,5 @@ module.exports = {
120
127
  corsSafeListedMethodsSet,
121
128
  safeMethodsSet,
122
129
  forbiddenMethodsSet,
123
- referrerPolicySet
130
+ referrerPolicyTokens: referrerPolicyTokensSet
124
131
  }
@@ -471,9 +471,9 @@ function forgivingBase64 (data) {
471
471
  /**
472
472
  * @param {string} input
473
473
  * @param {{ position: number }} position
474
- * @param {boolean?} extractValue
474
+ * @param {boolean} [extractValue=false]
475
475
  */
476
- function collectAnHTTPQuotedString (input, position, extractValue) {
476
+ function collectAnHTTPQuotedString (input, position, extractValue = false) {
477
477
  // 1. Let positionStart be position.
478
478
  const positionStart = position.position
479
479
 
@@ -11,7 +11,7 @@ const { File: NodeFile } = require('node:buffer')
11
11
  const File = globalThis.File ?? NodeFile
12
12
 
13
13
  const formDataNameBuffer = Buffer.from('form-data; name="')
14
- const filenameBuffer = Buffer.from('; filename')
14
+ const filenameBuffer = Buffer.from('filename')
15
15
  const dd = Buffer.from('--')
16
16
  const ddcrlf = Buffer.from('--\r\n')
17
17
 
@@ -75,7 +75,7 @@ function multipartFormDataParser (input, mimeType) {
75
75
  // Otherwise, let boundary be the result of UTF-8 decoding mimeType’s
76
76
  // parameters["boundary"].
77
77
  if (boundaryString === undefined) {
78
- return 'failure'
78
+ throw parsingError('missing boundary in content-type header')
79
79
  }
80
80
 
81
81
  const boundary = Buffer.from(`--${boundaryString}`, 'utf8')
@@ -111,7 +111,7 @@ function multipartFormDataParser (input, mimeType) {
111
111
  if (input.subarray(position.position, position.position + boundary.length).equals(boundary)) {
112
112
  position.position += boundary.length
113
113
  } else {
114
- return 'failure'
114
+ throw parsingError('expected a value starting with -- and the boundary')
115
115
  }
116
116
 
117
117
  // 5.2. If position points to the sequence of bytes 0x2D 0x2D 0x0D 0x0A
@@ -127,7 +127,7 @@ function multipartFormDataParser (input, mimeType) {
127
127
  // 5.3. If position does not point to a sequence of bytes starting with 0x0D
128
128
  // 0x0A (CR LF), return failure.
129
129
  if (input[position.position] !== 0x0d || input[position.position + 1] !== 0x0a) {
130
- return 'failure'
130
+ throw parsingError('expected CRLF')
131
131
  }
132
132
 
133
133
  // 5.4. Advance position by 2. (This skips past the newline.)
@@ -138,10 +138,6 @@ function multipartFormDataParser (input, mimeType) {
138
138
  // is not failure. Otherwise, return failure.
139
139
  const result = parseMultipartFormDataHeaders(input, position)
140
140
 
141
- if (result === 'failure') {
142
- return 'failure'
143
- }
144
-
145
141
  let { name, filename, contentType, encoding } = result
146
142
 
147
143
  // 5.6. Advance position by 2. (This skips past the empty line that marks
@@ -157,7 +153,7 @@ function multipartFormDataParser (input, mimeType) {
157
153
  const boundaryIndex = input.indexOf(boundary.subarray(2), position.position)
158
154
 
159
155
  if (boundaryIndex === -1) {
160
- return 'failure'
156
+ throw parsingError('expected boundary after body')
161
157
  }
162
158
 
163
159
  body = input.subarray(position.position, boundaryIndex - 4)
@@ -174,7 +170,7 @@ function multipartFormDataParser (input, mimeType) {
174
170
  // 5.9. If position does not point to a sequence of bytes starting with
175
171
  // 0x0D 0x0A (CR LF), return failure. Otherwise, advance position by 2.
176
172
  if (input[position.position] !== 0x0d || input[position.position + 1] !== 0x0a) {
177
- return 'failure'
173
+ throw parsingError('expected CRLF')
178
174
  } else {
179
175
  position.position += 2
180
176
  }
@@ -230,7 +226,7 @@ function parseMultipartFormDataHeaders (input, position) {
230
226
  if (input[position.position] === 0x0d && input[position.position + 1] === 0x0a) {
231
227
  // 2.1.1. If name is null, return failure.
232
228
  if (name === null) {
233
- return 'failure'
229
+ throw parsingError('header name is null')
234
230
  }
235
231
 
236
232
  // 2.1.2. Return name, filename and contentType.
@@ -250,12 +246,12 @@ function parseMultipartFormDataHeaders (input, position) {
250
246
 
251
247
  // 2.4. If header name does not match the field-name token production, return failure.
252
248
  if (!HTTP_TOKEN_CODEPOINTS.test(headerName.toString())) {
253
- return 'failure'
249
+ throw parsingError('header name does not match the field-name token production')
254
250
  }
255
251
 
256
252
  // 2.5. If the byte at position is not 0x3A (:), return failure.
257
253
  if (input[position.position] !== 0x3a) {
258
- return 'failure'
254
+ throw parsingError('expected :')
259
255
  }
260
256
 
261
257
  // 2.6. Advance position by 1.
@@ -278,7 +274,7 @@ function parseMultipartFormDataHeaders (input, position) {
278
274
  // 2. If position does not point to a sequence of bytes starting with
279
275
  // `form-data; name="`, return failure.
280
276
  if (!bufferStartsWith(input, formDataNameBuffer, position)) {
281
- return 'failure'
277
+ throw parsingError('expected form-data; name=" for content-disposition header')
282
278
  }
283
279
 
284
280
  // 3. Advance position so it points at the byte after the next 0x22 (")
@@ -290,34 +286,61 @@ function parseMultipartFormDataHeaders (input, position) {
290
286
  // failure.
291
287
  name = parseMultipartFormDataName(input, position)
292
288
 
293
- if (name === null) {
294
- return 'failure'
295
- }
296
-
297
289
  // 5. If position points to a sequence of bytes starting with `; filename="`:
298
- if (bufferStartsWith(input, filenameBuffer, position)) {
299
- // Note: undici also handles filename*
300
- let check = position.position + filenameBuffer.length
301
-
302
- if (input[check] === 0x2a) {
303
- position.position += 1
304
- check += 1
305
- }
306
-
307
- if (input[check] !== 0x3d || input[check + 1] !== 0x22) { // ="
308
- return 'failure'
309
- }
310
-
311
- // 1. Advance position so it points at the byte after the next 0x22 (") byte
312
- // (the one in the sequence of bytes matched above).
313
- position.position += 12
314
-
315
- // 2. Set filename to the result of parsing a multipart/form-data name given
316
- // input and position, if the result is not failure. Otherwise, return failure.
317
- filename = parseMultipartFormDataName(input, position)
318
-
319
- if (filename === null) {
320
- return 'failure'
290
+ if (input[position.position] === 0x3b /* ; */ && input[position.position + 1] === 0x20 /* ' ' */) {
291
+ const at = { position: position.position + 2 }
292
+
293
+ if (bufferStartsWith(input, filenameBuffer, at)) {
294
+ if (input[at.position + 8] === 0x2a /* '*' */) {
295
+ at.position += 10 // skip past filename*=
296
+
297
+ // Remove leading http tab and spaces. See RFC for examples.
298
+ // https://datatracker.ietf.org/doc/html/rfc6266#section-5
299
+ collectASequenceOfBytes(
300
+ (char) => char === 0x20 || char === 0x09,
301
+ input,
302
+ at
303
+ )
304
+
305
+ const headerValue = collectASequenceOfBytes(
306
+ (char) => char !== 0x20 && char !== 0x0d && char !== 0x0a, // ' ' or CRLF
307
+ input,
308
+ at
309
+ )
310
+
311
+ if (
312
+ (headerValue[0] !== 0x75 && headerValue[0] !== 0x55) || // u or U
313
+ (headerValue[1] !== 0x74 && headerValue[1] !== 0x54) || // t or T
314
+ (headerValue[2] !== 0x66 && headerValue[2] !== 0x46) || // f or F
315
+ headerValue[3] !== 0x2d || // -
316
+ headerValue[4] !== 0x38 // 8
317
+ ) {
318
+ throw parsingError('unknown encoding, expected utf-8\'\'')
319
+ }
320
+
321
+ // skip utf-8''
322
+ filename = decodeURIComponent(new TextDecoder().decode(headerValue.subarray(7)))
323
+
324
+ position.position = at.position
325
+ } else {
326
+ // 1. Advance position so it points at the byte after the next 0x22 (") byte
327
+ // (the one in the sequence of bytes matched above).
328
+ position.position += 11
329
+
330
+ // Remove leading http tab and spaces. See RFC for examples.
331
+ // https://datatracker.ietf.org/doc/html/rfc6266#section-5
332
+ collectASequenceOfBytes(
333
+ (char) => char === 0x20 || char === 0x09,
334
+ input,
335
+ position
336
+ )
337
+
338
+ position.position++ // skip past " after removing whitespace
339
+
340
+ // 2. Set filename to the result of parsing a multipart/form-data name given
341
+ // input and position, if the result is not failure. Otherwise, return failure.
342
+ filename = parseMultipartFormDataName(input, position)
343
+ }
321
344
  }
322
345
  }
323
346
 
@@ -367,7 +390,7 @@ function parseMultipartFormDataHeaders (input, position) {
367
390
  // 2.9. If position does not point to a sequence of bytes starting with 0x0D 0x0A
368
391
  // (CR LF), return failure. Otherwise, advance position by 2 (past the newline).
369
392
  if (input[position.position] !== 0x0d && input[position.position + 1] !== 0x0a) {
370
- return 'failure'
393
+ throw parsingError('expected CRLF')
371
394
  } else {
372
395
  position.position += 2
373
396
  }
@@ -393,7 +416,7 @@ function parseMultipartFormDataName (input, position) {
393
416
 
394
417
  // 3. If the byte at position is not 0x22 ("), return failure. Otherwise, advance position by 1.
395
418
  if (input[position.position] !== 0x22) {
396
- return null // name could be 'failure'
419
+ throw parsingError('expected "')
397
420
  } else {
398
421
  position.position++
399
422
  }
@@ -468,6 +491,10 @@ function bufferStartsWith (buffer, start, position) {
468
491
  return true
469
492
  }
470
493
 
494
+ function parsingError (cause) {
495
+ return new TypeError('Failed to parse body as FormData.', { cause: new TypeError(cause) })
496
+ }
497
+
471
498
  module.exports = {
472
499
  multipartFormDataParser,
473
500
  validateBoundary
@@ -14,6 +14,8 @@ class FormData {
14
14
  #state = []
15
15
 
16
16
  constructor (form) {
17
+ webidl.util.markAsUncloneable(this)
18
+
17
19
  if (form !== undefined) {
18
20
  throw webidl.errors.conversionFailed({
19
21
  prefix: 'FormData constructor',
@@ -256,6 +258,6 @@ function makeEntry (name, value, filename) {
256
258
  return { name, value }
257
259
  }
258
260
 
259
- webidl.is.FormData = webidl.util.MakeTypeAssertion(FormData.prototype)
261
+ webidl.is.FormData = webidl.util.MakeTypeAssertion(FormData)
260
262
 
261
263
  module.exports = { FormData, makeEntry, setFormDataState }
@@ -436,6 +436,8 @@ class Headers {
436
436
  * @returns
437
437
  */
438
438
  constructor (init = undefined) {
439
+ webidl.util.markAsUncloneable(this)
440
+
439
441
  if (init === kConstruct) {
440
442
  return
441
443
  }
@@ -449,7 +451,7 @@ class Headers {
449
451
 
450
452
  // 2. If init is given, then fill this with init.
451
453
  if (init !== undefined) {
452
- init = webidl.converters.HeadersInit(init, 'Headers contructor', 'init')
454
+ init = webidl.converters.HeadersInit(init, 'Headers constructor', 'init')
453
455
  fill(this, init)
454
456
  }
455
457
  }
@@ -1943,8 +1943,10 @@ async function httpNetworkFetch (
1943
1943
  // 19. Run these steps in parallel:
1944
1944
 
1945
1945
  // 1. Run these steps, but abort when fetchParams is canceled:
1946
- fetchParams.controller.onAborted = onAborted
1947
- fetchParams.controller.on('terminated', onAborted)
1946
+ if (!fetchParams.controller.resume) {
1947
+ fetchParams.controller.on('terminated', onAborted)
1948
+ }
1949
+
1948
1950
  fetchParams.controller.resume = async () => {
1949
1951
  // 1. While true
1950
1952
  while (true) {
@@ -2205,10 +2207,6 @@ async function httpNetworkFetch (
2205
2207
  fetchParams.controller.off('terminated', this.abort)
2206
2208
  }
2207
2209
 
2208
- if (fetchParams.controller.onAborted) {
2209
- fetchParams.controller.off('terminated', fetchParams.controller.onAborted)
2210
- }
2211
-
2212
2210
  fetchParams.controller.ended = true
2213
2211
 
2214
2212
  this.body.push(null)
@@ -92,6 +92,8 @@ class Request {
92
92
 
93
93
  // https://fetch.spec.whatwg.org/#dom-request
94
94
  constructor (input, init = undefined) {
95
+ webidl.util.markAsUncloneable(this)
96
+
95
97
  if (input === kConstruct) {
96
98
  return
97
99
  }
@@ -986,7 +988,7 @@ Object.defineProperties(Request.prototype, {
986
988
  }
987
989
  })
988
990
 
989
- webidl.is.Request = webidl.util.MakeTypeAssertion(Request.prototype)
991
+ webidl.is.Request = webidl.util.MakeTypeAssertion(Request)
990
992
 
991
993
  // https://fetch.spec.whatwg.org/#requestinfo
992
994
  webidl.converters.RequestInfo = function (V, prefix, argument) {
@@ -112,6 +112,8 @@ class Response {
112
112
 
113
113
  // https://fetch.spec.whatwg.org/#dom-response
114
114
  constructor (body = null, init = undefined) {
115
+ webidl.util.markAsUncloneable(this)
116
+
115
117
  if (body === kConstruct) {
116
118
  return
117
119
  }
@@ -619,7 +621,7 @@ webidl.converters.ResponseInit = webidl.dictionaryConverter([
619
621
  }
620
622
  ])
621
623
 
622
- webidl.is.Response = webidl.util.MakeTypeAssertion(Response.prototype)
624
+ webidl.is.Response = webidl.util.MakeTypeAssertion(Response)
623
625
 
624
626
  module.exports = {
625
627
  isNetworkError,