Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions deps/undici/src/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,5 @@ AGENTS.md

# Ignore .githuman
.githuman

benchmarks/package-lock.json
13 changes: 8 additions & 5 deletions deps/undici/src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,9 @@ await fetch('https://example.com', {
```

`install()` replaces the global `fetch`, `Headers`, `Response`, `Request`, and
`FormData` implementations with undici's versions, so they all match.
`FormData` implementations with undici's versions, so they all match. It also
installs undici's `WebSocket`, `CloseEvent`, `ErrorEvent`, `MessageEvent`, and
`EventSource` globals.

Avoid mixing a global `FormData` with `undici.fetch()`, or `undici.FormData`
with the built-in global `fetch()`.
Expand Down Expand Up @@ -283,12 +285,12 @@ const data2 = await getData();

## Global Installation

Undici provides an `install()` function to add all WHATWG fetch classes to `globalThis`, making them available globally:
Undici provides an `install()` function to add fetch-related and other web API classes to `globalThis`, making them available globally:

```js
import { install } from 'undici'

// Install all WHATWG fetch classes globally
// Install undici's global web APIs
install()

// Now you can use fetch classes globally without importing
Expand Down Expand Up @@ -316,8 +318,9 @@ The `install()` function adds the following classes to `globalThis`:

When you call `install()`, these globals come from the same undici
implementation. For example, global `fetch` and global `FormData` will both be
undici's versions, which is the recommended setup if you want to use undici
through globals.
undici's versions, and `WebSocket` and `EventSource` will also come from
undici, which is the recommended setup if you want to use undici through
globals.

This is useful for:
- Polyfilling environments that don't have fetch
Expand Down
12 changes: 7 additions & 5 deletions deps/undici/src/docs/docs/api/GlobalInstallation.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
# Global Installation

Undici provides an `install()` function to add all WHATWG fetch classes to `globalThis`, making them available globally without requiring imports.
Undici provides an `install()` function to add fetch-related and other web API classes to `globalThis`, making them available globally without requiring imports.

## `install()`

Install all WHATWG fetch classes globally on `globalThis`.
Install undici's global web APIs on `globalThis`.

**Example:**

```js
import { install } from 'undici'

// Install all WHATWG fetch classes globally
// Install undici's global web APIs
install()

// Now you can use fetch classes globally without importing
Expand Down Expand Up @@ -74,6 +74,8 @@ await fetch('https://example.com', {

After `install()`, `fetch`, `Headers`, `Response`, `Request`, and `FormData`
all come from the installed `undici` package, so they work as a matching set.
`WebSocket`, `CloseEvent`, `ErrorEvent`, `MessageEvent`, and `EventSource`
also come from the installed `undici` package.

If you do not want to install globals, import both from `undici` instead:

Expand Down Expand Up @@ -135,5 +137,5 @@ test('fetch API test', async () => {

- The `install()` function overwrites any existing global implementations
- Classes installed are undici's implementations, not Node.js built-ins
- This provides access to undici's latest features and performance improvements
- The global installation persists for the lifetime of the process
- This provides access to undici's latest fetch, WebSocket, and EventSource features and performance improvements
- The global installation persists for the lifetime of the process
23 changes: 23 additions & 0 deletions deps/undici/src/docs/docs/api/SnapshotAgent.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ new SnapshotAgent([options])
- **ignoreHeaders** `Array<String>` - Headers to ignore during request matching
- **excludeHeaders** `Array<String>` - Headers to exclude from snapshots (for security)
- **matchBody** `Boolean` - Whether to include request body in matching. Default: `true`
- **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`.
- **matchQuery** `Boolean` - Whether to include query parameters in matching. Default: `true`
- **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`.
- **caseSensitive** `Boolean` - Whether header matching is case-sensitive. Default: `false`
- **shouldRecord** `Function` - Callback to determine if a request should be recorded
- **shouldPlayback** `Function` - Callback to determine if a request should be played back
Expand Down Expand Up @@ -108,6 +110,27 @@ await agent.saveSnapshots('./custom-snapshots.json')

## Advanced Configuration

### Body Matching

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:

```javascript
const agent = new SnapshotAgent({
mode: 'playback',
snapshotPath: './snapshots.json',

// Match on everything except the timestamp field
normalizeBody: (body) => {
if (!body) return ''
const parsed = JSON.parse(String(body))
delete parsed.timestamp
return JSON.stringify(parsed)
}
})
```

`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.

### Header Filtering

Control which headers are used for request matching and what gets stored in snapshots:
Expand Down
4 changes: 4 additions & 0 deletions deps/undici/src/lib/api/api-pipeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const {
RequestAbortedError
} = require('../core/errors')
const util = require('../core/util')
const { kBodyUsed } = require('../core/symbols')
const { addSignal, removeSignal } = require('./abort-signal')

function noop () {}
Expand All @@ -24,6 +25,9 @@ class PipelineRequest extends Readable {
super({ autoDestroy: true })

this[kResume] = null
// Pipeline request bodies come from a live writable side and cannot be
// replayed across redirects or retries, even before any bytes are read.
this[kBodyUsed] = true
}

_read () {
Expand Down
56 changes: 51 additions & 5 deletions deps/undici/src/lib/api/api-stream.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,61 @@
'use strict'

const assert = require('node:assert')
const { finished } = require('node:stream')
const { AsyncResource } = require('node:async_hooks')
const { InvalidArgumentError, InvalidReturnValueError } = require('../core/errors')
const util = require('../core/util')
const { addSignal, removeSignal } = require('./abort-signal')

function noop () {}

function getWritableError (stream) {
return stream.errored ?? stream.writableErrored ?? stream._writableState?.errored
}

function createPrematureCloseError () {
const err = new Error('Premature close')
err.code = 'ERR_STREAM_PREMATURE_CLOSE'
return err
}

function trackWritableLifecycle (stream, callback) {
let done = false

const cleanup = () => {
stream.removeListener('close', onClose)
stream.removeListener('error', onError)
stream.removeListener('finish', onFinish)
}

const finish = (err, fromErrorEvent = false) => {
if (done) {
return
}

done = true
cleanup()
callback(err, fromErrorEvent)
}

const onClose = () => {
const err = getWritableError(stream)
finish(err ?? (!stream.writableFinished ? createPrematureCloseError() : undefined))
}

const onError = (err) => finish(err, true)
const onFinish = () => finish()

stream.on('close', onClose)
stream.on('error', onError)
stream.on('finish', onFinish)

if (stream.closed) {
process.nextTick(onClose)
} else if (stream.writableFinished) {
process.nextTick(onFinish)
}
}

class StreamHandler extends AsyncResource {
constructor (opts, factory, callback) {
if (!opts || typeof opts !== 'object') {
Expand Down Expand Up @@ -117,20 +164,19 @@ class StreamHandler extends AsyncResource {
throw new InvalidReturnValueError('expected Writable')
}

// TODO: Avoid finished. It registers an unnecessary amount of listeners.
finished(res, { readable: false }, (err) => {
trackWritableLifecycle(res, (err, fromErrorEvent) => {
const { callback, res, opaque, trailers, abort } = this

this.res = null
if (err || !res?.readable) {
util.destroy(res, err)
util.destroy(res, fromErrorEvent ? undefined : err)
}

this.callback = null
this.runInAsyncScope(callback, null, err || null, { opaque, trailers })

if (err) {
abort()
abort(err)
}
})

Expand Down
1 change: 1 addition & 0 deletions deps/undici/src/lib/core/symbols.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ module.exports = {
kListeners: Symbol('listeners'),
kHTTPContext: Symbol('http context'),
kMaxConcurrentStreams: Symbol('max concurrent streams'),
kHostAuthority: Symbol('host authority'),
kHTTP2InitialWindowSize: Symbol('http2 initial window size'),
kHTTP2ConnectionWindowSize: Symbol('http2 connection window size'),
kEnableConnectProtocol: Symbol('http2session connect protocol'),
Expand Down
4 changes: 2 additions & 2 deletions deps/undici/src/lib/core/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -776,7 +776,7 @@ function isValidHeaderValue (characters) {
return !headerCharRegex.test(characters)
}

const rangeHeaderRegex = /^bytes (\d+)-(\d+)\/(\d+)?$/
const rangeHeaderRegex = /^bytes (\d+)-(\d+)\/(\d+|\*)?$/

/**
* @typedef {object} RangeHeader
Expand All @@ -799,7 +799,7 @@ function parseRangeHeader (range) {
? {
start: parseInt(m[1]),
end: m[2] ? parseInt(m[2]) : null,
size: m[3] ? parseInt(m[3]) : null
size: m[3] && m[3] !== '*' ? parseInt(m[3]) : null
}
: null
}
Expand Down
77 changes: 59 additions & 18 deletions deps/undici/src/lib/dispatcher/client-h1.js
Original file line number Diff line number Diff line change
Expand Up @@ -360,23 +360,62 @@ class Parser {
this.paused = true
socket.unshift(data)
} else {
const ptr = llhttp.llhttp_get_error_reason(this.ptr)
let message = ''
if (ptr) {
const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0)
message =
'Response does not match the HTTP/1.1 protocol (' +
Buffer.from(llhttp.memory.buffer, ptr, len).toString() +
')'
}
throw new HTTPParserError(message, constants.ERROR[ret], data)
throw this.createError(ret, data)
}
}
} catch (err) {
util.destroy(socket, err)
}
}

finish () {
assert(currentParser === null)
assert(this.ptr != null)
assert(!this.paused)

const { llhttp } = this

let ret

try {
currentParser = this
ret = llhttp.llhttp_finish(this.ptr)
} finally {
currentParser = null
}

if (ret === constants.ERROR.OK) {
return null
}

if (ret === constants.ERROR.PAUSED || ret === constants.ERROR.PAUSED_UPGRADE) {
this.paused = true
return null
}

return this.createError(ret, EMPTY_BUF)
}

createError (ret, data) {
const { llhttp, contentLength, bytesRead } = this

if (contentLength !== -1 && bytesRead !== contentLength) {
return new ResponseContentLengthMismatchError()
}

const ptr = llhttp.llhttp_get_error_reason(this.ptr)
let message = ''
if (ptr) {
const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0)
message =
'Response does not match the HTTP/1.1 protocol (' +
Buffer.from(llhttp.memory.buffer, ptr, len).toString() +
')'
}

return new HTTPParserError(message, constants.ERROR[ret], data)
}

destroy () {
assert(currentParser === null)
assert(this.ptr != null)
Expand Down Expand Up @@ -888,8 +927,11 @@ function onHttpSocketError (err) {
// On Mac OS, we get an ECONNRESET even if there is a full body to be forwarded
// to the user.
if (err.code === 'ECONNRESET' && parser.statusCode && !parser.shouldKeepAlive) {
// We treat all incoming data so for as a valid response.
parser.onMessageComplete()
const parserErr = parser.finish()
if (parserErr) {
this[kError] = parserErr
this[kClient][kOnError](parserErr)
}
return
}

Expand All @@ -906,8 +948,10 @@ function onHttpSocketEnd () {
const parser = this[kParser]

if (parser.statusCode && !parser.shouldKeepAlive) {
// We treat all incoming data so far as a valid response.
parser.onMessageComplete()
const parserErr = parser.finish()
if (parserErr) {
util.destroy(this, parserErr)
}
return
}

Expand All @@ -919,8 +963,7 @@ function onHttpSocketClose () {

if (parser) {
if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) {
// We treat all incoming data so far as a valid response.
parser.onMessageComplete()
this[kError] = parser.finish() || this[kError]
}

this[kParser].destroy()
Expand Down Expand Up @@ -1382,8 +1425,6 @@ function writeBuffer (abort, body, client, request, socket, contentLength, heade
* @returns {Promise<void>}
*/
async function writeBlob (abort, body, client, request, socket, contentLength, header, expectsPayload) {
assert(contentLength === body.size, 'blob body must have content length')

try {
if (contentLength != null && contentLength !== body.size) {
throw new RequestContentLengthMismatchError()
Expand Down
Loading
Loading