| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -91,3 +91,5 @@ AGENTS.md | |||
| 91 | 91 | ||
| 92 | 92 | # Ignore .githuman | |
| 93 | 93 | .githuman | |
| 94 | + | ||
| 95 | + benchmarks/package-lock.json | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -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 | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -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 | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -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: | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -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 () { | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -1,14 +1,61 @@ | |||
| 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') | |
| 8 | 7 | 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 | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -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'), | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -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 | } | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -360,23 +360,62 @@ 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) { | |
| 376 | 367 | util.destroy(socket, err) | |
| 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() | |
| Back | FazBrowse Home | New Git URL |
0 commit comments