undici 8.2.0 → 8.3.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
@@ -200,7 +200,9 @@ await fetch('https://example.com', {
200
200
  ```
201
201
 
202
202
  `install()` replaces the global `fetch`, `Headers`, `Response`, `Request`, and
203
- `FormData` implementations with undici's versions, so they all match.
203
+ `FormData` implementations with undici's versions, so they all match. It also
204
+ installs undici's `WebSocket`, `CloseEvent`, `ErrorEvent`, `MessageEvent`, and
205
+ `EventSource` globals.
204
206
 
205
207
  Avoid mixing a global `FormData` with `undici.fetch()`, or `undici.FormData`
206
208
  with the built-in global `fetch()`.
@@ -283,12 +285,12 @@ const data2 = await getData();
283
285
 
284
286
  ## Global Installation
285
287
 
286
- Undici provides an `install()` function to add all WHATWG fetch classes to `globalThis`, making them available globally:
288
+ Undici provides an `install()` function to add fetch-related and other web API classes to `globalThis`, making them available globally:
287
289
 
288
290
  ```js
289
291
  import { install } from 'undici'
290
292
 
291
- // Install all WHATWG fetch classes globally
293
+ // Install undici's global web APIs
292
294
  install()
293
295
 
294
296
  // Now you can use fetch classes globally without importing
@@ -316,8 +318,9 @@ The `install()` function adds the following classes to `globalThis`:
316
318
 
317
319
  When you call `install()`, these globals come from the same undici
318
320
  implementation. For example, global `fetch` and global `FormData` will both be
319
- undici's versions, which is the recommended setup if you want to use undici
320
- through globals.
321
+ undici's versions, and `WebSocket` and `EventSource` will also come from
322
+ undici, which is the recommended setup if you want to use undici through
323
+ globals.
321
324
 
322
325
  This is useful for:
323
326
  - Polyfilling environments that don't have fetch
@@ -1,17 +1,17 @@
1
1
  # Global Installation
2
2
 
3
- Undici provides an `install()` function to add all WHATWG fetch classes to `globalThis`, making them available globally without requiring imports.
3
+ Undici provides an `install()` function to add fetch-related and other web API classes to `globalThis`, making them available globally without requiring imports.
4
4
 
5
5
  ## `install()`
6
6
 
7
- Install all WHATWG fetch classes globally on `globalThis`.
7
+ Install undici's global web APIs on `globalThis`.
8
8
 
9
9
  **Example:**
10
10
 
11
11
  ```js
12
12
  import { install } from 'undici'
13
13
 
14
- // Install all WHATWG fetch classes globally
14
+ // Install undici's global web APIs
15
15
  install()
16
16
 
17
17
  // Now you can use fetch classes globally without importing
@@ -74,6 +74,8 @@ await fetch('https://example.com', {
74
74
 
75
75
  After `install()`, `fetch`, `Headers`, `Response`, `Request`, and `FormData`
76
76
  all come from the installed `undici` package, so they work as a matching set.
77
+ `WebSocket`, `CloseEvent`, `ErrorEvent`, `MessageEvent`, and `EventSource`
78
+ also come from the installed `undici` package.
77
79
 
78
80
  If you do not want to install globals, import both from `undici` instead:
79
81
 
@@ -135,5 +137,5 @@ test('fetch API test', async () => {
135
137
 
136
138
  - The `install()` function overwrites any existing global implementations
137
139
  - Classes installed are undici's implementations, not Node.js built-ins
138
- - This provides access to undici's latest features and performance improvements
139
- - The global installation persists for the lifetime of the process
140
+ - This provides access to undici's latest fetch, WebSocket, and EventSource features and performance improvements
141
+ - The global installation persists for the lifetime of the process
@@ -27,7 +27,9 @@ new SnapshotAgent([options])
27
27
  - **ignoreHeaders** `Array<String>` - Headers to ignore during request matching
28
28
  - **excludeHeaders** `Array<String>` - Headers to exclude from snapshots (for security)
29
29
  - **matchBody** `Boolean` - Whether to include request body in matching. Default: `true`
30
+ - **normalizeBody** `Function` - Optional function `(body) => string` to normalize the request body before matching (e.g. strip volatile fields like timestamps). Only used when `matchBody` is `true`.
30
31
  - **matchQuery** `Boolean` - Whether to include query parameters in matching. Default: `true`
32
+ - **normalizeQuery** `Function` - Optional function `(query: URLSearchParams) => string` to normalize query parameters before matching (e.g. strip volatile params like cache-busters). Only used when `matchQuery` is `true`.
31
33
  - **caseSensitive** `Boolean` - Whether header matching is case-sensitive. Default: `false`
32
34
  - **shouldRecord** `Function` - Callback to determine if a request should be recorded
33
35
  - **shouldPlayback** `Function` - Callback to determine if a request should be played back
@@ -108,6 +110,27 @@ await agent.saveSnapshots('./custom-snapshots.json')
108
110
 
109
111
  ## Advanced Configuration
110
112
 
113
+ ### Body Matching
114
+
115
+ By default (`matchBody: true`) the full request body string is included in the snapshot key. Set it to `false` to ignore the body entirely, or use `normalizeBody` to strip volatile fields (like timestamps) before matching:
116
+
117
+ ```javascript
118
+ const agent = new SnapshotAgent({
119
+ mode: 'playback',
120
+ snapshotPath: './snapshots.json',
121
+
122
+ // Match on everything except the timestamp field
123
+ normalizeBody: (body) => {
124
+ if (!body) return ''
125
+ const parsed = JSON.parse(String(body))
126
+ delete parsed.timestamp
127
+ return JSON.stringify(parsed)
128
+ }
129
+ })
130
+ ```
131
+
132
+ `normalizeBody` receives the raw body (`string | Buffer | null | undefined`) and must return a `string`. It runs at both record and playback time so the hash is consistent. Two requests match the same snapshot whenever their normalized strings are identical.
133
+
111
134
  ### Header Filtering
112
135
 
113
136
  Control which headers are used for request matching and what gets stored in snapshots:
@@ -13,6 +13,7 @@ const {
13
13
  RequestAbortedError
14
14
  } = require('../core/errors')
15
15
  const util = require('../core/util')
16
+ const { kBodyUsed } = require('../core/symbols')
16
17
  const { addSignal, removeSignal } = require('./abort-signal')
17
18
 
18
19
  function noop () {}
@@ -24,6 +25,9 @@ class PipelineRequest extends Readable {
24
25
  super({ autoDestroy: true })
25
26
 
26
27
  this[kResume] = null
28
+ // Pipeline request bodies come from a live writable side and cannot be
29
+ // replayed across redirects or retries, even before any bytes are read.
30
+ this[kBodyUsed] = true
27
31
  }
28
32
 
29
33
  _read () {
@@ -1,7 +1,6 @@
1
1
  'use strict'
2
2
 
3
3
  const assert = require('node:assert')
4
- const { finished } = require('node:stream')
5
4
  const { AsyncResource } = require('node:async_hooks')
6
5
  const { InvalidArgumentError, InvalidReturnValueError } = require('../core/errors')
7
6
  const util = require('../core/util')
@@ -9,6 +8,54 @@ const { addSignal, removeSignal } = require('./abort-signal')
9
8
 
10
9
  function noop () {}
11
10
 
11
+ function getWritableError (stream) {
12
+ return stream.errored ?? stream.writableErrored ?? stream._writableState?.errored
13
+ }
14
+
15
+ function createPrematureCloseError () {
16
+ const err = new Error('Premature close')
17
+ err.code = 'ERR_STREAM_PREMATURE_CLOSE'
18
+ return err
19
+ }
20
+
21
+ function trackWritableLifecycle (stream, callback) {
22
+ let done = false
23
+
24
+ const cleanup = () => {
25
+ stream.removeListener('close', onClose)
26
+ stream.removeListener('error', onError)
27
+ stream.removeListener('finish', onFinish)
28
+ }
29
+
30
+ const finish = (err, fromErrorEvent = false) => {
31
+ if (done) {
32
+ return
33
+ }
34
+
35
+ done = true
36
+ cleanup()
37
+ callback(err, fromErrorEvent)
38
+ }
39
+
40
+ const onClose = () => {
41
+ const err = getWritableError(stream)
42
+ finish(err ?? (!stream.writableFinished ? createPrematureCloseError() : undefined))
43
+ }
44
+
45
+ const onError = (err) => finish(err, true)
46
+ const onFinish = () => finish()
47
+
48
+ stream.on('close', onClose)
49
+ stream.on('error', onError)
50
+ stream.on('finish', onFinish)
51
+
52
+ if (stream.closed) {
53
+ process.nextTick(onClose)
54
+ } else if (stream.writableFinished) {
55
+ process.nextTick(onFinish)
56
+ }
57
+ }
58
+
12
59
  class StreamHandler extends AsyncResource {
13
60
  constructor (opts, factory, callback) {
14
61
  if (!opts || typeof opts !== 'object') {
@@ -117,20 +164,19 @@ class StreamHandler extends AsyncResource {
117
164
  throw new InvalidReturnValueError('expected Writable')
118
165
  }
119
166
 
120
- // TODO: Avoid finished. It registers an unnecessary amount of listeners.
121
- finished(res, { readable: false }, (err) => {
167
+ trackWritableLifecycle(res, (err, fromErrorEvent) => {
122
168
  const { callback, res, opaque, trailers, abort } = this
123
169
 
124
170
  this.res = null
125
171
  if (err || !res?.readable) {
126
- util.destroy(res, err)
172
+ util.destroy(res, fromErrorEvent ? undefined : err)
127
173
  }
128
174
 
129
175
  this.callback = null
130
176
  this.runInAsyncScope(callback, null, err || null, { opaque, trailers })
131
177
 
132
178
  if (err) {
133
- abort()
179
+ abort(err)
134
180
  }
135
181
  })
136
182
 
@@ -62,6 +62,7 @@ module.exports = {
62
62
  kListeners: Symbol('listeners'),
63
63
  kHTTPContext: Symbol('http context'),
64
64
  kMaxConcurrentStreams: Symbol('max concurrent streams'),
65
+ kHostAuthority: Symbol('host authority'),
65
66
  kHTTP2InitialWindowSize: Symbol('http2 initial window size'),
66
67
  kHTTP2ConnectionWindowSize: Symbol('http2 connection window size'),
67
68
  kEnableConnectProtocol: Symbol('http2session connect protocol'),
package/lib/core/util.js CHANGED
@@ -776,7 +776,7 @@ function isValidHeaderValue (characters) {
776
776
  return !headerCharRegex.test(characters)
777
777
  }
778
778
 
779
- const rangeHeaderRegex = /^bytes (\d+)-(\d+)\/(\d+)?$/
779
+ const rangeHeaderRegex = /^bytes (\d+)-(\d+)\/(\d+|\*)?$/
780
780
 
781
781
  /**
782
782
  * @typedef {object} RangeHeader
@@ -799,7 +799,7 @@ function parseRangeHeader (range) {
799
799
  ? {
800
800
  start: parseInt(m[1]),
801
801
  end: m[2] ? parseInt(m[2]) : null,
802
- size: m[3] ? parseInt(m[3]) : null
802
+ size: m[3] && m[3] !== '*' ? parseInt(m[3]) : null
803
803
  }
804
804
  : null
805
805
  }
@@ -360,16 +360,7 @@ class Parser {
360
360
  this.paused = true
361
361
  socket.unshift(data)
362
362
  } else {
363
- const ptr = llhttp.llhttp_get_error_reason(this.ptr)
364
- let message = ''
365
- if (ptr) {
366
- const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0)
367
- message =
368
- 'Response does not match the HTTP/1.1 protocol (' +
369
- Buffer.from(llhttp.memory.buffer, ptr, len).toString() +
370
- ')'
371
- }
372
- throw new HTTPParserError(message, constants.ERROR[ret], data)
363
+ throw this.createError(ret, data)
373
364
  }
374
365
  }
375
366
  } catch (err) {
@@ -377,6 +368,54 @@ class Parser {
377
368
  }
378
369
  }
379
370
 
371
+ finish () {
372
+ assert(currentParser === null)
373
+ assert(this.ptr != null)
374
+ assert(!this.paused)
375
+
376
+ const { llhttp } = this
377
+
378
+ let ret
379
+
380
+ try {
381
+ currentParser = this
382
+ ret = llhttp.llhttp_finish(this.ptr)
383
+ } finally {
384
+ currentParser = null
385
+ }
386
+
387
+ if (ret === constants.ERROR.OK) {
388
+ return null
389
+ }
390
+
391
+ if (ret === constants.ERROR.PAUSED || ret === constants.ERROR.PAUSED_UPGRADE) {
392
+ this.paused = true
393
+ return null
394
+ }
395
+
396
+ return this.createError(ret, EMPTY_BUF)
397
+ }
398
+
399
+ createError (ret, data) {
400
+ const { llhttp, contentLength, bytesRead } = this
401
+
402
+ if (contentLength !== -1 && bytesRead !== contentLength) {
403
+ return new ResponseContentLengthMismatchError()
404
+ }
405
+
406
+ const ptr = llhttp.llhttp_get_error_reason(this.ptr)
407
+ let message = ''
408
+ if (ptr) {
409
+ const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0)
410
+ message =
411
+ 'Response does not match the HTTP/1.1 protocol (' +
412
+ Buffer.from(llhttp.memory.buffer, ptr, len).toString() +
413
+ ')'
414
+ }
415
+
416
+ return new HTTPParserError(message, constants.ERROR[ret], data)
417
+ }
418
+
380
419
  destroy () {
381
420
  assert(currentParser === null)
382
421
  assert(this.ptr != null)
@@ -888,8 +927,11 @@ function onHttpSocketError (err) {
888
927
  // On Mac OS, we get an ECONNRESET even if there is a full body to be forwarded
889
928
  // to the user.
890
929
  if (err.code === 'ECONNRESET' && parser.statusCode && !parser.shouldKeepAlive) {
891
- // We treat all incoming data so for as a valid response.
892
- parser.onMessageComplete()
930
+ const parserErr = parser.finish()
931
+ if (parserErr) {
932
+ this[kError] = parserErr
933
+ this[kClient][kOnError](parserErr)
934
+ }
893
935
  return
894
936
  }
895
937
 
@@ -906,8 +948,10 @@ function onHttpSocketEnd () {
906
948
  const parser = this[kParser]
907
949
 
908
950
  if (parser.statusCode && !parser.shouldKeepAlive) {
909
- // We treat all incoming data so far as a valid response.
910
- parser.onMessageComplete()
951
+ const parserErr = parser.finish()
952
+ if (parserErr) {
953
+ util.destroy(this, parserErr)
954
+ }
911
955
  return
912
956
  }
913
957
 
@@ -919,8 +963,7 @@ function onHttpSocketClose () {
919
963
 
920
964
  if (parser) {
921
965
  if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) {
922
- // We treat all incoming data so far as a valid response.
923
- parser.onMessageComplete()
966
+ this[kError] = parser.finish() || this[kError]
924
967
  }
925
968
 
926
969
  this[kParser].destroy()
@@ -1382,8 +1425,6 @@ function writeBuffer (abort, body, client, request, socket, contentLength, heade
1382
1425
  * @returns {Promise<void>}
1383
1426
  */
1384
1427
  async function writeBlob (abort, body, client, request, socket, contentLength, header, expectsPayload) {
1385
- assert(contentLength === body.size, 'blob body must have content length')
1386
-
1387
1428
  try {
1388
1429
  if (contentLength != null && contentLength !== body.size) {
1389
1430
  throw new RequestContentLengthMismatchError()