undici 5.0.0 → 5.1.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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![Node CI](https://github.com/nodejs/undici/actions/workflows/nodejs.yml/badge.svg)](https://github.com/nodejs/undici/actions/workflows/nodejs.yml) [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat)](http://standardjs.com/) [![npm version](https://badge.fury.io/js/undici.svg)](https://badge.fury.io/js/undici) [![codecov](https://codecov.io/gh/nodejs/undici/branch/main/graph/badge.svg?token=yZL6LtXkOA)](https://codecov.io/gh/nodejs/undici)
4
4
 
5
- A HTTP/1.1 client, written from scratch for Node.js.
5
+ An HTTP/1.1 client, written from scratch for Node.js.
6
6
 
7
7
  > Undici means eleven in Italian. 1.1 -> 11 -> Eleven -> Undici.
8
8
  It is also a Stranger Things reference.
@@ -65,7 +65,15 @@ for await (const data of body) {
65
65
  console.log('trailers', trailers)
66
66
  ```
67
67
 
68
- Using [the body mixin from the Fetch Standard](https://fetch.spec.whatwg.org/#body-mixin).
68
+ ## Body Mixins
69
+
70
+ The `body` mixins are the most common way to format the request/response body. Mixins include:
71
+
72
+ - [`.formData()`](https://fetch.spec.whatwg.org/#dom-body-formdata)
73
+ - [`.json()`](https://fetch.spec.whatwg.org/#dom-body-json)
74
+ - [`.text()`](https://fetch.spec.whatwg.org/#dom-body-text)
75
+
76
+ Example usage:
69
77
 
70
78
  ```js
71
79
  import { request } from 'undici'
@@ -83,6 +91,12 @@ console.log('data', await body.json())
83
91
  console.log('trailers', trailers)
84
92
  ```
85
93
 
94
+ _Note: Once a mixin has been called then the body cannot be reused, thus calling additional mixins on `.body`, e.g. `.body.json(); .body.text()` will result in an error `TypeError: unusable` being thrown and returned through the `Promise` rejection._
95
+
96
+ Should you need to access the `body` in plain-text after using a mixin, the best practice is to use the `.text()` mixin first and then manually parse the text to the desired format.
97
+
98
+ For more information about their behavior, please reference the body mixin from the [Fetch Standard](https://fetch.spec.whatwg.org/#body-mixin).
99
+
86
100
  ## Common API Methods
87
101
 
88
102
  This section documents our most commonly used API methods. Additional APIs are documented in their own files within the [docs](./docs/) folder and are accessible via the navigation list on the left side of the docs site.
@@ -213,7 +227,7 @@ const data = {
213
227
 
214
228
  #### `response.body`
215
229
 
216
- Nodejs has two kinds of streams: [web streams](https://nodejs.org/dist/latest-v16.x/docs/api/webstreams.html) which follow the API of the WHATWG web standard found in browsers, and an older Node-specific [streams API](https://nodejs.org/api/stream.html). `response.body` returns a readable web stream. If you would prefer to work with a Node stream you can convert a web stream using `.fromWeb()`.
230
+ Nodejs has two kinds of streams: [web streams](https://nodejs.org/dist/latest-v16.x/docs/api/webstreams.html), which follow the API of the WHATWG web standard found in browsers, and an older Node-specific [streams API](https://nodejs.org/api/stream.html). `response.body` returns a readable web stream. If you would prefer to work with a Node stream you can convert a web stream using `.fromWeb()`.
217
231
 
218
232
  ```js
219
233
  import {fetch} from 'undici';
@@ -228,7 +242,7 @@ Nodejs has two kinds of streams: [web streams](https://nodejs.org/dist/latest-v1
228
242
 
229
243
  #### Specification Compliance
230
244
 
231
- This section documents parts of the [Fetch Standard](https://fetch.spec.whatwg.org) which Undici does
245
+ This section documents parts of the [Fetch Standard](https://fetch.spec.whatwg.org) that Undici does
232
246
  not support or does not fully implement.
233
247
 
234
248
  ##### Garbage Collection
@@ -239,7 +253,7 @@ The [Fetch Standard](https://fetch.spec.whatwg.org) allows users to skip consumi
239
253
  [garbage collection](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_Management#garbage_collection) to release connection resources. Undici does not do the same. Therefore, it is important to always either consume or cancel the response body.
240
254
 
241
255
  Garbage collection in Node is less aggressive and deterministic
242
- (due to the lack of clear idle periods that browser have through the rendering refresh rate)
256
+ (due to the lack of clear idle periods that browsers have through the rendering refresh rate)
243
257
  which means that leaving the release of connection resources to the garbage collector can lead
244
258
  to excessive connection usage, reduced performance (due to less connection re-use), and even
245
259
  stalls or deadlocks when running out of connections.
@@ -301,7 +315,7 @@ Returns: `Dispatcher`
301
315
 
302
316
  ## Specification Compliance
303
317
 
304
- This section documents parts of the HTTP/1.1 specification which Undici does
318
+ This section documents parts of the HTTP/1.1 specification that Undici does
305
319
  not support or does not fully implement.
306
320
 
307
321
  ### Expect
@@ -334,7 +348,7 @@ aborted.
334
348
 
335
349
  ### Manual Redirect
336
350
 
337
- Since it is not possible to manually follow an HTTP redirect on server-side,
351
+ Since it is not possible to manually follow an HTTP redirect on the server-side,
338
352
  Undici returns the actual response instead of an `opaqueredirect` filtered one
339
353
  when invoked with a `manual` redirect. This aligns `fetch()` with the other
340
354
  implementations in Deno and Cloudflare Workers.
@@ -193,7 +193,7 @@ Returns: `Boolean` - `false` if dispatcher is busy and further dispatch calls wo
193
193
  * **path** `string`
194
194
  * **method** `string`
195
195
  * **body** `string | Buffer | Uint8Array | stream.Readable | Iterable | AsyncIterable | null` (optional) - Default: `null`
196
- * **headers** `UndiciHeaders` (optional) - Default: `null`
196
+ * **headers** `UndiciHeaders | string[]` (optional) - Default: `null`.
197
197
  * **idempotent** `boolean` (optional) - Default: `true` if `method` is `'HEAD'` or `'GET'` - Whether the requests can be safely retried or not. If `false` the request won't be sent until all preceding requests in the pipeline has completed.
198
198
  * **blocking** `boolean` (optional) - Default: `false` - Whether the response is expected to take a long time and would end up blocking the pipeline. When this is set to `true` further pipelining will be avoided on the same connection until headers have been received.
199
199
  * **upgrade** `string | null` (optional) - Default: `null` - Upgrade the request. Should be used to specify the kind of upgrade i.e. `'Websocket'`.
@@ -445,3 +445,79 @@ mockAgent.disableNetConnect()
445
445
  await request('http://example.com')
446
446
  // Will throw
447
447
  ```
448
+
449
+ ### `MockAgent.pendingInterceptors()`
450
+
451
+ This method returns any pending interceptors registered on a mock agent. A pending interceptor meets one of the following criteria:
452
+
453
+ - Is registered with neither `.times(<number>)` nor `.persist()`, and has not been invoked;
454
+ - Is persistent (i.e., registered with `.persist()`) and has not been invoked;
455
+ - Is registered with `.times(<number>)` and has not been invoked `<number>` of times.
456
+
457
+ Returns: `PendingInterceptor[]` (where `PendingInterceptor` is a `MockDispatch` with an additional `origin: string`)
458
+
459
+ #### Example - List all pending inteceptors
460
+
461
+ ```js
462
+ const agent = new MockAgent()
463
+ agent.disableNetConnect()
464
+
465
+ agent
466
+ .get('https://example.com')
467
+ .intercept({ method: 'GET', path: '/' })
468
+ .reply(200, '')
469
+
470
+ const pendingInterceptors = agent.pendingInterceptors()
471
+ // Returns [
472
+ // {
473
+ // timesInvoked: 0,
474
+ // times: 1,
475
+ // persist: false,
476
+ // consumed: false,
477
+ // pending: true,
478
+ // path: '/',
479
+ // method: 'GET',
480
+ // body: undefined,
481
+ // headers: undefined,
482
+ // data: {
483
+ // error: null,
484
+ // statusCode: 200,
485
+ // data: '',
486
+ // headers: {},
487
+ // trailers: {}
488
+ // },
489
+ // origin: 'https://example.com'
490
+ // }
491
+ // ]
492
+ ```
493
+
494
+ ### `MockAgent.assertNoPendingInterceptors([options])`
495
+
496
+ This method throws if the mock agent has any pending interceptors. A pending interceptor meets one of the following criteria:
497
+
498
+ - Is registered with neither `.times(<number>)` nor `.persist()`, and has not been invoked;
499
+ - Is persistent (i.e., registered with `.persist()`) and has not been invoked;
500
+ - Is registered with `.times(<number>)` and has not been invoked `<number>` of times.
501
+
502
+ #### Example - Check that there are no pending interceptors
503
+
504
+ ```js
505
+ const agent = new MockAgent()
506
+ agent.disableNetConnect()
507
+
508
+ agent
509
+ .get('https://example.com')
510
+ .intercept({ method: 'GET', path: '/' })
511
+ .reply(200, '')
512
+
513
+ agent.assertNoPendingInterceptors()
514
+ // Throws an UndiciError with the following message:
515
+ //
516
+ // 1 interceptor is pending:
517
+ //
518
+ // ┌─────────┬────────┬───────────────────────┬──────┬─────────────┬────────────┬─────────────┬───────────┐
519
+ // │ (index) │ Method │ Origin │ Path │ Status code │ Persistent │ Invocations │ Remaining │
520
+ // ├─────────┼────────┼───────────────────────┼──────┼─────────────┼────────────┼─────────────┼───────────┤
521
+ // │ 0 │ 'GET' │ 'https://example.com' │ '/' │ 200 │ '❌' │ 0 │ 1 │
522
+ // └─────────┴────────┴───────────────────────┴──────┴─────────────┴────────────┴─────────────┴───────────┘
523
+ ```
@@ -5,17 +5,20 @@ Undici have its own mocking [utility](../api/MockAgent.md). It allow us to inter
5
5
  Example:
6
6
 
7
7
  ```js
8
- // index.mjs
8
+ // bank.mjs
9
9
  import { request } from 'undici'
10
10
 
11
- export async function bankTransfer(recepient, ammount) {
12
- const { body } = await request('http://localhost:3000/bank-transfer',
11
+ export async function bankTransfer(recepient, amount) {
12
+ const { body } = await request('http://localhost:3000/bank-transfer',
13
13
  {
14
14
  method: 'POST',
15
15
  headers: {
16
16
  'X-TOKEN-SECRET': 'SuperSecretToken',
17
17
  },
18
- body: JSON.stringify({ recepient })
18
+ body: JSON.stringify({
19
+ recepient,
20
+ amount
21
+ })
19
22
  }
20
23
  )
21
24
  return await body.json()
@@ -28,7 +31,7 @@ And this is what the test file looks like:
28
31
  // index.test.mjs
29
32
  import { strict as assert } from 'assert'
30
33
  import { MockAgent, setGlobalDispatcher, } from 'undici'
31
- import { bankTransfer } from './undici.mjs'
34
+ import { bankTransfer } from './bank.mjs'
32
35
 
33
36
  const mockAgent = new MockAgent();
34
37
 
@@ -46,7 +49,7 @@ mockPool.intercept({
46
49
  },
47
50
  body: JSON.stringify({
48
51
  recepient: '1234567890',
49
- ammount: '100'
52
+ amount: '100'
50
53
  })
51
54
  }).reply(200, {
52
55
  message: 'transaction processed'
@@ -94,7 +97,7 @@ mockPool.intercept({
94
97
 
95
98
  const badRequest = await bankTransfer('1234567890', '100')
96
99
  // Will throw an error
97
- // MockNotMatchedError: Mock dispatch not matched for path '/bank-transfer':
100
+ // MockNotMatchedError: Mock dispatch not matched for path '/bank-transfer':
98
101
  // subsequent request to origin http://localhost:3000 was not allowed (net.connect disabled)
99
102
  ```
100
103
 
package/index.d.ts CHANGED
@@ -16,6 +16,7 @@ import { request, pipeline, stream, connect, upgrade } from './types/api'
16
16
  export * from './types/fetch'
17
17
  export * from './types/file'
18
18
  export * from './types/formdata'
19
+ export { Interceptable } from './types/mock-interceptor'
19
20
 
20
21
  export { Dispatcher, BalancedPool, Pool, Client, buildConnector, errors, Agent, request, stream, pipeline, connect, upgrade, setGlobalDispatcher, getGlobalDispatcher, MockClient, MockPool, MockAgent, mockErrors, ProxyAgent }
21
22
  export default Undici
@@ -88,14 +88,16 @@ class RequestHandler extends AsyncResource {
88
88
  this.res = body
89
89
  const headers = this.responseHeaders === 'raw' ? util.parseRawHeaders(rawHeaders) : util.parseHeaders(rawHeaders)
90
90
 
91
- this.runInAsyncScope(callback, null, null, {
92
- statusCode,
93
- headers,
94
- trailers: this.trailers,
95
- opaque,
96
- body,
97
- context
98
- })
91
+ if (callback !== null) {
92
+ this.runInAsyncScope(callback, null, null, {
93
+ statusCode,
94
+ headers,
95
+ trailers: this.trailers,
96
+ opaque,
97
+ body,
98
+ context
99
+ })
100
+ }
99
101
  }
100
102
 
101
103
  onData (chunk) {
@@ -11,6 +11,12 @@ const kHandler = Symbol('handler')
11
11
 
12
12
  const channels = {}
13
13
 
14
+ let extractBody
15
+
16
+ const nodeVersion = process.versions.node.split('.')
17
+ const nodeMajor = Number(nodeVersion[0])
18
+ const nodeMinor = Number(nodeVersion[1])
19
+
14
20
  try {
15
21
  const diagnosticsChannel = require('diagnostics_channel')
16
22
  channels.create = diagnosticsChannel.channel('undici:request:create')
@@ -79,7 +85,7 @@ class Request {
79
85
  this.body = body.byteLength ? body : null
80
86
  } else if (typeof body === 'string') {
81
87
  this.body = body.length ? Buffer.from(body) : null
82
- } else if (util.isIterable(body) || util.isBlobLike(body)) {
88
+ } else if (util.isFormDataLike(body) || util.isIterable(body) || util.isBlobLike(body)) {
83
89
  this.body = body
84
90
  } else {
85
91
  throw new InvalidArgumentError('body must be a string, a Buffer, a Readable stream, an iterable, or an async iterable')
@@ -126,7 +132,22 @@ class Request {
126
132
  throw new InvalidArgumentError('headers must be an object or an array')
127
133
  }
128
134
 
129
- if (util.isBlobLike(body) && this.contentType == null && body.type) {
135
+ if (util.isFormDataLike(this.body)) {
136
+ if (nodeMajor < 16 || (nodeMajor === 16 && nodeMinor < 5)) {
137
+ throw new InvalidArgumentError('Form-Data bodies are only supported in node v16.5 and newer.')
138
+ }
139
+
140
+ if (!extractBody) {
141
+ extractBody = require('../fetch/body.js').extractBody
142
+ }
143
+
144
+ const [bodyStream, contentType] = extractBody(body)
145
+ if (this.contentType == null) {
146
+ this.contentType = contentType
147
+ this.headers += `content-type: ${contentType}\r\n`
148
+ }
149
+ this.body = bodyStream.stream
150
+ } else if (util.isBlobLike(body) && this.contentType == null && body.type) {
130
151
  this.contentType = body.type
131
152
  this.headers += `content-type: ${body.type}\r\n`
132
153
  }
package/lib/core/util.js CHANGED
@@ -324,6 +324,10 @@ function ReadableStreamFrom (iterable) {
324
324
  )
325
325
  }
326
326
 
327
+ function isFormDataLike (chunk) {
328
+ return chunk && chunk.constructor && chunk.constructor.name === 'FormData'
329
+ }
330
+
327
331
  const kEnumerableProperty = Object.create(null)
328
332
  kEnumerableProperty.enumerable = true
329
333
 
@@ -352,5 +356,6 @@ module.exports = {
352
356
  ReadableStreamFrom,
353
357
  isBuffer,
354
358
  validateHandler,
355
- getSocketInfo
359
+ getSocketInfo,
360
+ isFormDataLike
356
361
  }
package/lib/fetch/body.js CHANGED
@@ -71,7 +71,7 @@ function extractBody (object, keepalive = false) {
71
71
 
72
72
  // Set source to a copy of the bytes held by object.
73
73
  source = new Uint8Array(object)
74
- } else if (object instanceof FormData) {
74
+ } else if (util.isFormDataLike(object)) {
75
75
  const boundary = '----formdata-undici-' + Math.random()
76
76
  const prefix = `--${boundary}\r\nContent-Disposition: form-data`
77
77
 
@@ -348,7 +348,7 @@ const properties = {
348
348
  bodyUsed: {
349
349
  enumerable: true,
350
350
  get () {
351
- return this[kState].body && util.isDisturbed(this[kState].body.stream)
351
+ return !!this[kState].body && util.isDisturbed(this[kState].body.stream)
352
352
  }
353
353
  }
354
354
  }
@@ -11,22 +11,8 @@ const {
11
11
  forbiddenResponseHeaderNames
12
12
  } = require('./constants')
13
13
 
14
- function binarySearch (arr, val) {
15
- let low = 0
16
- let high = Math.floor(arr.length / 2)
17
-
18
- while (high > low) {
19
- const mid = (high + low) >>> 1
20
-
21
- if (val.localeCompare(arr[mid * 2]) > 0) {
22
- low = mid + 1
23
- } else {
24
- high = mid
25
- }
26
- }
27
-
28
- return low * 2
29
- }
14
+ const kHeadersMap = Symbol('headers map')
15
+ const kHeadersSortedMap = Symbol('headers map sorted')
30
16
 
31
17
  function normalizeAndValidateHeaderName (name) {
32
18
  if (name === undefined) {
@@ -91,64 +77,74 @@ function fill (headers, object) {
91
77
  }
92
78
  }
93
79
 
94
- // TODO: Composition over inheritence? Or helper methods?
95
- class HeadersList extends Array {
80
+ class HeadersList {
81
+ constructor (init) {
82
+ if (init instanceof HeadersList) {
83
+ this[kHeadersMap] = new Map(init[kHeadersMap])
84
+ this[kHeadersSortedMap] = init[kHeadersSortedMap]
85
+ } else {
86
+ this[kHeadersMap] = new Map(init)
87
+ this[kHeadersSortedMap] = null
88
+ }
89
+ }
90
+
96
91
  append (name, value) {
92
+ this[kHeadersSortedMap] = null
93
+
97
94
  const normalizedName = normalizeAndValidateHeaderName(name)
98
95
  const normalizedValue = normalizeAndValidateHeaderValue(name, value)
99
96
 
100
- const index = binarySearch(this, normalizedName)
97
+ const exists = this[kHeadersMap].get(normalizedName)
101
98
 
102
- if (this[index] === normalizedName) {
103
- this[index + 1] += `, ${normalizedValue}`
99
+ if (exists) {
100
+ this[kHeadersMap].set(normalizedName, `${exists}, ${normalizedValue}`)
104
101
  } else {
105
- this.splice(index, 0, normalizedName, normalizedValue)
102
+ this[kHeadersMap].set(normalizedName, `${normalizedValue}`)
106
103
  }
107
104
  }
108
105
 
109
- delete (name) {
106
+ set (name, value) {
107
+ this[kHeadersSortedMap] = null
108
+
110
109
  const normalizedName = normalizeAndValidateHeaderName(name)
110
+ return this[kHeadersMap].set(normalizedName, value)
111
+ }
111
112
 
112
- const index = binarySearch(this, normalizedName)
113
+ delete (name) {
114
+ this[kHeadersSortedMap] = null
113
115
 
114
- if (this[index] === normalizedName) {
115
- this.splice(index, 2)
116
- }
116
+ const normalizedName = normalizeAndValidateHeaderName(name)
117
+ return this[kHeadersMap].delete(normalizedName)
117
118
  }
118
119
 
119
120
  get (name) {
120
121
  const normalizedName = normalizeAndValidateHeaderName(name)
121
-
122
- const index = binarySearch(this, normalizedName)
123
-
124
- if (this[index] === normalizedName) {
125
- return this[index + 1]
126
- }
127
-
128
- return null
122
+ return this[kHeadersMap].get(normalizedName) ?? null
129
123
  }
130
124
 
131
125
  has (name) {
132
126
  const normalizedName = normalizeAndValidateHeaderName(name)
127
+ return this[kHeadersMap].has(normalizedName)
128
+ }
133
129
 
134
- const index = binarySearch(this, normalizedName)
130
+ keys () {
131
+ return this[kHeadersMap].keys()
132
+ }
135
133
 
136
- return this[index] === normalizedName
134
+ values () {
135
+ return this[kHeadersMap].values()
137
136
  }
138
137
 
139
- set (name, value) {
140
- const normalizedName = normalizeAndValidateHeaderName(name)
141
- const normalizedValue = normalizeAndValidateHeaderValue(name, value)
138
+ entries () {
139
+ return this[kHeadersMap].entries()
140
+ }
142
141
 
143
- const index = binarySearch(this, normalizedName)
144
- if (this[index] === normalizedName) {
145
- this[index + 1] = normalizedValue
146
- } else {
147
- this.splice(index, 0, normalizedName, normalizedValue)
148
- }
142
+ [Symbol.iterator] () {
143
+ return this[kHeadersMap][Symbol.iterator]()
149
144
  }
150
145
  }
151
146
 
147
+ // https://fetch.spec.whatwg.org/#headers-class
152
148
  class Headers {
153
149
  constructor (...args) {
154
150
  if (
@@ -161,7 +157,6 @@ class Headers {
161
157
  )
162
158
  }
163
159
  const init = args.length >= 1 ? args[0] ?? {} : {}
164
-
165
160
  this[kHeadersList] = new HeadersList()
166
161
 
167
162
  // The new Headers(init) constructor steps are:
@@ -287,20 +282,18 @@ class Headers {
287
282
  )
288
283
  }
289
284
 
290
- const normalizedName = normalizeAndValidateHeaderName(String(args[0]))
291
-
292
285
  if (this[kGuard] === 'immutable') {
293
286
  throw new TypeError('immutable')
294
287
  } else if (
295
288
  this[kGuard] === 'request' &&
296
- forbiddenHeaderNames.includes(normalizedName)
289
+ forbiddenHeaderNames.includes(String(args[0]).toLocaleLowerCase())
297
290
  ) {
298
291
  return
299
292
  } else if (this[kGuard] === 'request-no-cors') {
300
293
  // TODO
301
294
  } else if (
302
295
  this[kGuard] === 'response' &&
303
- forbiddenResponseHeaderNames.includes(normalizedName)
296
+ forbiddenResponseHeaderNames.includes(String(args[0]).toLocaleLowerCase())
304
297
  ) {
305
298
  return
306
299
  }
@@ -308,25 +301,41 @@ class Headers {
308
301
  return this[kHeadersList].set(String(args[0]), String(args[1]))
309
302
  }
310
303
 
311
- * keys () {
312
- const clone = this[kHeadersList].slice()
313
- for (let index = 0; index < clone.length; index += 2) {
314
- yield clone[index]
304
+ get [kHeadersSortedMap] () {
305
+ this[kHeadersList][kHeadersSortedMap] ??= new Map([...this[kHeadersList]].sort((a, b) => a[0] < b[0] ? -1 : 1))
306
+ return this[kHeadersList][kHeadersSortedMap]
307
+ }
308
+
309
+ keys () {
310
+ if (!(this instanceof Headers)) {
311
+ throw new TypeError('Illegal invocation')
315
312
  }
313
+
314
+ return this[kHeadersSortedMap].keys()
316
315
  }
317
316
 
318
- * values () {
319
- const clone = this[kHeadersList].slice()
320
- for (let index = 1; index < clone.length; index += 2) {
321
- yield clone[index]
317
+ values () {
318
+ if (!(this instanceof Headers)) {
319
+ throw new TypeError('Illegal invocation')
322
320
  }
321
+
322
+ return this[kHeadersSortedMap].values()
323
323
  }
324
324
 
325
- * entries () {
326
- const clone = this[kHeadersList].slice()
327
- for (let index = 0; index < clone.length; index += 2) {
328
- yield [clone[index], clone[index + 1]]
325
+ entries () {
326
+ if (!(this instanceof Headers)) {
327
+ throw new TypeError('Illegal invocation')
329
328
  }
329
+
330
+ return this[kHeadersSortedMap].entries()
331
+ }
332
+
333
+ [Symbol.iterator] () {
334
+ if (!(this instanceof Headers)) {
335
+ throw new TypeError('Illegal invocation')
336
+ }
337
+
338
+ return this[kHeadersSortedMap]
330
339
  }
331
340
 
332
341
  forEach (...args) {
@@ -346,15 +355,9 @@ class Headers {
346
355
  const callback = args[0]
347
356
  const thisArg = args[1]
348
357
 
349
- const clone = this[kHeadersList].slice()
350
- for (let index = 0; index < clone.length; index += 2) {
351
- callback.call(
352
- thisArg,
353
- clone[index + 1],
354
- clone[index],
355
- this
356
- )
357
- }
358
+ this[kHeadersSortedMap].forEach((value, index) => {
359
+ callback.apply(thisArg, [value, index, this])
360
+ })
358
361
  }
359
362
 
360
363
  [Symbol.for('nodejs.util.inspect.custom')] () {
@@ -384,7 +387,6 @@ module.exports = {
384
387
  fill,
385
388
  Headers,
386
389
  HeadersList,
387
- binarySearch,
388
390
  normalizeAndValidateHeaderName,
389
391
  normalizeAndValidateHeaderValue
390
392
  }
@@ -768,7 +768,7 @@ async function schemeFetch (fetchParams) {
768
768
  const {
769
769
  protocol: scheme,
770
770
  pathname: path
771
- } = new URL(requestCurrentURL(request))
771
+ } = requestCurrentURL(request)
772
772
 
773
773
  // switch on request’s current URL’s scheme, and run the associated steps:
774
774
  switch (scheme) {
@@ -780,7 +780,7 @@ async function schemeFetch (fetchParams) {
780
780
  const resp = makeResponse({
781
781
  statusText: 'OK',
782
782
  headersList: [
783
- 'content-type', 'text/html;charset=utf-8'
783
+ ['content-type', 'text/html;charset=utf-8']
784
784
  ]
785
785
  })
786
786
 
@@ -792,7 +792,7 @@ async function schemeFetch (fetchParams) {
792
792
  return makeNetworkError('invalid path called')
793
793
  }
794
794
  case 'blob:': {
795
- resolveObjectURL ??= require('buffer').resolveObjectURL
795
+ resolveObjectURL = resolveObjectURL || require('buffer').resolveObjectURL
796
796
 
797
797
  // 1. Run these steps, but abort when the ongoing fetch is terminated:
798
798
  // 1. Let blob be request’s current URL’s blob URL entry’s object.
@@ -871,7 +871,7 @@ async function schemeFetch (fetchParams) {
871
871
  return makeResponse({
872
872
  statusText: 'OK',
873
873
  headersList: [
874
- 'content-type', contentType
874
+ ['content-type', contentType]
875
875
  ],
876
876
  body: extractBody(dataURLStruct.body)[0]
877
877
  })
@@ -1919,8 +1919,10 @@ async function httpNetworkFetch (
1919
1919
  origin: url.origin,
1920
1920
  method: request.method,
1921
1921
  body: fetchParams.controller.dispatcher[kIsMockActive] ? request.body && request.body.source : body,
1922
- headers: request.headersList,
1923
- maxRedirections: 0
1922
+ headers: [...request.headersList].flat(),
1923
+ maxRedirections: 0,
1924
+ bodyTimeout: 300_000,
1925
+ headersTimeout: 300_000
1924
1926
  },
1925
1927
  {
1926
1928
  body: null,
@@ -1962,16 +1964,18 @@ async function httpNetworkFetch (
1962
1964
  const decoders = []
1963
1965
 
1964
1966
  // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding
1965
- for (const coding of codings) {
1966
- if (/(x-)?gzip/.test(coding)) {
1967
- decoders.push(zlib.createGunzip())
1968
- } else if (/(x-)?deflate/.test(coding)) {
1969
- decoders.push(zlib.createInflate())
1970
- } else if (coding === 'br') {
1971
- decoders.push(zlib.createBrotliDecompress())
1972
- } else {
1973
- decoders.length = 0
1974
- break
1967
+ if (request.method !== 'HEAD' && request.method !== 'CONNECT' && !nullBodyStatus.includes(status)) {
1968
+ for (const coding of codings) {
1969
+ if (/(x-)?gzip/.test(coding)) {
1970
+ decoders.push(zlib.createGunzip())
1971
+ } else if (/(x-)?deflate/.test(coding)) {
1972
+ decoders.push(zlib.createInflate())
1973
+ } else if (coding === 'br') {
1974
+ decoders.push(zlib.createBrotliDecompress())
1975
+ } else {
1976
+ decoders.length = 0
1977
+ break
1978
+ }
1975
1979
  }
1976
1980
  }
1977
1981
 
@@ -2029,7 +2033,7 @@ async function httpNetworkFetch (
2029
2033
 
2030
2034
  fetchParams.controller.terminate(error)
2031
2035
 
2032
- reject(makeNetworkError(error))
2036
+ reject(error)
2033
2037
  }
2034
2038
  }
2035
2039
  ))
@@ -420,7 +420,15 @@ class Request {
420
420
  // 4. If headers is a Headers object, then for each header in its header
421
421
  // list, append header’s name/header’s value to this’s headers.
422
422
  if (headers instanceof Headers) {
423
- this[kState].headersList.push(...headers[kHeadersList])
423
+ // TODO (fix): Why doesn't this work?
424
+ // for (const [key, val] of headers[kHeadersList]) {
425
+ // this[kHeaders].append(key, val)
426
+ // }
427
+
428
+ this[kState].headersList = new HeadersList([
429
+ ...this[kState].headersList,
430
+ ...headers[kHeadersList]
431
+ ])
424
432
  } else {
425
433
  // 5. Otherwise, fill this’s headers with headers.
426
434
  fillHeaders(this[kState].headersList, headers)
@@ -460,6 +468,7 @@ class Request {
460
468
  // this’s headers.
461
469
  if (contentType && !this[kHeaders].has('content-type')) {
462
470
  this[kHeaders].append('content-type', contentType)
471
+ this[kState].headersList.append('content-type', contentType)
463
472
  }
464
473
  }
465
474
 
@@ -793,9 +802,8 @@ function makeRequest (init) {
793
802
  timingAllowFailed: false,
794
803
  ...init,
795
804
  headersList: init.headersList
796
- ? new HeadersList(...init.headersList)
797
- : new HeadersList(),
798
- urlList: init.urlList ? [...init.urlList.map((url) => new URL(url))] : []
805
+ ? new HeadersList(init.headersList)
806
+ : new HeadersList()
799
807
  }
800
808
  request.url = request.urlList[0]
801
809
  return request
@@ -81,7 +81,7 @@ class Response {
81
81
  const value = parsedURL.toString()
82
82
 
83
83
  // 7. Append `Location`/value to responseObject’s response’s header list.
84
- responseObject[kState].headersList.push('location', value)
84
+ responseObject[kState].headersList.append('location', value)
85
85
 
86
86
  // 8. Return responseObject.
87
87
  return responseObject
@@ -172,7 +172,7 @@ class Response {
172
172
  // not contain `Content-Type`, then append `Content-Type`/Content-Type
173
173
  // to this’s response’s header list.
174
174
  if (contentType && !this.headers.has('content-type')) {
175
- this.headers.set('content-type', contentType)
175
+ this.headers.append('content-type', contentType)
176
176
  }
177
177
  }
178
178
  }
@@ -350,7 +350,7 @@ function makeResponse (init) {
350
350
  statusText: '',
351
351
  ...init,
352
352
  headersList: init.headersList
353
- ? new HeadersList(...init.headersList)
353
+ ? new HeadersList(init.headersList)
354
354
  : new HeadersList(),
355
355
  urlList: init.urlList ? [...init.urlList] : []
356
356
  }
@@ -393,17 +393,15 @@ function makeFilteredHeadersList (headersList, filter) {
393
393
  get (target, prop) {
394
394
  // Override methods used by Headers class.
395
395
  if (prop === 'get' || prop === 'has') {
396
- return (name) => filter(name) ? target[prop](name) : undefined
397
- } else if (prop === 'slice') {
398
- return (...args) => {
399
- assert(args.length === 0)
400
- const arr = []
401
- for (let index = 0; index < target.length; index += 2) {
402
- if (filter(target[index])) {
403
- arr.push(target[index], target[index + 1])
396
+ const defaultReturn = prop === 'has' ? false : null
397
+ return (name) => filter(name) ? target[prop](name) : defaultReturn
398
+ } else if (prop === Symbol.iterator) {
399
+ return function * () {
400
+ for (const entry of target) {
401
+ if (filter(entry[0])) {
402
+ yield entry
404
403
  }
405
404
  }
406
- return arr
407
405
  }
408
406
  } else {
409
407
  return target[prop]
@@ -423,7 +421,10 @@ function filterResponse (response, type) {
423
421
 
424
422
  return makeFilteredResponse(response, {
425
423
  type: 'basic',
426
- headersList: makeFilteredHeadersList(response.headersList, (name) => !forbiddenResponseHeaderNames.includes(name))
424
+ headersList: makeFilteredHeadersList(
425
+ response.headersList,
426
+ (name) => !forbiddenResponseHeaderNames.includes(name.toLowerCase())
427
+ )
427
428
  })
428
429
  } else if (type === 'cors') {
429
430
  // A CORS filtered response is a filtered response whose type is "cors"
package/lib/fetch/util.js CHANGED
@@ -318,7 +318,42 @@ function sameOrigin (A, B) {
318
318
 
319
319
  // https://fetch.spec.whatwg.org/#corb-check
320
320
  function CORBCheck (request, response) {
321
- // TODO
321
+ // 1. If request’s initiator is "download", then return allowed.
322
+ if (request.initiator === 'download') {
323
+ return 'allowed'
324
+ }
325
+
326
+ // 2. If request’s current URL’s scheme is not an HTTP(S) scheme, then return allowed.
327
+ if (!/^https?$/.test(request.currentURL.scheme)) {
328
+ return 'allowed'
329
+ }
330
+
331
+ // 3. Let mimeType be the result of extracting a MIME type from response’s header list.
332
+ const mimeType = response.headersList.get('content-type')
333
+
334
+ // 4. If mimeType is failure, then return allowed.
335
+ if (mimeType === '') {
336
+ return 'allowed'
337
+ }
338
+
339
+ // 5. If response’s status is 206 and mimeType is a CORB-protected MIME type, then return blocked.
340
+
341
+ const isCORBProtectedMIME =
342
+ (/^text\/html\b/.test(mimeType) ||
343
+ /^application\/javascript\b/.test(mimeType) ||
344
+ /^application\/xml\b/.test(mimeType)) && !/^application\/xml\+svg\b/.test(mimeType)
345
+
346
+ if (response.status === 206 && isCORBProtectedMIME) {
347
+ return 'blocked'
348
+ }
349
+
350
+ // 6. If determine nosniff with response’s header list is true and mimeType is a CORB-protected MIME type or its essence is "text/plain", then return blocked.
351
+ // https://fetch.spec.whatwg.org/#determinenosniff
352
+ if (response.headersList.get('x-content-type-options') && isCORBProtectedMIME) {
353
+ return 'blocked'
354
+ }
355
+
356
+ // 7. Return allowed.
322
357
  return 'allowed'
323
358
  }
324
359
 
@@ -16,8 +16,10 @@ const {
16
16
  const MockClient = require('./mock-client')
17
17
  const MockPool = require('./mock-pool')
18
18
  const { matchValue, buildMockOptions } = require('./mock-utils')
19
- const { InvalidArgumentError } = require('../core/errors')
19
+ const { InvalidArgumentError, UndiciError } = require('../core/errors')
20
20
  const Dispatcher = require('../dispatcher')
21
+ const Pluralizer = require('./pluralizer')
22
+ const PendingInterceptorsFormatter = require('./pending-interceptors-formatter')
21
23
 
22
24
  class FakeWeakRef {
23
25
  constructor (value) {
@@ -134,6 +136,30 @@ class MockAgent extends Dispatcher {
134
136
  [kGetNetConnect] () {
135
137
  return this[kNetConnect]
136
138
  }
139
+
140
+ pendingInterceptors () {
141
+ const mockAgentClients = this[kClients]
142
+
143
+ return Array.from(mockAgentClients.entries())
144
+ .flatMap(([origin, scope]) => scope.deref()[kDispatches].map(dispatch => ({ ...dispatch, origin })))
145
+ .filter(({ pending }) => pending)
146
+ }
147
+
148
+ assertNoPendingInterceptors ({ pendingInterceptorsFormatter = new PendingInterceptorsFormatter() } = {}) {
149
+ const pending = this.pendingInterceptors()
150
+
151
+ if (pending.length === 0) {
152
+ return
153
+ }
154
+
155
+ const pluralizer = new Pluralizer('interceptor', 'interceptors').pluralize(pending.length)
156
+
157
+ throw new UndiciError(`
158
+ ${pluralizer.count} ${pluralizer.noun} ${pluralizer.is} pending:
159
+
160
+ ${pendingInterceptorsFormatter.format(pending)}
161
+ `.trim())
162
+ }
137
163
  }
138
164
 
139
165
  module.exports = MockAgent
@@ -12,7 +12,7 @@ const {
12
12
  const { InvalidArgumentError } = require('../core/errors')
13
13
 
14
14
  /**
15
- * Defines the scope API for a interceptor reply
15
+ * Defines the scope API for an interceptor reply
16
16
  */
17
17
  class MockScope {
18
18
  constructor (mockDispatch) {
@@ -74,6 +74,9 @@ class MockInterceptor {
74
74
  const parsedURL = new URL(opts.path, 'data://')
75
75
  opts.path = parsedURL.pathname + parsedURL.search
76
76
  }
77
+ if (typeof opts.method === 'string') {
78
+ opts.method = opts.method.toUpperCase()
79
+ }
77
80
 
78
81
  this[kDispatchKey] = buildKey(opts)
79
82
  this[kDispatches] = mockDispatches
@@ -107,9 +107,9 @@ function getMockDispatch (mockDispatches, key) {
107
107
  }
108
108
 
109
109
  function addMockDispatch (mockDispatches, key, data) {
110
- const baseData = { times: null, persist: false, consumed: false }
110
+ const baseData = { timesInvoked: 0, times: 1, persist: false, consumed: false }
111
111
  const replyData = typeof data === 'function' ? { callback: data } : { ...data }
112
- const newMockDispatch = { ...baseData, ...key, data: { error: null, ...replyData } }
112
+ const newMockDispatch = { ...baseData, ...key, pending: true, data: { error: null, ...replyData } }
113
113
  mockDispatches.push(newMockDispatch)
114
114
  return newMockDispatch
115
115
  }
@@ -140,6 +140,80 @@ function generateKeyValues (data) {
140
140
  return Object.entries(data).reduce((keyValuePairs, [key, value]) => [...keyValuePairs, key, value], [])
141
141
  }
142
142
 
143
+ /**
144
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status
145
+ * @param {number} statusCode
146
+ */
147
+ function getStatusText (statusCode) {
148
+ switch (statusCode) {
149
+ case 100: return 'Continue'
150
+ case 101: return 'Switching Protocols'
151
+ case 102: return 'Processing'
152
+ case 103: return 'Early Hints'
153
+ case 200: return 'OK'
154
+ case 201: return 'Created'
155
+ case 202: return 'Accepted'
156
+ case 203: return 'Non-Authoritative Information'
157
+ case 204: return 'No Content'
158
+ case 205: return 'Reset Content'
159
+ case 206: return 'Partial Content'
160
+ case 207: return 'Multi-Status'
161
+ case 208: return 'Already Reported'
162
+ case 226: return 'IM Used'
163
+ case 300: return 'Multiple Choice'
164
+ case 301: return 'Moved Permanently'
165
+ case 302: return 'Found'
166
+ case 303: return 'See Other'
167
+ case 304: return 'Not Modified'
168
+ case 305: return 'Use Proxy'
169
+ case 306: return 'unused'
170
+ case 307: return 'Temporary Redirect'
171
+ case 308: return 'Permanent Redirect'
172
+ case 400: return 'Bad Request'
173
+ case 401: return 'Unauthorized'
174
+ case 402: return 'Payment Required'
175
+ case 403: return 'Forbidden'
176
+ case 404: return 'Not Found'
177
+ case 405: return 'Method Not Allowed'
178
+ case 406: return 'Not Acceptable'
179
+ case 407: return 'Proxy Authentication Required'
180
+ case 408: return 'Request Timeout'
181
+ case 409: return 'Conflict'
182
+ case 410: return 'Gone'
183
+ case 411: return 'Length Required'
184
+ case 412: return 'Precondition Failed'
185
+ case 413: return 'Payload Too Large'
186
+ case 414: return 'URI Too Large'
187
+ case 415: return 'Unsupported Media Type'
188
+ case 416: return 'Range Not Satisfiable'
189
+ case 417: return 'Expectation Failed'
190
+ case 418: return 'I\'m a teapot'
191
+ case 421: return 'Misdirected Request'
192
+ case 422: return 'Unprocessable Entity'
193
+ case 423: return 'Locked'
194
+ case 424: return 'Failed Dependency'
195
+ case 425: return 'Too Early'
196
+ case 426: return 'Upgrade Required'
197
+ case 428: return 'Precondition Required'
198
+ case 429: return 'Too Many Requests'
199
+ case 431: return 'Request Header Fields Too Large'
200
+ case 451: return 'Unavailable For Legal Reasons'
201
+ case 500: return 'Internal Server Error'
202
+ case 501: return 'Not Implemented'
203
+ case 502: return 'Bad Gateway'
204
+ case 503: return 'Service Unavailable'
205
+ case 504: return 'Gateway Timeout'
206
+ case 505: return 'HTTP Version Not Supported'
207
+ case 506: return 'Variant Also Negotiates'
208
+ case 507: return 'Insufficient Storage'
209
+ case 508: return 'Loop Detected'
210
+ case 510: return 'Not Extended'
211
+ case 511: return 'Network Authentication Required'
212
+ default:
213
+ throw new ReferenceError(`Unknown status code "${statusCode}"!`)
214
+ }
215
+ }
216
+
143
217
  async function getResponse (body) {
144
218
  const buffers = []
145
219
  for await (const data of body) {
@@ -156,6 +230,8 @@ function mockDispatch (opts, handler) {
156
230
  const key = buildKey(opts)
157
231
  const mockDispatch = getMockDispatch(this[kDispatches], key)
158
232
 
233
+ mockDispatch.timesInvoked++
234
+
159
235
  // Here's where we resolve a callback if a callback is present for the dispatch data.
160
236
  if (mockDispatch.data.callback) {
161
237
  mockDispatch.data = { ...mockDispatch.data, ...mockDispatch.data.callback(opts) }
@@ -163,18 +239,11 @@ function mockDispatch (opts, handler) {
163
239
 
164
240
  // Parse mockDispatch data
165
241
  const { data: { statusCode, data, headers, trailers, error }, delay, persist } = mockDispatch
166
- let { times } = mockDispatch
167
- if (typeof times === 'number' && times > 0) {
168
- times = --mockDispatch.times
169
- }
242
+ const { timesInvoked, times } = mockDispatch
170
243
 
171
- // If persist is true, skip
172
- // Or if times is a number and > 0, skip
173
- // Otherwise, mark as consumed
174
-
175
- if (!(persist === true || (typeof times === 'number' && times > 0))) {
176
- mockDispatch.consumed = true
177
- }
244
+ // If it's used up and not persistent, mark as consumed
245
+ mockDispatch.consumed = !persist && timesInvoked >= times
246
+ mockDispatch.pending = timesInvoked < times
178
247
 
179
248
  // If specified, trigger dispatch error
180
249
  if (error !== null) {
@@ -197,7 +266,7 @@ function mockDispatch (opts, handler) {
197
266
  const responseHeaders = generateKeyValues(headers)
198
267
  const responseTrailers = generateKeyValues(trailers)
199
268
 
200
- handler.onHeaders(statusCode, responseHeaders, resume)
269
+ handler.onHeaders(statusCode, responseHeaders, resume, getStatusText(statusCode))
201
270
  handler.onData(Buffer.from(responseData))
202
271
  handler.onComplete(responseTrailers)
203
272
  deleteMockDispatch(mockDispatches, key)
@@ -264,6 +333,7 @@ module.exports = {
264
333
  generateKeyValues,
265
334
  matchValue,
266
335
  getResponse,
336
+ getStatusText,
267
337
  mockDispatch,
268
338
  buildMockDispatch,
269
339
  checkNetConnect,
@@ -0,0 +1,40 @@
1
+ 'use strict'
2
+
3
+ const { Transform } = require('stream')
4
+ const { Console } = require('console')
5
+
6
+ /**
7
+ * Gets the output of `console.table(…)` as a string.
8
+ */
9
+ module.exports = class PendingInterceptorsFormatter {
10
+ constructor ({ disableColors } = {}) {
11
+ this.transform = new Transform({
12
+ transform (chunk, _enc, cb) {
13
+ cb(null, chunk)
14
+ }
15
+ })
16
+
17
+ this.logger = new Console({
18
+ stdout: this.transform,
19
+ inspectOptions: {
20
+ colors: !disableColors && !process.env.CI
21
+ }
22
+ })
23
+ }
24
+
25
+ format (pendingInterceptors) {
26
+ const withPrettyHeaders = pendingInterceptors.map(
27
+ ({ method, path, data: { statusCode }, persist, times, timesInvoked, origin }) => ({
28
+ Method: method,
29
+ Origin: origin,
30
+ Path: path,
31
+ 'Status code': statusCode,
32
+ Persistent: persist ? '✅' : '❌',
33
+ Invocations: timesInvoked,
34
+ Remaining: persist ? Infinity : times - timesInvoked
35
+ }))
36
+
37
+ this.logger.table(withPrettyHeaders)
38
+ return this.transform.read().toString()
39
+ }
40
+ }
@@ -0,0 +1,29 @@
1
+ 'use strict'
2
+
3
+ const singulars = {
4
+ pronoun: 'it',
5
+ is: 'is',
6
+ was: 'was',
7
+ this: 'this'
8
+ }
9
+
10
+ const plurals = {
11
+ pronoun: 'they',
12
+ is: 'are',
13
+ was: 'were',
14
+ this: 'these'
15
+ }
16
+
17
+ module.exports = class Pluralizer {
18
+ constructor (singular, plural) {
19
+ this.singular = singular
20
+ this.plural = plural
21
+ }
22
+
23
+ pluralize (count) {
24
+ const one = count === 1
25
+ const keys = one ? singulars : plurals
26
+ const noun = one ? this.singular : this.plural
27
+ return { ...keys, count, noun }
28
+ }
29
+ }
@@ -23,7 +23,7 @@ class ProxyAgent extends DispatcherBase {
23
23
  origin: this[kProxy].uri,
24
24
  path: opts.origin + opts.path,
25
25
  headers: {
26
- ...opts.headers,
26
+ ...buildHeaders(opts.headers),
27
27
  host
28
28
  }
29
29
  },
@@ -55,4 +55,25 @@ function buildProxyOptions (opts) {
55
55
  }
56
56
  }
57
57
 
58
+ /**
59
+ * @param {string[] | Record<string, string>} headers
60
+ * @returns {Record<string, string>}
61
+ */
62
+ function buildHeaders (headers) {
63
+ // When using undici.fetch, the headers list is stored
64
+ // as an array.
65
+ if (Array.isArray(headers)) {
66
+ /** @type {Record<string, string>} */
67
+ const headersPair = {}
68
+
69
+ for (let i = 0; i < headers.length; i += 2) {
70
+ headersPair[headers[i]] = headers[i + 1]
71
+ }
72
+
73
+ return headersPair
74
+ }
75
+
76
+ return headers
77
+ }
78
+
58
79
  module.exports = ProxyAgent
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "undici",
3
- "version": "5.0.0",
3
+ "version": "5.1.0",
4
4
  "description": "An HTTP/1.1 client, written from scratch for Node.js",
5
5
  "homepage": "https://undici.nodejs.org",
6
6
  "bugs": {
@@ -40,7 +40,7 @@
40
40
  "docs"
41
41
  ],
42
42
  "scripts": {
43
- "build:node": "npx esbuild@0.14.25 index.js --bundle --platform=node --outfile=undici.js",
43
+ "build:node": "npx esbuild@0.14.38 index-fetch.js --bundle --platform=node --outfile=undici-fetch.js",
44
44
  "prebuild:wasm": "docker build -t llhttp_wasm_builder -f build/Dockerfile .",
45
45
  "build:wasm": "node build/wasm.js --docker",
46
46
  "lint": "standard | snazzy",
@@ -63,22 +63,22 @@
63
63
  "fuzz": "jsfuzz test/fuzzing/fuzz.js corpus"
64
64
  },
65
65
  "devDependencies": {
66
- "@sinonjs/fake-timers": "^7.0.5",
67
- "@types/node": "^16.9.1",
66
+ "@sinonjs/fake-timers": "^9.1.2",
67
+ "@types/node": "^17.0.29",
68
68
  "abort-controller": "^3.0.0",
69
69
  "busboy": "^0.3.1",
70
70
  "chai": "^4.3.4",
71
71
  "chai-as-promised": "^7.1.1",
72
72
  "chai-iterator": "^3.0.2",
73
73
  "chai-string": "^1.5.0",
74
- "concurrently": "^6.2.1",
74
+ "concurrently": "^7.1.0",
75
75
  "cronometro": "^0.8.0",
76
76
  "delay": "^5.0.0",
77
77
  "docsify-cli": "^4.4.3",
78
78
  "formdata-node": "^4.3.1",
79
79
  "https-pem": "^2.0.0",
80
80
  "husky": "^7.0.2",
81
- "jest": "^27.2.0",
81
+ "jest": "^28.0.1",
82
82
  "jsfuzz": "^1.0.15",
83
83
  "mocha": "^9.1.1",
84
84
  "p-timeout": "^3.2.0",
@@ -86,11 +86,11 @@
86
86
  "proxy": "^1.0.2",
87
87
  "proxyquire": "^2.1.3",
88
88
  "semver": "^7.3.5",
89
- "sinon": "^11.1.2",
89
+ "sinon": "^13.0.2",
90
90
  "snazzy": "^9.0.0",
91
- "standard": "^16.0.3",
92
- "tap": "^15.0.9",
93
- "tsd": "^0.17.0",
91
+ "standard": "^17.0.0",
92
+ "tap": "^16.1.0",
93
+ "tsd": "^0.20.0",
94
94
  "wait-on": "^6.0.0"
95
95
  },
96
96
  "engines": {
@@ -4,6 +4,7 @@ import { EventEmitter } from 'events'
4
4
  import { IncomingHttpHeaders } from 'http'
5
5
  import { Blob } from 'buffer'
6
6
  import BodyReadable from './readable'
7
+ import { FormData } from './formdata'
7
8
 
8
9
  type AbortSignal = unknown;
9
10
 
@@ -43,7 +44,7 @@ declare namespace Dispatcher {
43
44
  path: string;
44
45
  method: HttpMethod;
45
46
  /** Default: `null` */
46
- body?: string | Buffer | Uint8Array | Readable | null;
47
+ body?: string | Buffer | Uint8Array | Readable | null | FormData;
47
48
  /** Default: `null` */
48
49
  headers?: IncomingHttpHeaders | string[] | null;
49
50
  /** Whether the requests can be safely retried or not. If `false` the request won't be sent until all preceding requests in the pipeline have completed. Default: `true` if `method` is `HEAD` or `GET`. */
package/types/fetch.d.ts CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { Blob } from 'buffer'
6
6
  import { URL, URLSearchParams } from 'url'
7
+ import { ReadableStream } from 'stream/web'
7
8
  import { FormData } from './formdata'
8
9
 
9
10
  export type RequestInfo = string | URL | Request
@@ -13,13 +14,6 @@ export declare function fetch (
13
14
  init?: RequestInit
14
15
  ): Promise<Response>
15
16
 
16
- declare class ControlledAsyncIterable implements AsyncIterable<Uint8Array> {
17
- constructor (input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>)
18
- data: AsyncIterable<Uint8Array>
19
- disturbed: boolean
20
- readonly [Symbol.asyncIterator]: () => AsyncIterator<Uint8Array>
21
- }
22
-
23
17
  export type BodyInit =
24
18
  | ArrayBuffer
25
19
  | AsyncIterable<Uint8Array>
@@ -32,7 +26,7 @@ export type BodyInit =
32
26
  | string
33
27
 
34
28
  export interface BodyMixin {
35
- readonly body: ControlledAsyncIterable | null
29
+ readonly body: ReadableStream | null
36
30
  readonly bodyUsed: boolean
37
31
 
38
32
  readonly arrayBuffer: () => Promise<ArrayBuffer>
@@ -139,7 +133,7 @@ export declare class Request implements BodyMixin {
139
133
  readonly keepalive: boolean
140
134
  readonly signal: AbortSignal
141
135
 
142
- readonly body: ControlledAsyncIterable | null
136
+ readonly body: ReadableStream | null
143
137
  readonly bodyUsed: boolean
144
138
 
145
139
  readonly arrayBuffer: () => Promise<ArrayBuffer>
@@ -178,7 +172,7 @@ export declare class Response implements BodyMixin {
178
172
  readonly url: string
179
173
  readonly redirected: boolean
180
174
 
181
- readonly body: ControlledAsyncIterable | null
175
+ readonly body: ReadableStream | null
182
176
  readonly bodyUsed: boolean
183
177
 
184
178
  readonly arrayBuffer: () => Promise<ArrayBuffer>
@@ -1,9 +1,14 @@
1
1
  import Agent = require('./agent')
2
2
  import Dispatcher = require('./dispatcher')
3
- import { Interceptable } from './mock-interceptor'
3
+ import { Interceptable, MockInterceptor } from './mock-interceptor'
4
+ import MockDispatch = MockInterceptor.MockDispatch;
4
5
 
5
6
  export = MockAgent
6
7
 
8
+ interface PendingInterceptor extends MockDispatch {
9
+ origin: string;
10
+ }
11
+
7
12
  /** A mocked Agent class that implements the Agent API. It allows one to intercept HTTP requests made through undici and return mocked responses instead. */
8
13
  declare class MockAgent<TMockAgentOptions extends MockAgent.Options = MockAgent.Options> extends Dispatcher {
9
14
  constructor(options?: MockAgent.Options)
@@ -26,6 +31,14 @@ declare class MockAgent<TMockAgentOptions extends MockAgent.Options = MockAgent.
26
31
  enableNetConnect(host: ((host: string) => boolean)): void;
27
32
  /** Causes all requests to throw when requests are not matched in a MockAgent intercept. */
28
33
  disableNetConnect(): void;
34
+ pendingInterceptors(): PendingInterceptor[];
35
+ assertNoPendingInterceptors(options?: {
36
+ pendingInterceptorsFormatter?: PendingInterceptorsFormatter;
37
+ }): void;
38
+ }
39
+
40
+ interface PendingInterceptorsFormatter {
41
+ format(pendingInterceptors: readonly PendingInterceptor[]): string;
29
42
  }
30
43
 
31
44
  declare namespace MockAgent {
@@ -1,6 +1,6 @@
1
1
  import { IncomingHttpHeaders } from 'http'
2
2
  import Dispatcher from './dispatcher';
3
- import { Headers } from './fetch'
3
+ import { BodyInit, Headers } from './fetch'
4
4
 
5
5
  export {
6
6
  Interceptable,
@@ -71,7 +71,7 @@ declare namespace MockInterceptor {
71
71
  path: string;
72
72
  origin: string;
73
73
  method: string;
74
- body?: string;
74
+ body?: BodyInit | Dispatcher.DispatchOptions['body'];
75
75
  headers: Headers;
76
76
  maxRedirections: number;
77
77
  }