undici 5.7.0 → 5.8.2
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/docs/api/MockAgent.md +17 -0
- package/docs/best-practices/mocking-request.md +9 -9
- package/docs/best-practices/proxy.md +8 -7
- package/index.js +6 -1
- package/lib/balanced-pool.js +81 -4
- package/lib/core/connect.js +28 -5
- package/lib/core/request.js +31 -1
- package/lib/core/util.js +14 -3
- package/lib/fetch/body.js +28 -1
- package/lib/fetch/dataURL.js +1 -1
- package/lib/fetch/index.js +11 -7
- package/lib/fetch/request.js +5 -5
- package/lib/fetch/response.js +8 -7
- package/lib/fetch/util.js +9 -1
- package/lib/fetch/webidl.js +9 -5
- package/lib/handler/redirect.js +2 -1
- package/lib/mock/mock-utils.js +22 -10
- package/package.json +2 -1
- package/types/mock-interceptor.d.ts +1 -1
- package/types/pool-stats.d.ts +19 -0
- package/types/pool.d.ts +4 -0
package/docs/api/MockAgent.md
CHANGED
|
@@ -177,6 +177,23 @@ for await (const data of result2.body) {
|
|
|
177
177
|
console.log('data', data.toString('utf8')) // data hello
|
|
178
178
|
}
|
|
179
179
|
```
|
|
180
|
+
#### Example - Mock different requests within the same file
|
|
181
|
+
```js
|
|
182
|
+
const { MockAgent, setGlobalDispatcher } = require('undici');
|
|
183
|
+
const agent = new MockAgent();
|
|
184
|
+
agent.disableNetConnect();
|
|
185
|
+
setGlobalDispatcher(agent);
|
|
186
|
+
describe('Test', () => {
|
|
187
|
+
it('200', async () => {
|
|
188
|
+
const mockAgent = agent.get('http://test.com');
|
|
189
|
+
// your test
|
|
190
|
+
});
|
|
191
|
+
it('200', async () => {
|
|
192
|
+
const mockAgent = agent.get('http://testing.com');
|
|
193
|
+
// your test
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
```
|
|
180
197
|
|
|
181
198
|
#### Example - Mocked request with query body, headers and trailers
|
|
182
199
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Mocking Request
|
|
2
2
|
|
|
3
|
-
Undici
|
|
3
|
+
Undici has its own mocking [utility](../api/MockAgent.md). It allow us to intercept undici HTTP requests and return mocked values instead. It can be useful for testing purposes.
|
|
4
4
|
|
|
5
5
|
Example:
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@ Example:
|
|
|
8
8
|
// bank.mjs
|
|
9
9
|
import { request } from 'undici'
|
|
10
10
|
|
|
11
|
-
export async function bankTransfer(
|
|
11
|
+
export async function bankTransfer(recipient, amount) {
|
|
12
12
|
const { body } = await request('http://localhost:3000/bank-transfer',
|
|
13
13
|
{
|
|
14
14
|
method: 'POST',
|
|
@@ -16,7 +16,7 @@ export async function bankTransfer(recepient, amount) {
|
|
|
16
16
|
'X-TOKEN-SECRET': 'SuperSecretToken',
|
|
17
17
|
},
|
|
18
18
|
body: JSON.stringify({
|
|
19
|
-
|
|
19
|
+
recipient,
|
|
20
20
|
amount
|
|
21
21
|
})
|
|
22
22
|
}
|
|
@@ -48,7 +48,7 @@ mockPool.intercept({
|
|
|
48
48
|
'X-TOKEN-SECRET': 'SuperSecretToken',
|
|
49
49
|
},
|
|
50
50
|
body: JSON.stringify({
|
|
51
|
-
|
|
51
|
+
recipient: '1234567890',
|
|
52
52
|
amount: '100'
|
|
53
53
|
})
|
|
54
54
|
}).reply(200, {
|
|
@@ -77,7 +77,7 @@ Explore other MockAgent functionality [here](../api/MockAgent.md)
|
|
|
77
77
|
|
|
78
78
|
## Debug Mock Value
|
|
79
79
|
|
|
80
|
-
When the interceptor
|
|
80
|
+
When the interceptor and the request options are not the same, undici will automatically make a real HTTP request. To prevent real requests from being made, use `mockAgent.disableNetConnect()`:
|
|
81
81
|
|
|
82
82
|
```js
|
|
83
83
|
const mockAgent = new MockAgent();
|
|
@@ -89,7 +89,7 @@ mockAgent.disableNetConnect()
|
|
|
89
89
|
const mockPool = mockAgent.get('http://localhost:3000');
|
|
90
90
|
|
|
91
91
|
mockPool.intercept({
|
|
92
|
-
path: '/bank-
|
|
92
|
+
path: '/bank-transfer',
|
|
93
93
|
method: 'POST',
|
|
94
94
|
}).reply(200, {
|
|
95
95
|
message: 'transaction processed'
|
|
@@ -103,7 +103,7 @@ const badRequest = await bankTransfer('1234567890', '100')
|
|
|
103
103
|
|
|
104
104
|
## Reply with data based on request
|
|
105
105
|
|
|
106
|
-
If the mocked response needs to be dynamically derived from the request parameters, you can provide a function instead of an object to `reply
|
|
106
|
+
If the mocked response needs to be dynamically derived from the request parameters, you can provide a function instead of an object to `reply`:
|
|
107
107
|
|
|
108
108
|
```js
|
|
109
109
|
mockPool.intercept({
|
|
@@ -113,7 +113,7 @@ mockPool.intercept({
|
|
|
113
113
|
'X-TOKEN-SECRET': 'SuperSecretToken',
|
|
114
114
|
},
|
|
115
115
|
body: JSON.stringify({
|
|
116
|
-
|
|
116
|
+
recipient: '1234567890',
|
|
117
117
|
amount: '100'
|
|
118
118
|
})
|
|
119
119
|
}).reply(200, (opts) => {
|
|
@@ -129,7 +129,7 @@ in this case opts will be
|
|
|
129
129
|
{
|
|
130
130
|
method: 'POST',
|
|
131
131
|
headers: { 'X-TOKEN-SECRET': 'SuperSecretToken' },
|
|
132
|
-
body: '{"
|
|
132
|
+
body: '{"recipient":"1234567890","amount":"100"}',
|
|
133
133
|
origin: 'http://localhost:3000',
|
|
134
134
|
path: '/bank-transfer'
|
|
135
135
|
}
|
|
@@ -20,10 +20,10 @@ import { createServer } from 'http'
|
|
|
20
20
|
import proxy from 'proxy'
|
|
21
21
|
|
|
22
22
|
const server = await buildServer()
|
|
23
|
-
const
|
|
23
|
+
const proxyServer = await buildProxy()
|
|
24
24
|
|
|
25
25
|
const serverUrl = `http://localhost:${server.address().port}`
|
|
26
|
-
const proxyUrl = `http://localhost:${
|
|
26
|
+
const proxyUrl = `http://localhost:${proxyServer.address().port}`
|
|
27
27
|
|
|
28
28
|
server.on('request', (req, res) => {
|
|
29
29
|
console.log(req.url) // '/hello?foo=bar'
|
|
@@ -47,7 +47,7 @@ console.log(response.statusCode) // 200
|
|
|
47
47
|
console.log(JSON.parse(data)) // { hello: 'world' }
|
|
48
48
|
|
|
49
49
|
server.close()
|
|
50
|
-
|
|
50
|
+
proxyServer.close()
|
|
51
51
|
client.close()
|
|
52
52
|
|
|
53
53
|
function buildServer () {
|
|
@@ -73,12 +73,12 @@ import { createServer } from 'http'
|
|
|
73
73
|
import proxy from 'proxy'
|
|
74
74
|
|
|
75
75
|
const server = await buildServer()
|
|
76
|
-
const
|
|
76
|
+
const proxyServer = await buildProxy()
|
|
77
77
|
|
|
78
78
|
const serverUrl = `http://localhost:${server.address().port}`
|
|
79
|
-
const proxyUrl = `http://localhost:${
|
|
79
|
+
const proxyUrl = `http://localhost:${proxyServer.address().port}`
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
proxyServer.authenticate = function (req, fn) {
|
|
82
82
|
fn(null, req.headers['proxy-authorization'] === `Basic ${Buffer.from('user:pass').toString('base64')}`)
|
|
83
83
|
}
|
|
84
84
|
|
|
@@ -107,7 +107,7 @@ console.log(response.statusCode) // 200
|
|
|
107
107
|
console.log(JSON.parse(data)) // { hello: 'world' }
|
|
108
108
|
|
|
109
109
|
server.close()
|
|
110
|
-
|
|
110
|
+
proxyServer.close()
|
|
111
111
|
client.close()
|
|
112
112
|
|
|
113
113
|
function buildServer () {
|
|
@@ -124,3 +124,4 @@ function buildProxy () {
|
|
|
124
124
|
})
|
|
125
125
|
}
|
|
126
126
|
```
|
|
127
|
+
|
package/index.js
CHANGED
|
@@ -53,7 +53,12 @@ function makeDispatcher (fn) {
|
|
|
53
53
|
throw new InvalidArgumentError('invalid opts.path')
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
let path = opts.path
|
|
57
|
+
if (!opts.path.startsWith('/')) {
|
|
58
|
+
path = `/${path}`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
url = new URL(util.parseOrigin(url).origin + path)
|
|
57
62
|
} else {
|
|
58
63
|
if (!opts) {
|
|
59
64
|
opts = typeof url === 'object' ? url : {}
|
package/lib/balanced-pool.js
CHANGED
|
@@ -18,6 +18,17 @@ const { parseOrigin } = require('./core/util')
|
|
|
18
18
|
const kFactory = Symbol('factory')
|
|
19
19
|
|
|
20
20
|
const kOptions = Symbol('options')
|
|
21
|
+
const kGreatestCommonDivisor = Symbol('kGreatestCommonDivisor')
|
|
22
|
+
const kCurrentWeight = Symbol('kCurrentWeight')
|
|
23
|
+
const kIndex = Symbol('kIndex')
|
|
24
|
+
const kWeight = Symbol('kWeight')
|
|
25
|
+
const kMaxWeightPerServer = Symbol('kMaxWeightPerServer')
|
|
26
|
+
const kErrorPenalty = Symbol('kErrorPenalty')
|
|
27
|
+
|
|
28
|
+
function getGreatestCommonDivisor (a, b) {
|
|
29
|
+
if (b === 0) return a
|
|
30
|
+
return getGreatestCommonDivisor(b, a % b)
|
|
31
|
+
}
|
|
21
32
|
|
|
22
33
|
function defaultFactory (origin, opts) {
|
|
23
34
|
return new Pool(origin, opts)
|
|
@@ -28,6 +39,11 @@ class BalancedPool extends PoolBase {
|
|
|
28
39
|
super()
|
|
29
40
|
|
|
30
41
|
this[kOptions] = opts
|
|
42
|
+
this[kIndex] = -1
|
|
43
|
+
this[kCurrentWeight] = 0
|
|
44
|
+
|
|
45
|
+
this[kMaxWeightPerServer] = this[kOptions].maxWeightPerServer || 100
|
|
46
|
+
this[kErrorPenalty] = this[kOptions].errorPenalty || 15
|
|
31
47
|
|
|
32
48
|
if (!Array.isArray(upstreams)) {
|
|
33
49
|
upstreams = [upstreams]
|
|
@@ -42,6 +58,7 @@ class BalancedPool extends PoolBase {
|
|
|
42
58
|
for (const upstream of upstreams) {
|
|
43
59
|
this.addUpstream(upstream)
|
|
44
60
|
}
|
|
61
|
+
this._updateBalancedPoolStats()
|
|
45
62
|
}
|
|
46
63
|
|
|
47
64
|
addUpstream (upstream) {
|
|
@@ -54,12 +71,40 @@ class BalancedPool extends PoolBase {
|
|
|
54
71
|
))) {
|
|
55
72
|
return this
|
|
56
73
|
}
|
|
74
|
+
const pool = this[kFactory](upstreamOrigin, Object.assign({}, this[kOptions]))
|
|
75
|
+
|
|
76
|
+
this[kAddClient](pool)
|
|
77
|
+
pool.on('connect', () => {
|
|
78
|
+
pool[kWeight] = Math.min(this[kMaxWeightPerServer], pool[kWeight] + this[kErrorPenalty])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
pool.on('connectionError', () => {
|
|
82
|
+
pool[kWeight] = Math.max(1, pool[kWeight] - this[kErrorPenalty])
|
|
83
|
+
this._updateBalancedPoolStats()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
pool.on('disconnect', (...args) => {
|
|
87
|
+
const err = args[2]
|
|
88
|
+
if (err && err.code === 'UND_ERR_SOCKET') {
|
|
89
|
+
// decrease the weight of the pool.
|
|
90
|
+
pool[kWeight] = Math.max(1, pool[kWeight] - this[kErrorPenalty])
|
|
91
|
+
this._updateBalancedPoolStats()
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
for (const client of this[kClients]) {
|
|
96
|
+
client[kWeight] = this[kMaxWeightPerServer]
|
|
97
|
+
}
|
|
57
98
|
|
|
58
|
-
this
|
|
99
|
+
this._updateBalancedPoolStats()
|
|
59
100
|
|
|
60
101
|
return this
|
|
61
102
|
}
|
|
62
103
|
|
|
104
|
+
_updateBalancedPoolStats () {
|
|
105
|
+
this[kGreatestCommonDivisor] = this[kClients].map(p => p[kWeight]).reduce(getGreatestCommonDivisor, 0)
|
|
106
|
+
}
|
|
107
|
+
|
|
63
108
|
removeUpstream (upstream) {
|
|
64
109
|
const upstreamOrigin = parseOrigin(upstream).origin
|
|
65
110
|
|
|
@@ -100,10 +145,42 @@ class BalancedPool extends PoolBase {
|
|
|
100
145
|
return
|
|
101
146
|
}
|
|
102
147
|
|
|
103
|
-
this[kClients].
|
|
104
|
-
|
|
148
|
+
const allClientsBusy = this[kClients].map(pool => pool[kNeedDrain]).reduce((a, b) => a && b, true)
|
|
149
|
+
|
|
150
|
+
if (allClientsBusy) {
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let counter = 0
|
|
155
|
+
|
|
156
|
+
let maxWeightIndex = this[kClients].findIndex(pool => !pool[kNeedDrain])
|
|
157
|
+
|
|
158
|
+
while (counter++ < this[kClients].length) {
|
|
159
|
+
this[kIndex] = (this[kIndex] + 1) % this[kClients].length
|
|
160
|
+
const pool = this[kClients][this[kIndex]]
|
|
161
|
+
|
|
162
|
+
// find pool index with the largest weight
|
|
163
|
+
if (pool[kWeight] > this[kClients][maxWeightIndex][kWeight] && !pool[kNeedDrain]) {
|
|
164
|
+
maxWeightIndex = this[kIndex]
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// decrease the current weight every `this[kClients].length`.
|
|
168
|
+
if (this[kIndex] === 0) {
|
|
169
|
+
// Set the current weight to the next lower weight.
|
|
170
|
+
this[kCurrentWeight] = this[kCurrentWeight] - this[kGreatestCommonDivisor]
|
|
171
|
+
|
|
172
|
+
if (this[kCurrentWeight] <= 0) {
|
|
173
|
+
this[kCurrentWeight] = this[kMaxWeightPerServer]
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (pool[kWeight] >= this[kCurrentWeight] && (!pool[kNeedDrain])) {
|
|
177
|
+
return pool
|
|
178
|
+
}
|
|
179
|
+
}
|
|
105
180
|
|
|
106
|
-
|
|
181
|
+
this[kCurrentWeight] = this[kClients][maxWeightIndex][kWeight]
|
|
182
|
+
this[kIndex] = maxWeightIndex
|
|
183
|
+
return this[kClients][maxWeightIndex]
|
|
107
184
|
}
|
|
108
185
|
}
|
|
109
186
|
|
package/lib/core/connect.js
CHANGED
|
@@ -75,14 +75,12 @@ function buildConnector ({ maxCachedSessions, socketPath, timeout, ...opts }) {
|
|
|
75
75
|
})
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
const
|
|
79
|
-
? setTimeout(onConnectTimeout, timeout, socket)
|
|
80
|
-
: null
|
|
78
|
+
const cancelTimeout = setupTimeout(() => onConnectTimeout(socket), timeout)
|
|
81
79
|
|
|
82
80
|
socket
|
|
83
81
|
.setNoDelay(true)
|
|
84
82
|
.once(protocol === 'https:' ? 'secureConnect' : 'connect', function () {
|
|
85
|
-
|
|
83
|
+
cancelTimeout()
|
|
86
84
|
|
|
87
85
|
if (callback) {
|
|
88
86
|
const cb = callback
|
|
@@ -91,7 +89,7 @@ function buildConnector ({ maxCachedSessions, socketPath, timeout, ...opts }) {
|
|
|
91
89
|
}
|
|
92
90
|
})
|
|
93
91
|
.on('error', function (err) {
|
|
94
|
-
|
|
92
|
+
cancelTimeout()
|
|
95
93
|
|
|
96
94
|
if (callback) {
|
|
97
95
|
const cb = callback
|
|
@@ -104,6 +102,31 @@ function buildConnector ({ maxCachedSessions, socketPath, timeout, ...opts }) {
|
|
|
104
102
|
}
|
|
105
103
|
}
|
|
106
104
|
|
|
105
|
+
function setupTimeout (onConnectTimeout, timeout) {
|
|
106
|
+
if (!timeout) {
|
|
107
|
+
return () => {}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let s1 = null
|
|
111
|
+
let s2 = null
|
|
112
|
+
const timeoutId = setTimeout(() => {
|
|
113
|
+
// setImmediate is added to make sure that we priotorise socket error events over timeouts
|
|
114
|
+
s1 = setImmediate(() => {
|
|
115
|
+
if (process.platform === 'win32') {
|
|
116
|
+
// Windows needs an extra setImmediate probably due to implementation differences in the socket logic
|
|
117
|
+
s2 = setImmediate(() => onConnectTimeout())
|
|
118
|
+
} else {
|
|
119
|
+
onConnectTimeout()
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
}, timeout)
|
|
123
|
+
return () => {
|
|
124
|
+
clearTimeout(timeoutId)
|
|
125
|
+
clearImmediate(s1)
|
|
126
|
+
clearImmediate(s2)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
107
130
|
function onConnectTimeout (socket) {
|
|
108
131
|
util.destroy(socket, new ConnectTimeoutError())
|
|
109
132
|
}
|
package/lib/core/request.js
CHANGED
|
@@ -7,6 +7,27 @@ const {
|
|
|
7
7
|
const assert = require('assert')
|
|
8
8
|
const util = require('./util')
|
|
9
9
|
|
|
10
|
+
// tokenRegExp and headerCharRegex have been lifted from
|
|
11
|
+
// https://github.com/nodejs/node/blob/main/lib/_http_common.js
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Verifies that the given val is a valid HTTP token
|
|
15
|
+
* per the rules defined in RFC 7230
|
|
16
|
+
* See https://tools.ietf.org/html/rfc7230#section-3.2.6
|
|
17
|
+
*/
|
|
18
|
+
const tokenRegExp = /^[\^_`a-zA-Z\-0-9!#$%&'*+.|~]+$/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Matches if val contains an invalid field-vchar
|
|
22
|
+
* field-value = *( field-content / obs-fold )
|
|
23
|
+
* field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ]
|
|
24
|
+
* field-vchar = VCHAR / obs-text
|
|
25
|
+
*/
|
|
26
|
+
const headerCharRegex = /[^\t\x20-\x7e\x80-\xff]/
|
|
27
|
+
|
|
28
|
+
// Verifies that a given path is valid does not contain control chars \x00 to \x20
|
|
29
|
+
const invalidPathRegex = /[^\u0021-\u00ff]/
|
|
30
|
+
|
|
10
31
|
const kHandler = Symbol('handler')
|
|
11
32
|
|
|
12
33
|
const channels = {}
|
|
@@ -54,10 +75,14 @@ class Request {
|
|
|
54
75
|
method !== 'CONNECT'
|
|
55
76
|
) {
|
|
56
77
|
throw new InvalidArgumentError('path must be an absolute URL or start with a slash')
|
|
78
|
+
} else if (invalidPathRegex.exec(path) !== null) {
|
|
79
|
+
throw new InvalidArgumentError('invalid request path')
|
|
57
80
|
}
|
|
58
81
|
|
|
59
82
|
if (typeof method !== 'string') {
|
|
60
83
|
throw new InvalidArgumentError('method must be a string')
|
|
84
|
+
} else if (tokenRegExp.exec(method) === null) {
|
|
85
|
+
throw new InvalidArgumentError('invalid request method')
|
|
61
86
|
}
|
|
62
87
|
|
|
63
88
|
if (upgrade && typeof upgrade !== 'string') {
|
|
@@ -272,7 +297,8 @@ function processHeader (request, key, val) {
|
|
|
272
297
|
} else if (
|
|
273
298
|
request.contentType === null &&
|
|
274
299
|
key.length === 12 &&
|
|
275
|
-
key.toLowerCase() === 'content-type'
|
|
300
|
+
key.toLowerCase() === 'content-type' &&
|
|
301
|
+
headerCharRegex.exec(val) === null
|
|
276
302
|
) {
|
|
277
303
|
request.contentType = val
|
|
278
304
|
request.headers += `${key}: ${val}\r\n`
|
|
@@ -301,6 +327,10 @@ function processHeader (request, key, val) {
|
|
|
301
327
|
key.toLowerCase() === 'expect'
|
|
302
328
|
) {
|
|
303
329
|
throw new NotSupportedError('expect header not supported')
|
|
330
|
+
} else if (tokenRegExp.exec(key) === null) {
|
|
331
|
+
throw new InvalidArgumentError('invalid header key')
|
|
332
|
+
} else if (headerCharRegex.exec(val) !== null) {
|
|
333
|
+
throw new InvalidArgumentError(`invalid ${key} header`)
|
|
304
334
|
} else {
|
|
305
335
|
request.headers += `${key}: ${val}\r\n`
|
|
306
336
|
}
|
package/lib/core/util.js
CHANGED
|
@@ -108,14 +108,25 @@ function parseURL (url) {
|
|
|
108
108
|
const port = url.port != null
|
|
109
109
|
? url.port
|
|
110
110
|
: (url.protocol === 'https:' ? 443 : 80)
|
|
111
|
-
|
|
111
|
+
let origin = url.origin != null
|
|
112
112
|
? url.origin
|
|
113
113
|
: `${url.protocol}//${url.hostname}:${port}`
|
|
114
|
-
|
|
114
|
+
let path = url.path != null
|
|
115
115
|
? url.path
|
|
116
116
|
: `${url.pathname || ''}${url.search || ''}`
|
|
117
117
|
|
|
118
|
-
|
|
118
|
+
if (origin.endsWith('/')) {
|
|
119
|
+
origin = origin.substring(0, origin.length - 1)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (path && !path.startsWith('/')) {
|
|
123
|
+
path = `/${path}`
|
|
124
|
+
}
|
|
125
|
+
// new URL(path, origin) is unsafe when `path` contains an absolute URL
|
|
126
|
+
// From https://developer.mozilla.org/en-US/docs/Web/API/URL/URL:
|
|
127
|
+
// If first parameter is a relative URL, second param is required, and will be used as the base URL.
|
|
128
|
+
// If first parameter is an absolute URL, a given second param will be ignored.
|
|
129
|
+
url = new URL(origin + path)
|
|
119
130
|
}
|
|
120
131
|
|
|
121
132
|
return url
|
package/lib/fetch/body.js
CHANGED
|
@@ -291,6 +291,10 @@ function bodyMixinMethods (instance) {
|
|
|
291
291
|
const chunks = []
|
|
292
292
|
|
|
293
293
|
for await (const chunk of consumeBody(this[kState].body)) {
|
|
294
|
+
if (!isUint8Array(chunk)) {
|
|
295
|
+
throw new TypeError('Expected Uint8Array chunk')
|
|
296
|
+
}
|
|
297
|
+
|
|
294
298
|
// Assemble one final large blob with Uint8Array's can exhaust memory.
|
|
295
299
|
// That's why we create create multiple blob's and using references
|
|
296
300
|
chunks.push(new Blob([chunk]))
|
|
@@ -314,6 +318,10 @@ function bodyMixinMethods (instance) {
|
|
|
314
318
|
let offset = 0
|
|
315
319
|
|
|
316
320
|
for await (const chunk of consumeBody(this[kState].body)) {
|
|
321
|
+
if (!isUint8Array(chunk)) {
|
|
322
|
+
throw new TypeError('Expected Uint8Array chunk')
|
|
323
|
+
}
|
|
324
|
+
|
|
317
325
|
buffer.set(chunk, offset)
|
|
318
326
|
offset += chunk.length
|
|
319
327
|
}
|
|
@@ -331,6 +339,10 @@ function bodyMixinMethods (instance) {
|
|
|
331
339
|
let size = 0
|
|
332
340
|
|
|
333
341
|
for await (const chunk of consumeBody(this[kState].body)) {
|
|
342
|
+
if (!isUint8Array(chunk)) {
|
|
343
|
+
throw new TypeError('Expected Uint8Array chunk')
|
|
344
|
+
}
|
|
345
|
+
|
|
334
346
|
chunks.push(chunk)
|
|
335
347
|
size += chunk.byteLength
|
|
336
348
|
}
|
|
@@ -355,6 +367,10 @@ function bodyMixinMethods (instance) {
|
|
|
355
367
|
const textDecoder = new TextDecoder()
|
|
356
368
|
|
|
357
369
|
for await (const chunk of consumeBody(this[kState].body)) {
|
|
370
|
+
if (!isUint8Array(chunk)) {
|
|
371
|
+
throw new TypeError('Expected Uint8Array chunk')
|
|
372
|
+
}
|
|
373
|
+
|
|
358
374
|
result += textDecoder.decode(chunk, { stream: true })
|
|
359
375
|
}
|
|
360
376
|
|
|
@@ -388,7 +404,18 @@ function bodyMixinMethods (instance) {
|
|
|
388
404
|
// 1. Let entries be the result of parsing bytes.
|
|
389
405
|
let entries
|
|
390
406
|
try {
|
|
391
|
-
|
|
407
|
+
let text = ''
|
|
408
|
+
// application/x-www-form-urlencoded parser will keep the BOM.
|
|
409
|
+
// https://url.spec.whatwg.org/#concept-urlencoded-parser
|
|
410
|
+
const textDecoder = new TextDecoder('utf-8', { ignoreBOM: true })
|
|
411
|
+
for await (const chunk of consumeBody(this[kState].body)) {
|
|
412
|
+
if (!isUint8Array(chunk)) {
|
|
413
|
+
throw new TypeError('Expected Uint8Array chunk')
|
|
414
|
+
}
|
|
415
|
+
text += textDecoder.decode(chunk, { stream: true })
|
|
416
|
+
}
|
|
417
|
+
text += textDecoder.decode()
|
|
418
|
+
entries = new URLSearchParams(text)
|
|
392
419
|
} catch (err) {
|
|
393
420
|
// istanbul ignore next: Unclear when new URLSearchParams can fail on a string.
|
|
394
421
|
// 2. If entries is failure, then throw a TypeError.
|
package/lib/fetch/dataURL.js
CHANGED
package/lib/fetch/index.js
CHANGED
|
@@ -33,7 +33,8 @@ const {
|
|
|
33
33
|
isBlobLike,
|
|
34
34
|
sameOrigin,
|
|
35
35
|
isCancelled,
|
|
36
|
-
isAborted
|
|
36
|
+
isAborted,
|
|
37
|
+
isErrorLike
|
|
37
38
|
} = require('./util')
|
|
38
39
|
const { kState, kHeaders, kGuard, kRealm } = require('./symbols')
|
|
39
40
|
const assert = require('assert')
|
|
@@ -1854,7 +1855,7 @@ async function httpNetworkFetch (
|
|
|
1854
1855
|
timingInfo.decodedBodySize += bytes?.byteLength ?? 0
|
|
1855
1856
|
|
|
1856
1857
|
// 6. If bytes is failure, then terminate fetchParams’s controller.
|
|
1857
|
-
if (bytes
|
|
1858
|
+
if (isErrorLike(bytes)) {
|
|
1858
1859
|
fetchParams.controller.terminate(bytes)
|
|
1859
1860
|
return
|
|
1860
1861
|
}
|
|
@@ -1894,7 +1895,7 @@ async function httpNetworkFetch (
|
|
|
1894
1895
|
// 3. Otherwise, if stream is readable, error stream with a TypeError.
|
|
1895
1896
|
if (isReadable(stream)) {
|
|
1896
1897
|
fetchParams.controller.controller.error(new TypeError('terminated', {
|
|
1897
|
-
cause: reason
|
|
1898
|
+
cause: isErrorLike(reason) ? reason : undefined
|
|
1898
1899
|
}))
|
|
1899
1900
|
}
|
|
1900
1901
|
}
|
|
@@ -1942,14 +1943,17 @@ async function httpNetworkFetch (
|
|
|
1942
1943
|
}
|
|
1943
1944
|
|
|
1944
1945
|
let codings = []
|
|
1946
|
+
let location = ''
|
|
1945
1947
|
|
|
1946
1948
|
const headers = new Headers()
|
|
1947
1949
|
for (let n = 0; n < headersList.length; n += 2) {
|
|
1948
|
-
const key = headersList[n + 0].toString()
|
|
1949
|
-
const val = headersList[n + 1].toString()
|
|
1950
|
+
const key = headersList[n + 0].toString('latin1')
|
|
1951
|
+
const val = headersList[n + 1].toString('latin1')
|
|
1950
1952
|
|
|
1951
1953
|
if (key.toLowerCase() === 'content-encoding') {
|
|
1952
1954
|
codings = val.split(',').map((x) => x.trim())
|
|
1955
|
+
} else if (key.toLowerCase() === 'location') {
|
|
1956
|
+
location = val
|
|
1953
1957
|
}
|
|
1954
1958
|
|
|
1955
1959
|
headers.append(key, val)
|
|
@@ -1960,7 +1964,7 @@ async function httpNetworkFetch (
|
|
|
1960
1964
|
const decoders = []
|
|
1961
1965
|
|
|
1962
1966
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding
|
|
1963
|
-
if (request.method !== 'HEAD' && request.method !== 'CONNECT' && !nullBodyStatus.includes(status)) {
|
|
1967
|
+
if (request.method !== 'HEAD' && request.method !== 'CONNECT' && !nullBodyStatus.includes(status) && !(request.redirect === 'follow' && location)) {
|
|
1964
1968
|
for (const coding of codings) {
|
|
1965
1969
|
if (/(x-)?gzip/.test(coding)) {
|
|
1966
1970
|
decoders.push(zlib.createGunzip())
|
|
@@ -1980,7 +1984,7 @@ async function httpNetworkFetch (
|
|
|
1980
1984
|
statusText,
|
|
1981
1985
|
headersList: headers[kHeadersList],
|
|
1982
1986
|
body: decoders.length
|
|
1983
|
-
? pipeline(this.body, ...decoders, () => {})
|
|
1987
|
+
? pipeline(this.body, ...decoders, () => { })
|
|
1984
1988
|
: this.body.on('error', () => {})
|
|
1985
1989
|
})
|
|
1986
1990
|
|
package/lib/fetch/request.js
CHANGED
|
@@ -367,9 +367,9 @@ class Request {
|
|
|
367
367
|
}
|
|
368
368
|
|
|
369
369
|
if (signal.aborted) {
|
|
370
|
-
ac.abort()
|
|
370
|
+
ac.abort(signal.reason)
|
|
371
371
|
} else {
|
|
372
|
-
const abort = () => ac.abort()
|
|
372
|
+
const abort = () => ac.abort(signal.reason)
|
|
373
373
|
signal.addEventListener('abort', abort, { once: true })
|
|
374
374
|
requestFinalizer.register(this, { signal, abort })
|
|
375
375
|
}
|
|
@@ -726,12 +726,12 @@ class Request {
|
|
|
726
726
|
// 4. Make clonedRequestObject’s signal follow this’s signal.
|
|
727
727
|
const ac = new AbortController()
|
|
728
728
|
if (this.signal.aborted) {
|
|
729
|
-
ac.abort()
|
|
729
|
+
ac.abort(this.signal.reason)
|
|
730
730
|
} else {
|
|
731
731
|
this.signal.addEventListener(
|
|
732
732
|
'abort',
|
|
733
|
-
|
|
734
|
-
ac.abort()
|
|
733
|
+
() => {
|
|
734
|
+
ac.abort(this.signal.reason)
|
|
735
735
|
},
|
|
736
736
|
{ once: true }
|
|
737
737
|
)
|
package/lib/fetch/response.js
CHANGED
|
@@ -10,7 +10,8 @@ const {
|
|
|
10
10
|
isCancelled,
|
|
11
11
|
isAborted,
|
|
12
12
|
isBlobLike,
|
|
13
|
-
serializeJavascriptValueToJSONString
|
|
13
|
+
serializeJavascriptValueToJSONString,
|
|
14
|
+
isErrorLike
|
|
14
15
|
} = require('./util')
|
|
15
16
|
const {
|
|
16
17
|
redirectStatus,
|
|
@@ -347,15 +348,15 @@ function makeResponse (init) {
|
|
|
347
348
|
}
|
|
348
349
|
|
|
349
350
|
function makeNetworkError (reason) {
|
|
351
|
+
const isError = isErrorLike(reason)
|
|
350
352
|
return makeResponse({
|
|
351
353
|
type: 'error',
|
|
352
354
|
status: 0,
|
|
353
|
-
error:
|
|
354
|
-
reason
|
|
355
|
-
|
|
356
|
-
:
|
|
357
|
-
|
|
358
|
-
}),
|
|
355
|
+
error: isError
|
|
356
|
+
? reason
|
|
357
|
+
: new Error(reason ? String(reason) : reason, {
|
|
358
|
+
cause: isError ? reason : undefined
|
|
359
|
+
}),
|
|
359
360
|
aborted: reason && reason.name === 'AbortError'
|
|
360
361
|
})
|
|
361
362
|
}
|
package/lib/fetch/util.js
CHANGED
|
@@ -82,6 +82,13 @@ function isFileLike (object) {
|
|
|
82
82
|
)
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
function isErrorLike (object) {
|
|
86
|
+
return object instanceof Error || (
|
|
87
|
+
object?.constructor?.name === 'Error' ||
|
|
88
|
+
object?.constructor?.name === 'DOMException'
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
85
92
|
// Check whether |statusText| is a ByteString and
|
|
86
93
|
// matches the Reason-Phrase token production.
|
|
87
94
|
// RFC 2616: https://tools.ietf.org/html/rfc2616
|
|
@@ -469,5 +476,6 @@ module.exports = {
|
|
|
469
476
|
makeIterator,
|
|
470
477
|
isValidHeaderName,
|
|
471
478
|
isValidHeaderValue,
|
|
472
|
-
hasOwn
|
|
479
|
+
hasOwn,
|
|
480
|
+
isErrorLike
|
|
473
481
|
}
|
package/lib/fetch/webidl.js
CHANGED
|
@@ -388,9 +388,6 @@ webidl.converters.DOMString = function (V, opts = {}) {
|
|
|
388
388
|
return String(V)
|
|
389
389
|
}
|
|
390
390
|
|
|
391
|
-
// eslint-disable-next-line no-control-regex
|
|
392
|
-
const isNotLatin1 = /[^\u0000-\u00ff]/
|
|
393
|
-
|
|
394
391
|
// https://webidl.spec.whatwg.org/#es-ByteString
|
|
395
392
|
webidl.converters.ByteString = function (V) {
|
|
396
393
|
// 1. Let x be ? ToString(V).
|
|
@@ -399,8 +396,15 @@ webidl.converters.ByteString = function (V) {
|
|
|
399
396
|
|
|
400
397
|
// 2. If the value of any element of x is greater than
|
|
401
398
|
// 255, then throw a TypeError.
|
|
402
|
-
|
|
403
|
-
|
|
399
|
+
for (let index = 0; index < x.length; index++) {
|
|
400
|
+
const charCode = x.charCodeAt(index)
|
|
401
|
+
|
|
402
|
+
if (charCode > 255) {
|
|
403
|
+
throw new TypeError(
|
|
404
|
+
'Cannot convert argument to a ByteString because the character at' +
|
|
405
|
+
`index ${index} has a value of ${charCode} which is greater than 255.`
|
|
406
|
+
)
|
|
407
|
+
}
|
|
404
408
|
}
|
|
405
409
|
|
|
406
410
|
// 3. Return an IDL ByteString value whose length is the
|
package/lib/handler/redirect.js
CHANGED
|
@@ -186,7 +186,8 @@ function shouldRemoveHeader (header, removeContent, unknownOrigin) {
|
|
|
186
186
|
return (
|
|
187
187
|
(header.length === 4 && header.toString().toLowerCase() === 'host') ||
|
|
188
188
|
(removeContent && header.toString().toLowerCase().indexOf('content-') === 0) ||
|
|
189
|
-
(unknownOrigin && header.length === 13 && header.toString().toLowerCase() === 'authorization')
|
|
189
|
+
(unknownOrigin && header.length === 13 && header.toString().toLowerCase() === 'authorization') ||
|
|
190
|
+
(unknownOrigin && header.length === 6 && header.toString().toLowerCase() === 'cookie')
|
|
190
191
|
)
|
|
191
192
|
}
|
|
192
193
|
|
package/lib/mock/mock-utils.js
CHANGED
|
@@ -8,7 +8,7 @@ const {
|
|
|
8
8
|
kOrigin,
|
|
9
9
|
kGetNetConnect
|
|
10
10
|
} = require('./mock-symbols')
|
|
11
|
-
const { buildURL } = require('../core/util')
|
|
11
|
+
const { buildURL, nop } = require('../core/util')
|
|
12
12
|
|
|
13
13
|
function matchValue (match, value) {
|
|
14
14
|
if (typeof match === 'string') {
|
|
@@ -38,7 +38,7 @@ function lowerCaseEntries (headers) {
|
|
|
38
38
|
function getHeaderByName (headers, key) {
|
|
39
39
|
if (Array.isArray(headers)) {
|
|
40
40
|
for (let i = 0; i < headers.length; i += 2) {
|
|
41
|
-
if (headers[i] === key) {
|
|
41
|
+
if (headers[i].toLocaleLowerCase() === key.toLocaleLowerCase()) {
|
|
42
42
|
return headers[i + 1]
|
|
43
43
|
}
|
|
44
44
|
}
|
|
@@ -47,19 +47,24 @@ function getHeaderByName (headers, key) {
|
|
|
47
47
|
} else if (typeof headers.get === 'function') {
|
|
48
48
|
return headers.get(key)
|
|
49
49
|
} else {
|
|
50
|
-
return headers[key]
|
|
50
|
+
return lowerCaseEntries(headers)[key.toLocaleLowerCase()]
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/** @param {string[]} headers */
|
|
55
|
+
function buildHeadersFromArray (headers) { // fetch HeadersList
|
|
56
|
+
const clone = headers.slice()
|
|
57
|
+
const entries = []
|
|
58
|
+
for (let index = 0; index < clone.length; index += 2) {
|
|
59
|
+
entries.push([clone[index], clone[index + 1]])
|
|
60
|
+
}
|
|
61
|
+
return Object.fromEntries(entries)
|
|
62
|
+
}
|
|
63
|
+
|
|
54
64
|
function matchHeaders (mockDispatch, headers) {
|
|
55
65
|
if (typeof mockDispatch.headers === 'function') {
|
|
56
66
|
if (Array.isArray(headers)) { // fetch HeadersList
|
|
57
|
-
|
|
58
|
-
const entries = []
|
|
59
|
-
for (let index = 0; index < clone.length; index += 2) {
|
|
60
|
-
entries.push([clone[index], clone[index + 1]])
|
|
61
|
-
}
|
|
62
|
-
headers = Object.fromEntries(entries)
|
|
67
|
+
headers = buildHeadersFromArray(headers)
|
|
63
68
|
}
|
|
64
69
|
return mockDispatch.headers(headers ? lowerCaseEntries(headers) : {})
|
|
65
70
|
}
|
|
@@ -284,10 +289,17 @@ function mockDispatch (opts, handler) {
|
|
|
284
289
|
}
|
|
285
290
|
|
|
286
291
|
function handleReply (mockDispatches) {
|
|
287
|
-
|
|
292
|
+
// fetch's HeadersList is a 1D string array
|
|
293
|
+
const optsHeaders = Array.isArray(opts.headers)
|
|
294
|
+
? buildHeadersFromArray(opts.headers)
|
|
295
|
+
: opts.headers
|
|
296
|
+
const responseData = getResponseData(
|
|
297
|
+
typeof data === 'function' ? data({ ...opts, headers: optsHeaders }) : data
|
|
298
|
+
)
|
|
288
299
|
const responseHeaders = generateKeyValues(headers)
|
|
289
300
|
const responseTrailers = generateKeyValues(trailers)
|
|
290
301
|
|
|
302
|
+
handler.abort = nop
|
|
291
303
|
handler.onHeaders(statusCode, responseHeaders, resume, getStatusText(statusCode))
|
|
292
304
|
handler.onData(Buffer.from(responseData))
|
|
293
305
|
handler.onComplete(responseTrailers)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "undici",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.8.2",
|
|
4
4
|
"description": "An HTTP/1.1 client, written from scratch for Node.js",
|
|
5
5
|
"homepage": "https://undici.nodejs.org",
|
|
6
6
|
"bugs": {
|
|
@@ -67,6 +67,7 @@
|
|
|
67
67
|
"@sinonjs/fake-timers": "^9.1.2",
|
|
68
68
|
"@types/node": "^17.0.29",
|
|
69
69
|
"abort-controller": "^3.0.0",
|
|
70
|
+
"atomic-sleep": "^1.0.0",
|
|
70
71
|
"busboy": "^1.6.0",
|
|
71
72
|
"chai": "^4.3.4",
|
|
72
73
|
"chai-as-promised": "^7.1.1",
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import Pool = require("./pool")
|
|
2
|
+
|
|
3
|
+
export = PoolStats
|
|
4
|
+
|
|
5
|
+
declare class PoolStats {
|
|
6
|
+
constructor(pool: Pool);
|
|
7
|
+
/** Number of open socket connections in this pool. */
|
|
8
|
+
connected: number;
|
|
9
|
+
/** Number of open socket connections in this pool that do not have an active request. */
|
|
10
|
+
free: number;
|
|
11
|
+
/** Number of pending requests across all clients in this pool. */
|
|
12
|
+
pending: number;
|
|
13
|
+
/** Number of queued requests across all clients in this pool. */
|
|
14
|
+
queued: number;
|
|
15
|
+
/** Number of currently active requests across all clients in this pool. */
|
|
16
|
+
running: number;
|
|
17
|
+
/** Number of active, pending, or queued requests across all clients in this pool. */
|
|
18
|
+
size: number;
|
|
19
|
+
}
|
package/types/pool.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import Client = require('./client')
|
|
2
2
|
import Dispatcher = require('./dispatcher')
|
|
3
|
+
import TPoolStats = require('./pool-stats')
|
|
3
4
|
import { URL } from 'url'
|
|
4
5
|
|
|
5
6
|
export = Pool
|
|
@@ -10,9 +11,12 @@ declare class Pool extends Dispatcher {
|
|
|
10
11
|
closed: boolean;
|
|
11
12
|
/** `true` after `pool.destroyed()` has been called or `pool.close()` has been called and the pool shutdown has completed. */
|
|
12
13
|
destroyed: boolean;
|
|
14
|
+
/** Aggregate stats for a Pool. */
|
|
15
|
+
readonly stats: TPoolStats;
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
declare namespace Pool {
|
|
19
|
+
export type PoolStats = TPoolStats;
|
|
16
20
|
export interface Options extends Client.Options {
|
|
17
21
|
/** Default: `(origin, opts) => new Client(origin, opts)`. */
|
|
18
22
|
factory?(origin: URL, opts: object): Dispatcher;
|