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 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 | End of Life |
626
- |---------|-------------|-------------|
627
- | 5.x | v18.x | 2024-04-30 |
628
- | 6.x | v20.x v22.x | 2026-04-30 |
629
- | 7.x | v24.x | 2027-04-30 |
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(({ address, protocol, extensions, websocket }) => {
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('../types/proxy-agent').ProxyAgent.Options | string | URL} opts
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
- // Allow caching for status codes 200 and 307 (original behavior)
245
- // Also allow caching for other status codes that are heuristically cacheable
246
- // when they have explicit cache directives
247
- if (statusCode !== 200 && statusCode !== 307 && !HEURISTICALLY_CACHEABLE_STATUS_CODES.includes(statusCode)) {
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
 
@@ -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, normaliseHeaders, parseCacheControlHeader } = require('../util/cache.js')
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: normaliseHeaders(opts)
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 mockOptions = { ...opts }
30
- delete mockOptions.mode
31
- delete mockOptions.snapshotPath
30
+ const {
31
+ mode = 'record',
32
+ snapshotPath = null,
33
+ ...mockAgentOpts
34
+ } = opts
32
35
 
33
- super(mockOptions)
36
+ super(mockAgentOpts)
34
37
 
35
- // Validate mode option
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') && !opts.snapshotPath) {
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] = opts.snapshotPath
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._asyncDispatch(opts, handler)
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._replaySnapshot(snapshot, handler)
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._recordAndReplay(opts, handler)
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._recordAndReplay(opts, handler)
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 _asyncDispatch (opts, handler) {
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
- _recordAndReplay (opts, handler) {
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
- _replaySnapshot (snapshot, handler) {
185
- return new Promise((resolve) => {
186
- // Simulate the response
187
- setImmediate(() => {
188
- try {
189
- const { response } = snapshot
190
-
191
- const controller = {
192
- pause () {},
193
- resume () {},
194
- abort (reason) {
195
- this.aborted = true
196
- this.reason = reason
197
- },
198
-
199
- aborted: false,
200
- paused: false
201
- }
202
-
203
- handler.onRequestStart(controller)
204
-
205
- handler.onResponseStart(controller, response.statusCode, response.headers)
206
-
207
- // Body is always stored as base64 string
208
- const body = Buffer.from(response.body, 'base64')
209
- handler.onResponseData(controller, body)
210
-
211
- handler.onResponseEnd(controller, response.trailers)
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._setupMockInterceptors()
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
- * @private
252
+ * @returns {void}
252
253
  */
253
- _setupMockInterceptors () {
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()