undici 7.13.0 → 7.14.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 +5 -5
- package/docs/docs/api/DiagnosticsChannel.md +25 -1
- package/lib/dispatcher/proxy-agent.js +1 -2
- package/lib/handler/cache-handler.js +22 -4
- package/lib/interceptor/cache.js +2 -2
- package/lib/mock/snapshot-agent.js +73 -59
- package/lib/mock/snapshot-recorder.js +254 -191
- package/lib/mock/snapshot-utils.js +158 -0
- package/lib/util/cache.js +3 -3
- package/lib/web/cache/cache.js +4 -4
- package/lib/web/eventsource/eventsource.js +17 -2
- package/lib/web/fetch/formdata.js +1 -1
- package/lib/web/fetch/response.js +8 -4
- package/lib/web/websocket/stream/websocketstream.js +2 -2
- package/lib/web/websocket/websocket.js +11 -4
- package/package.json +5 -5
- package/types/eventsource.d.ts +6 -1
- package/types/index.d.ts +4 -1
package/README.md
CHANGED
|
@@ -622,11 +622,11 @@ and `undici.Agent`) which will enable the family autoselection algorithm when es
|
|
|
622
622
|
|
|
623
623
|
Undici aligns with the Node.js LTS schedule. The following table shows the supported versions:
|
|
624
624
|
|
|
625
|
-
| Version | Node.js
|
|
626
|
-
|
|
627
|
-
| 5.x
|
|
628
|
-
| 6.x
|
|
629
|
-
| 7.x
|
|
625
|
+
| Undici Version | Bundled in Node.js | Node.js Versions Supported | End of Life |
|
|
626
|
+
|----------------|-------------------|----------------------------|-------------|
|
|
627
|
+
| 5.x | 18.x | ≥14.0 (tested: 14, 16, 18) | 2024-04-30 |
|
|
628
|
+
| 6.x | 20.x, 22.x | ≥18.17 (tested: 18, 20, 21, 22) | 2026-04-30 |
|
|
629
|
+
| 7.x | 24.x | ≥20.18.1 (tested: 20, 22, 24) | 2027-04-30 |
|
|
630
630
|
|
|
631
631
|
## License
|
|
632
632
|
|
|
@@ -169,14 +169,38 @@ This message is published after the client has successfully connected to a serve
|
|
|
169
169
|
```js
|
|
170
170
|
import diagnosticsChannel from 'diagnostics_channel'
|
|
171
171
|
|
|
172
|
-
diagnosticsChannel.channel('undici:websocket:open').subscribe(({
|
|
172
|
+
diagnosticsChannel.channel('undici:websocket:open').subscribe(({
|
|
173
|
+
address, // { address: string, family: string, port: number }
|
|
174
|
+
protocol, // string - negotiated subprotocol
|
|
175
|
+
extensions, // string - negotiated extensions
|
|
176
|
+
websocket, // WebSocket - the WebSocket instance
|
|
177
|
+
handshakeResponse // object - HTTP response that upgraded the connection
|
|
178
|
+
}) => {
|
|
173
179
|
console.log(address) // address, family, and port
|
|
174
180
|
console.log(protocol) // negotiated subprotocols
|
|
175
181
|
console.log(extensions) // negotiated extensions
|
|
176
182
|
console.log(websocket) // the WebSocket instance
|
|
183
|
+
|
|
184
|
+
// Handshake response details
|
|
185
|
+
console.log(handshakeResponse.status) // 101 for successful WebSocket upgrade
|
|
186
|
+
console.log(handshakeResponse.statusText) // 'Switching Protocols'
|
|
187
|
+
console.log(handshakeResponse.headers) // Object containing response headers
|
|
177
188
|
})
|
|
178
189
|
```
|
|
179
190
|
|
|
191
|
+
### Handshake Response Object
|
|
192
|
+
|
|
193
|
+
The `handshakeResponse` object contains the HTTP response that upgraded the connection to WebSocket:
|
|
194
|
+
|
|
195
|
+
- `status` (number): The HTTP status code (101 for successful WebSocket upgrade)
|
|
196
|
+
- `statusText` (string): The HTTP status message ('Switching Protocols' for successful upgrade)
|
|
197
|
+
- `headers` (object): The HTTP response headers from the server, including:
|
|
198
|
+
- `upgrade: 'websocket'`
|
|
199
|
+
- `connection: 'upgrade'`
|
|
200
|
+
- `sec-websocket-accept` and other WebSocket-related headers
|
|
201
|
+
|
|
202
|
+
This information is particularly useful for debugging and monitoring WebSocket connections, as it provides access to the initial HTTP handshake response that established the WebSocket connection.
|
|
203
|
+
|
|
180
204
|
## `undici:websocket:close`
|
|
181
205
|
|
|
182
206
|
This message is published after the connection has closed.
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const { kProxy, kClose, kDestroy, kDispatch } = require('../core/symbols')
|
|
4
|
-
const { URL } = require('node:url')
|
|
5
4
|
const Agent = require('./agent')
|
|
6
5
|
const Pool = require('./pool')
|
|
7
6
|
const DispatcherBase = require('./dispatcher-base')
|
|
@@ -208,7 +207,7 @@ class ProxyAgent extends DispatcherBase {
|
|
|
208
207
|
}
|
|
209
208
|
|
|
210
209
|
/**
|
|
211
|
-
* @param {import('
|
|
210
|
+
* @param {import('../../types/proxy-agent').ProxyAgent.Options | string | URL} opts
|
|
212
211
|
* @returns {URL}
|
|
213
212
|
*/
|
|
214
213
|
#getUrl (opts) {
|
|
@@ -15,6 +15,15 @@ const HEURISTICALLY_CACHEABLE_STATUS_CODES = [
|
|
|
15
15
|
200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501
|
|
16
16
|
]
|
|
17
17
|
|
|
18
|
+
// Status codes which semantic is not handled by the cache
|
|
19
|
+
// https://datatracker.ietf.org/doc/html/rfc9111#section-3
|
|
20
|
+
// This list should not grow beyond 206 and 304 unless the RFC is updated
|
|
21
|
+
// by a newer one including more. Please introduce another list if
|
|
22
|
+
// implementing caching of responses with the 'must-understand' directive.
|
|
23
|
+
const NOT_UNDERSTOOD_STATUS_CODES = [
|
|
24
|
+
206, 304
|
|
25
|
+
]
|
|
26
|
+
|
|
18
27
|
const MAX_RESPONSE_AGE = 2147483647000
|
|
19
28
|
|
|
20
29
|
/**
|
|
@@ -241,10 +250,19 @@ class CacheHandler {
|
|
|
241
250
|
* @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives
|
|
242
251
|
*/
|
|
243
252
|
function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirectives) {
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
253
|
+
// Status code must be final and understood.
|
|
254
|
+
if (statusCode < 200 || NOT_UNDERSTOOD_STATUS_CODES.includes(statusCode)) {
|
|
255
|
+
return false
|
|
256
|
+
}
|
|
257
|
+
// Responses with neither status codes that are heuristically cacheable, nor "explicit enough" caching
|
|
258
|
+
// directives, are not cacheable. "Explicit enough": see https://www.rfc-editor.org/rfc/rfc9111.html#section-3
|
|
259
|
+
if (!HEURISTICALLY_CACHEABLE_STATUS_CODES.includes(statusCode) && !resHeaders['expires'] &&
|
|
260
|
+
!cacheControlDirectives.public &&
|
|
261
|
+
cacheControlDirectives['max-age'] === undefined &&
|
|
262
|
+
// RFC 9111: a private response directive, if the cache is not shared
|
|
263
|
+
!(cacheControlDirectives.private && cacheType === 'private') &&
|
|
264
|
+
!(cacheControlDirectives['s-maxage'] !== undefined && cacheType === 'shared')
|
|
265
|
+
) {
|
|
248
266
|
return false
|
|
249
267
|
}
|
|
250
268
|
|
package/lib/interceptor/cache.js
CHANGED
|
@@ -6,7 +6,7 @@ const util = require('../core/util')
|
|
|
6
6
|
const CacheHandler = require('../handler/cache-handler')
|
|
7
7
|
const MemoryCacheStore = require('../cache/memory-cache-store')
|
|
8
8
|
const CacheRevalidationHandler = require('../handler/cache-revalidation-handler')
|
|
9
|
-
const { assertCacheStore, assertCacheMethods, makeCacheKey,
|
|
9
|
+
const { assertCacheStore, assertCacheMethods, makeCacheKey, normalizeHeaders, parseCacheControlHeader } = require('../util/cache.js')
|
|
10
10
|
const { AbortError } = require('../core/errors.js')
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -326,7 +326,7 @@ module.exports = (opts = {}) => {
|
|
|
326
326
|
|
|
327
327
|
opts = {
|
|
328
328
|
...opts,
|
|
329
|
-
headers:
|
|
329
|
+
headers: normalizeHeaders(opts)
|
|
330
330
|
}
|
|
331
331
|
|
|
332
332
|
const reqCacheControl = opts.headers?.['cache-control']
|
|
@@ -5,6 +5,7 @@ const MockAgent = require('./mock-agent')
|
|
|
5
5
|
const { SnapshotRecorder } = require('./snapshot-recorder')
|
|
6
6
|
const WrapHandler = require('../handler/wrap-handler')
|
|
7
7
|
const { InvalidArgumentError, UndiciError } = require('../core/errors')
|
|
8
|
+
const { validateSnapshotMode } = require('./snapshot-utils')
|
|
8
9
|
|
|
9
10
|
const kSnapshotRecorder = Symbol('kSnapshotRecorder')
|
|
10
11
|
const kSnapshotMode = Symbol('kSnapshotMode')
|
|
@@ -12,7 +13,7 @@ const kSnapshotPath = Symbol('kSnapshotPath')
|
|
|
12
13
|
const kSnapshotLoaded = Symbol('kSnapshotLoaded')
|
|
13
14
|
const kRealAgent = Symbol('kRealAgent')
|
|
14
15
|
|
|
15
|
-
// Static flag to ensure warning is only emitted once
|
|
16
|
+
// Static flag to ensure warning is only emitted once per process
|
|
16
17
|
let warningEmitted = false
|
|
17
18
|
|
|
18
19
|
class SnapshotAgent extends MockAgent {
|
|
@@ -26,26 +27,24 @@ class SnapshotAgent extends MockAgent {
|
|
|
26
27
|
warningEmitted = true
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
const {
|
|
31
|
+
mode = 'record',
|
|
32
|
+
snapshotPath = null,
|
|
33
|
+
...mockAgentOpts
|
|
34
|
+
} = opts
|
|
32
35
|
|
|
33
|
-
super(
|
|
36
|
+
super(mockAgentOpts)
|
|
34
37
|
|
|
35
|
-
|
|
36
|
-
const validModes = ['record', 'playback', 'update']
|
|
37
|
-
const mode = opts.mode || 'record'
|
|
38
|
-
if (!validModes.includes(mode)) {
|
|
39
|
-
throw new InvalidArgumentError(`Invalid snapshot mode: ${mode}. Must be one of: ${validModes.join(', ')}`)
|
|
40
|
-
}
|
|
38
|
+
validateSnapshotMode(mode)
|
|
41
39
|
|
|
42
40
|
// Validate snapshotPath is provided when required
|
|
43
|
-
if ((mode === 'playback' || mode === 'update') && !
|
|
41
|
+
if ((mode === 'playback' || mode === 'update') && !snapshotPath) {
|
|
44
42
|
throw new InvalidArgumentError(`snapshotPath is required when mode is '${mode}'`)
|
|
45
43
|
}
|
|
46
44
|
|
|
47
45
|
this[kSnapshotMode] = mode
|
|
48
|
-
this[kSnapshotPath] =
|
|
46
|
+
this[kSnapshotPath] = snapshotPath
|
|
47
|
+
|
|
49
48
|
this[kSnapshotRecorder] = new SnapshotRecorder({
|
|
50
49
|
snapshotPath: this[kSnapshotPath],
|
|
51
50
|
mode: this[kSnapshotMode],
|
|
@@ -85,7 +84,7 @@ class SnapshotAgent extends MockAgent {
|
|
|
85
84
|
// Ensure snapshots are loaded
|
|
86
85
|
if (!this[kSnapshotLoaded]) {
|
|
87
86
|
// Need to load asynchronously, delegate to async version
|
|
88
|
-
return this
|
|
87
|
+
return this.#asyncDispatch(opts, handler)
|
|
89
88
|
}
|
|
90
89
|
|
|
91
90
|
// Try to find existing snapshot (synchronous)
|
|
@@ -93,10 +92,10 @@ class SnapshotAgent extends MockAgent {
|
|
|
93
92
|
|
|
94
93
|
if (snapshot) {
|
|
95
94
|
// Use recorded response (synchronous)
|
|
96
|
-
return this
|
|
95
|
+
return this.#replaySnapshot(snapshot, handler)
|
|
97
96
|
} else if (mode === 'update') {
|
|
98
97
|
// Make real request and record it (async required)
|
|
99
|
-
return this
|
|
98
|
+
return this.#recordAndReplay(opts, handler)
|
|
100
99
|
} else {
|
|
101
100
|
// Playback mode but no snapshot found
|
|
102
101
|
const error = new UndiciError(`No snapshot found for ${opts.method || 'GET'} ${opts.path}`)
|
|
@@ -108,16 +107,14 @@ class SnapshotAgent extends MockAgent {
|
|
|
108
107
|
}
|
|
109
108
|
} else if (mode === 'record') {
|
|
110
109
|
// Record mode - make real request and save response (async required)
|
|
111
|
-
return this
|
|
112
|
-
} else {
|
|
113
|
-
throw new InvalidArgumentError(`Invalid snapshot mode: ${mode}. Must be 'record', 'playback', or 'update'`)
|
|
110
|
+
return this.#recordAndReplay(opts, handler)
|
|
114
111
|
}
|
|
115
112
|
}
|
|
116
113
|
|
|
117
114
|
/**
|
|
118
115
|
* Async version of dispatch for when we need to load snapshots first
|
|
119
116
|
*/
|
|
120
|
-
async
|
|
117
|
+
async #asyncDispatch (opts, handler) {
|
|
121
118
|
await this.loadSnapshots()
|
|
122
119
|
return this.dispatch(opts, handler)
|
|
123
120
|
}
|
|
@@ -125,7 +122,7 @@ class SnapshotAgent extends MockAgent {
|
|
|
125
122
|
/**
|
|
126
123
|
* Records a real request and replays the response
|
|
127
124
|
*/
|
|
128
|
-
|
|
125
|
+
#recordAndReplay (opts, handler) {
|
|
129
126
|
const responseData = {
|
|
130
127
|
statusCode: null,
|
|
131
128
|
headers: {},
|
|
@@ -180,45 +177,46 @@ class SnapshotAgent extends MockAgent {
|
|
|
180
177
|
|
|
181
178
|
/**
|
|
182
179
|
* Replays a recorded response
|
|
180
|
+
*
|
|
181
|
+
* @param {Object} snapshot - The recorded snapshot to replay.
|
|
182
|
+
* @param {Object} handler - The handler to call with the response data.
|
|
183
|
+
* @returns {void}
|
|
183
184
|
*/
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
resolve()
|
|
213
|
-
} catch (error) {
|
|
214
|
-
handler.onError?.(error)
|
|
215
|
-
}
|
|
216
|
-
})
|
|
217
|
-
})
|
|
185
|
+
#replaySnapshot (snapshot, handler) {
|
|
186
|
+
try {
|
|
187
|
+
const { response } = snapshot
|
|
188
|
+
|
|
189
|
+
const controller = {
|
|
190
|
+
pause () { },
|
|
191
|
+
resume () { },
|
|
192
|
+
abort (reason) {
|
|
193
|
+
this.aborted = true
|
|
194
|
+
this.reason = reason
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
aborted: false,
|
|
198
|
+
paused: false
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
handler.onRequestStart(controller)
|
|
202
|
+
|
|
203
|
+
handler.onResponseStart(controller, response.statusCode, response.headers)
|
|
204
|
+
|
|
205
|
+
// Body is always stored as base64 string
|
|
206
|
+
const body = Buffer.from(response.body, 'base64')
|
|
207
|
+
handler.onResponseData(controller, body)
|
|
208
|
+
|
|
209
|
+
handler.onResponseEnd(controller, response.trailers)
|
|
210
|
+
} catch (error) {
|
|
211
|
+
handler.onError?.(error)
|
|
212
|
+
}
|
|
218
213
|
}
|
|
219
214
|
|
|
220
215
|
/**
|
|
221
216
|
* Loads snapshots from file
|
|
217
|
+
*
|
|
218
|
+
* @param {string} [filePath] - Optional file path to load snapshots from.
|
|
219
|
+
* @returns {Promise<void>} - Resolves when snapshots are loaded.
|
|
222
220
|
*/
|
|
223
221
|
async loadSnapshots (filePath) {
|
|
224
222
|
await this[kSnapshotRecorder].loadSnapshots(filePath || this[kSnapshotPath])
|
|
@@ -226,12 +224,15 @@ class SnapshotAgent extends MockAgent {
|
|
|
226
224
|
|
|
227
225
|
// In playback mode, set up MockAgent interceptors for all snapshots
|
|
228
226
|
if (this[kSnapshotMode] === 'playback') {
|
|
229
|
-
this
|
|
227
|
+
this.#setupMockInterceptors()
|
|
230
228
|
}
|
|
231
229
|
}
|
|
232
230
|
|
|
233
231
|
/**
|
|
234
232
|
* Saves snapshots to file
|
|
233
|
+
*
|
|
234
|
+
* @param {string} [filePath] - Optional file path to save snapshots to.
|
|
235
|
+
* @returns {Promise<void>} - Resolves when snapshots are saved.
|
|
235
236
|
*/
|
|
236
237
|
async saveSnapshots (filePath) {
|
|
237
238
|
return this[kSnapshotRecorder].saveSnapshots(filePath || this[kSnapshotPath])
|
|
@@ -248,9 +249,9 @@ class SnapshotAgent extends MockAgent {
|
|
|
248
249
|
*
|
|
249
250
|
* Called automatically when loading snapshots in playback mode.
|
|
250
251
|
*
|
|
251
|
-
* @
|
|
252
|
+
* @returns {void}
|
|
252
253
|
*/
|
|
253
|
-
|
|
254
|
+
#setupMockInterceptors () {
|
|
254
255
|
for (const snapshot of this[kSnapshotRecorder].getSnapshots()) {
|
|
255
256
|
const { request, responses, response } = snapshot
|
|
256
257
|
const url = new URL(request.url)
|
|
@@ -275,6 +276,7 @@ class SnapshotAgent extends MockAgent {
|
|
|
275
276
|
|
|
276
277
|
/**
|
|
277
278
|
* Gets the snapshot recorder
|
|
279
|
+
* @return {SnapshotRecorder} - The snapshot recorder instance
|
|
278
280
|
*/
|
|
279
281
|
getRecorder () {
|
|
280
282
|
return this[kSnapshotRecorder]
|
|
@@ -282,6 +284,7 @@ class SnapshotAgent extends MockAgent {
|
|
|
282
284
|
|
|
283
285
|
/**
|
|
284
286
|
* Gets the current mode
|
|
287
|
+
* @return {import('./snapshot-utils').SnapshotMode} - The current snapshot mode
|
|
285
288
|
*/
|
|
286
289
|
getMode () {
|
|
287
290
|
return this[kSnapshotMode]
|
|
@@ -289,6 +292,7 @@ class SnapshotAgent extends MockAgent {
|
|
|
289
292
|
|
|
290
293
|
/**
|
|
291
294
|
* Clears all snapshots
|
|
295
|
+
* @returns {void}
|
|
292
296
|
*/
|
|
293
297
|
clearSnapshots () {
|
|
294
298
|
this[kSnapshotRecorder].clear()
|
|
@@ -296,6 +300,7 @@ class SnapshotAgent extends MockAgent {
|
|
|
296
300
|
|
|
297
301
|
/**
|
|
298
302
|
* Resets call counts for all snapshots (useful for test cleanup)
|
|
303
|
+
* @returns {void}
|
|
299
304
|
*/
|
|
300
305
|
resetCallCounts () {
|
|
301
306
|
this[kSnapshotRecorder].resetCallCounts()
|
|
@@ -303,6 +308,8 @@ class SnapshotAgent extends MockAgent {
|
|
|
303
308
|
|
|
304
309
|
/**
|
|
305
310
|
* Deletes a specific snapshot by request options
|
|
311
|
+
* @param {import('./snapshot-recorder').SnapshotRequestOptions} requestOpts - Request options to identify the snapshot
|
|
312
|
+
* @return {Promise<boolean>} - Returns true if the snapshot was deleted, false if not found
|
|
306
313
|
*/
|
|
307
314
|
deleteSnapshot (requestOpts) {
|
|
308
315
|
return this[kSnapshotRecorder].deleteSnapshot(requestOpts)
|
|
@@ -310,6 +317,7 @@ class SnapshotAgent extends MockAgent {
|
|
|
310
317
|
|
|
311
318
|
/**
|
|
312
319
|
* Gets information about a specific snapshot
|
|
320
|
+
* @returns {import('./snapshot-recorder').SnapshotInfo|null} - Snapshot information or null if not found
|
|
313
321
|
*/
|
|
314
322
|
getSnapshotInfo (requestOpts) {
|
|
315
323
|
return this[kSnapshotRecorder].getSnapshotInfo(requestOpts)
|
|
@@ -317,13 +325,19 @@ class SnapshotAgent extends MockAgent {
|
|
|
317
325
|
|
|
318
326
|
/**
|
|
319
327
|
* Replaces all snapshots with new data (full replacement)
|
|
328
|
+
* @param {Array<{hash: string; snapshot: import('./snapshot-recorder').SnapshotEntryshotEntry}>|Record<string, import('./snapshot-recorder').SnapshotEntry>} snapshotData - New snapshot data to replace existing snapshots
|
|
329
|
+
* @returns {void}
|
|
320
330
|
*/
|
|
321
331
|
replaceSnapshots (snapshotData) {
|
|
322
332
|
this[kSnapshotRecorder].replaceSnapshots(snapshotData)
|
|
323
333
|
}
|
|
324
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Closes the agent, saving snapshots and cleaning up resources.
|
|
337
|
+
*
|
|
338
|
+
* @returns {Promise<void>}
|
|
339
|
+
*/
|
|
325
340
|
async close () {
|
|
326
|
-
// Close recorder (saves snapshots and cleans up timers)
|
|
327
341
|
await this[kSnapshotRecorder].close()
|
|
328
342
|
await this[kRealAgent]?.close()
|
|
329
343
|
await super.close()
|