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 +21 -7
- package/docs/api/Dispatcher.md +1 -1
- package/docs/api/MockAgent.md +76 -0
- package/docs/best-practices/mocking-request.md +10 -7
- package/index.d.ts +1 -0
- package/lib/api/api-request.js +10 -8
- package/lib/core/request.js +23 -2
- package/lib/core/util.js +6 -1
- package/lib/fetch/body.js +2 -2
- package/lib/fetch/headers.js +75 -73
- package/lib/fetch/index.js +21 -17
- package/lib/fetch/request.js +12 -4
- package/lib/fetch/response.js +14 -13
- package/lib/fetch/util.js +36 -1
- package/lib/mock/mock-agent.js +27 -1
- package/lib/mock/mock-interceptor.js +4 -1
- package/lib/mock/mock-utils.js +84 -14
- package/lib/mock/pending-interceptors-formatter.js +40 -0
- package/lib/mock/pluralizer.js +29 -0
- package/lib/proxy-agent.js +22 -1
- package/package.json +10 -10
- package/types/dispatcher.d.ts +2 -1
- package/types/fetch.d.ts +4 -10
- package/types/mock-agent.d.ts +14 -1
- package/types/mock-interceptor.d.ts +2 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/nodejs/undici/actions/workflows/nodejs.yml) [](http://standardjs.com/) [](https://badge.fury.io/js/undici) [](https://codecov.io/gh/nodejs/undici)
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
|
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.
|
package/docs/api/Dispatcher.md
CHANGED
|
@@ -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'`.
|
package/docs/api/MockAgent.md
CHANGED
|
@@ -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
|
-
//
|
|
8
|
+
// bank.mjs
|
|
9
9
|
import { request } from 'undici'
|
|
10
10
|
|
|
11
|
-
export async function bankTransfer(recepient,
|
|
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({
|
|
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 './
|
|
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
|
-
|
|
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
|
package/lib/api/api-request.js
CHANGED
|
@@ -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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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) {
|
package/lib/core/request.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
}
|
package/lib/fetch/headers.js
CHANGED
|
@@ -11,22 +11,8 @@ const {
|
|
|
11
11
|
forbiddenResponseHeaderNames
|
|
12
12
|
} = require('./constants')
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
|
97
|
+
const exists = this[kHeadersMap].get(normalizedName)
|
|
101
98
|
|
|
102
|
-
if (
|
|
103
|
-
this[
|
|
99
|
+
if (exists) {
|
|
100
|
+
this[kHeadersMap].set(normalizedName, `${exists}, ${normalizedValue}`)
|
|
104
101
|
} else {
|
|
105
|
-
this.
|
|
102
|
+
this[kHeadersMap].set(normalizedName, `${normalizedValue}`)
|
|
106
103
|
}
|
|
107
104
|
}
|
|
108
105
|
|
|
109
|
-
|
|
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
|
-
|
|
113
|
+
delete (name) {
|
|
114
|
+
this[kHeadersSortedMap] = null
|
|
113
115
|
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
130
|
+
keys () {
|
|
131
|
+
return this[kHeadersMap].keys()
|
|
132
|
+
}
|
|
135
133
|
|
|
136
|
-
|
|
134
|
+
values () {
|
|
135
|
+
return this[kHeadersMap].values()
|
|
137
136
|
}
|
|
138
137
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
138
|
+
entries () {
|
|
139
|
+
return this[kHeadersMap].entries()
|
|
140
|
+
}
|
|
142
141
|
|
|
143
|
-
|
|
144
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
}
|
package/lib/fetch/index.js
CHANGED
|
@@ -768,7 +768,7 @@ async function schemeFetch (fetchParams) {
|
|
|
768
768
|
const {
|
|
769
769
|
protocol: scheme,
|
|
770
770
|
pathname: path
|
|
771
|
-
} =
|
|
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
|
|
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
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
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(
|
|
2036
|
+
reject(error)
|
|
2033
2037
|
}
|
|
2034
2038
|
}
|
|
2035
2039
|
))
|
package/lib/fetch/request.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
package/lib/fetch/response.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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(
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
|
|
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(
|
|
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
|
-
//
|
|
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
|
|
package/lib/mock/mock-agent.js
CHANGED
|
@@ -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
|
|
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
|
package/lib/mock/mock-utils.js
CHANGED
|
@@ -107,9 +107,9 @@ function getMockDispatch (mockDispatches, key) {
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
function addMockDispatch (mockDispatches, key, data) {
|
|
110
|
-
const baseData = { times:
|
|
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
|
-
|
|
167
|
-
if (typeof times === 'number' && times > 0) {
|
|
168
|
-
times = --mockDispatch.times
|
|
169
|
-
}
|
|
242
|
+
const { timesInvoked, times } = mockDispatch
|
|
170
243
|
|
|
171
|
-
// If
|
|
172
|
-
|
|
173
|
-
|
|
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
|
+
}
|
package/lib/proxy-agent.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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": "^
|
|
67
|
-
"@types/node": "^
|
|
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": "^
|
|
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": "^
|
|
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": "^
|
|
89
|
+
"sinon": "^13.0.2",
|
|
90
90
|
"snazzy": "^9.0.0",
|
|
91
|
-
"standard": "^
|
|
92
|
-
"tap": "^
|
|
93
|
-
"tsd": "^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": {
|
package/types/dispatcher.d.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
175
|
+
readonly body: ReadableStream | null
|
|
182
176
|
readonly bodyUsed: boolean
|
|
183
177
|
|
|
184
178
|
readonly arrayBuffer: () => Promise<ArrayBuffer>
|
package/types/mock-agent.d.ts
CHANGED
|
@@ -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?:
|
|
74
|
+
body?: BodyInit | Dispatcher.DispatchOptions['body'];
|
|
75
75
|
headers: Headers;
|
|
76
76
|
maxRedirections: number;
|
|
77
77
|
}
|