diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1262b8a023e..0b8a5dab112 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,11 +59,8 @@ jobs: fail-fast: false max-parallel: 0 matrix: - node-version: ['20', '22', '24', '25'] + node-version: ['22', '24', '25'] runs-on: ['ubuntu-latest', 'windows-latest', 'macos-latest'] - exclude: - - node-version: '20' - runs-on: windows-latest uses: ./.github/workflows/nodejs.yml with: # Disable coverage on Node.js 25 until https://github.com/nodejs/node/issues/61971 is resolved. @@ -78,7 +75,7 @@ jobs: fail-fast: false max-parallel: 0 matrix: - node-version: ['24', '25'] + node-version: ['22', '24', '25'] runs-on: ['ubuntu-latest'] uses: ./.github/workflows/nodejs.yml with: @@ -93,7 +90,7 @@ jobs: fail-fast: false max-parallel: 0 matrix: - node-version: ['20', '22', '24', '25'] + node-version: ['22', '24', '25'] runs-on: ubuntu-latest timeout-minutes: 120 steps: @@ -179,7 +176,7 @@ jobs: fail-fast: false max-parallel: 0 matrix: - node-version: ['20', '22', '24', '25'] + node-version: ['22', '24', '25'] runs-on: ubuntu-latest timeout-minutes: 120 steps: @@ -273,7 +270,10 @@ jobs: fail-fast: false max-parallel: 0 matrix: - node-version: ['24', '25'] + # Node.js 22 is intentionally excluded here: --shared-builtin-undici/undici-path + # embedding is only validated for supported/current majors. + # Start validating shared builtin embedding from Node.js 26 onward. + node-version: ['26'] runs-on: ['ubuntu-latest'] with: node-version: ${{ matrix.node-version }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 96169532330..1f610816e50 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -52,7 +52,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v2.3.3 + uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v2.3.3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -62,7 +62,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@9e907b5e64f6b83e7804b09294d44122997950d6 # v2.3.3 + uses: github/codeql-action/autobuild@c10b8064de6f491fea524254123dbe5e09572f13 # v2.3.3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -75,6 +75,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v2.3.3 + uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v2.3.3 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/nodejs-shared.yml b/.github/workflows/nodejs-shared.yml index 36e1fd57ae6..113a48ae63c 100644 --- a/.github/workflows/nodejs-shared.yml +++ b/.github/workflows/nodejs-shared.yml @@ -59,21 +59,29 @@ jobs: const req = await fetch('https://nodejs.org/download${{ inputs.node-download-server-path }}/index.json') const releases = await req.json() - const latest = releases.find((r) => r.version.startsWith('v${{ inputs.node-version }}')) - return latest.version + const latest = releases.find((r) => r.version.startsWith('v${{ inputs.node-version }}.')) + return latest?.version || '' + + - name: Skip until Node.js ${{ inputs.node-version }} is released + if: steps.release.outputs.result == '' + run: echo 'No matching Node.js ${{ inputs.node-version }} release is available yet; skipping shared builtin embedding checks.' - name: Download and extract source for Node.js ${{ steps.release.outputs.result }} + if: steps.release.outputs.result != '' run: curl https://nodejs.org/download${{ inputs.node-download-server-path }}/${{ steps.release.outputs.result }}/node-${{ steps.release.outputs.result }}.tar.xz | tar xfJ - - name: Install ninja + if: steps.release.outputs.result != '' run: sudo apt-get install ninja-build - name: ccache + if: steps.release.outputs.result != '' uses: hendrikmuhs/ccache-action@bfa03e1de4d7f7c3e80ad9109feedd05c4f5a716 #v1.2.19 with: key: node(external_undici)${{ inputs.node-version }} - name: Build node ${{ steps.release.outputs.result }} with --shared-builtin-undici/undici-path + if: steps.release.outputs.result != '' working-directory: ./node-${{ steps.release.outputs.result }} run: | export CC="ccache gcc" @@ -85,6 +93,7 @@ jobs: echo "$(pwd)/final/bin" >> $GITHUB_PATH - name: Print version information + if: steps.release.outputs.result != '' run: | echo OS: $(node -p "os.version()") echo Node.js: $(node --version) @@ -95,6 +104,7 @@ jobs: echo Node.js built-in undici version: $(node -p "process.versions.undici") # undefined for external Undici - name: Run tests + if: steps.release.outputs.result != '' working-directory: ./node-${{ steps.release.outputs.result }} run: tools/test.py -p dots --flaky-tests=dontcare diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index c07c16845f3..9e963b0988f 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -110,12 +110,12 @@ jobs: NODE_V8_COVERAGE: ${{ inputs.codecov == true && './coverage/tmp' || '' }} UNDICI_NO_WASM_SIMD: ${{ inputs['no-wasm-simd'] }} - - name: Test cache-interceptor ${{ inputs.node-version != '20' && 'with' || 'without' }} sqlite + - name: Test cache-interceptor with sqlite run: npm run test:cache-interceptor id: test-cache-interceptor env: CI: true - NODE_OPTIONS: ${{ inputs.node-version != '20' && '--experimental-sqlite' || '' }} + NODE_OPTIONS: --experimental-sqlite NODE_V8_COVERAGE: ${{ inputs.codecov == true && './coverage/tmp' || '' }} UNDICI_NO_WASM_SIMD: ${{ inputs['no-wasm-simd'] }} diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 77bd36c5652..d870b88b1ce 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -51,6 +51,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@9e907b5e64f6b83e7804b09294d44122997950d6 # v3.29.5 + uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v3.29.5 with: sarif_file: results.sarif diff --git a/.gitignore b/.gitignore index 6791a8c9f9b..a5faeafdfa6 100644 --- a/.gitignore +++ b/.gitignore @@ -93,7 +93,7 @@ test/request-timeout.10mb.bin CLAUDE.md .claude -# Ignore .pi +# Local tooling .pi AGENTS.md diff --git a/README.md b/README.md index 82930f1053b..f3b929b0dc4 100644 --- a/README.md +++ b/README.md @@ -619,6 +619,12 @@ See [Dispatcher.upgrade](./docs/docs/api/Dispatcher.md#dispatcherupgradeoptions- Sets the global dispatcher used by Common API Methods. Global dispatcher is shared among compatible undici modules, including undici that is bundled internally with node.js. +Undici stores this dispatcher under `Symbol.for('undici.globalDispatcher.2')`. + +On Node.js 22, `setGlobalDispatcher()` also mirrors the configured dispatcher to +`Symbol.for('undici.globalDispatcher.1')` using `Dispatcher1Wrapper`, so Node.js built-in `fetch` +can keep using the legacy handler contract while Undici uses the new handler API. + ### `undici.getGlobalDispatcher()` Gets the global dispatcher used by Common API Methods. diff --git a/docs/docs/api/Client.md b/docs/docs/api/Client.md index 680375d1479..d2268e3b39f 100644 --- a/docs/docs/api/Client.md +++ b/docs/docs/api/Client.md @@ -29,7 +29,7 @@ Returns: `Client` * **strictContentLength** `Boolean` (optional) - Default: `true` - Whether to treat request content length mismatches as errors. If true, an error is thrown when the request content-length header doesn't match the length of the request body. **Security Warning:** Disabling this option can expose your application to HTTP Request Smuggling attacks, where mismatched content-length headers cause servers and proxies to interpret request boundaries differently. This can lead to cache poisoning, credential hijacking, and bypassing security controls. Only disable this in controlled environments where you fully trust the request source. * **autoSelectFamily**: `boolean` (optional) - Default: depends on local Node version, on Node 18.13.0 and above is `false`. Enables a family autodetection algorithm that loosely implements section 5 of [RFC 8305](https://tools.ietf.org/html/rfc8305#section-5). See [here](https://nodejs.org/api/net.html#socketconnectoptions-connectlistener) for more details. This option is ignored if not supported by the current Node version. * **autoSelectFamilyAttemptTimeout**: `number` - Default: depends on local Node version, on Node 18.13.0 and above is `250`. The amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the `autoSelectFamily` option. See [here](https://nodejs.org/api/net.html#socketconnectoptions-connectlistener) for more details. -* **allowH2**: `boolean` - Default: `false`. Enables support for H2 if the server has assigned bigger priority to it through ALPN negotiation. +* **allowH2**: `boolean` - Default: `true`. Enables support for H2 if the server has assigned bigger priority to it through ALPN negotiation. * **useH2c**: `boolean` - Default: `false`. Enforces h2c for non-https connections. * **maxConcurrentStreams**: `number` - Default: `100`. Dictates the maximum number of concurrent streams for a single H2 session. It can be overridden by a SETTINGS remote frame. * **initialWindowSize**: `number` (optional) - Default: `262144` (256KB). Sets the HTTP/2 stream-level flow-control window size (SETTINGS_INITIAL_WINDOW_SIZE). Must be a positive integer greater than 0. This default is higher than Node.js core's default (65535 bytes) to improve throughput, Node's choice is very conservative for current high-bandwith networks. See [RFC 7540 Section 6.9.2](https://datatracker.ietf.org/doc/html/rfc7540#section-6.9.2) for more details. @@ -282,4 +282,4 @@ console.log('requests completed') ### Event: `'error'` -Invoked for users errors such as throwing in the `onError` handler. +Invoked for user errors such as throwing in the `onResponseError` handler. diff --git a/docs/docs/api/Dispatcher.md b/docs/docs/api/Dispatcher.md index c9df4c63255..666a58ff8a6 100644 --- a/docs/docs/api/Dispatcher.md +++ b/docs/docs/api/Dispatcher.md @@ -212,6 +212,41 @@ Returns: `Boolean` - `false` if dispatcher is busy and further dispatch calls wo * **onResponseEnd** `(controller: DispatchController, trailers: Record) => void` - Invoked when response payload and trailers have been received and the request has completed. Not required for `upgrade` requests. * **onResponseError** `(controller: DispatchController, error: Error) => void` - Invoked when an error has occurred. May not throw. +#### Migration from legacy handler API + +If you were previously using `onConnect/onHeaders/onData/onComplete/onError`, switch to the new callbacks: + +- `onConnect(abort)` → `onRequestStart(controller)` and call `controller.abort(reason)` +- `onHeaders(status, rawHeaders, resume, statusText)` → `onResponseStart(controller, status, headers, statusText)` +- `onData(chunk)` → `onResponseData(controller, chunk)` +- `onComplete(trailers)` → `onResponseEnd(controller, trailers)` +- `onError(err)` → `onResponseError(controller, err)` +- `onUpgrade(status, rawHeaders, socket)` → `onRequestUpgrade(controller, status, headers, socket)` + +To access raw header arrays (for preserving duplicates/casing), read them from the controller: + +- `controller.rawHeaders` for response headers +- `controller.rawTrailers` for trailers + +Pause/resume now uses the controller: + +- Call `controller.pause()` and `controller.resume()` instead of returning `false` from handlers. + +#### Compatibility notes + +Undici now stores the global dispatcher under `Symbol.for('undici.globalDispatcher.2')`. +This avoids conflicts with runtimes (such as Node.js built-in `fetch`) that still rely on the legacy dispatcher handler interface. + +On Node.js 22, `setGlobalDispatcher()` also mirrors the configured dispatcher to `Symbol.for('undici.globalDispatcher.1')` using a `Dispatcher1Wrapper`, so Node's built-in `fetch` can keep using the legacy handler contract. + +If you need to expose a new dispatcher/agent to legacy v1 handler consumers (`onConnect/onHeaders/onData/onComplete/onError/onUpgrade`), use `Dispatcher1Wrapper`: + +```js +import { Agent, Dispatcher1Wrapper } from 'undici' + +const legacyCompatibleDispatcher = new Dispatcher1Wrapper(new Agent()) +``` + #### Example 1 - Dispatch GET request ```js @@ -236,21 +271,21 @@ client.dispatch({ 'x-foo': 'bar' } }, { - onConnect: () => { + onRequestStart: () => { console.log('Connected!') }, - onError: (error) => { + onResponseError: (_controller, error) => { console.error(error) }, - onHeaders: (statusCode, headers) => { - console.log(`onHeaders | statusCode: ${statusCode} | headers: ${headers}`) + onResponseStart: (_controller, statusCode, headers) => { + console.log(`onResponseStart | statusCode: ${statusCode} | headers: ${JSON.stringify(headers)}`) }, - onData: (chunk) => { - console.log('onData: chunk received') + onResponseData: (_controller, chunk) => { + console.log('onResponseData: chunk received') data.push(chunk) }, - onComplete: (trailers) => { - console.log(`onComplete | trailers: ${trailers}`) + onResponseEnd: (_controller, trailers) => { + console.log(`onResponseEnd | trailers: ${JSON.stringify(trailers)}`) const res = Buffer.concat(data).toString('utf8') console.log(`Data: ${res}`) client.close() @@ -288,15 +323,15 @@ client.dispatch({ method: 'GET', upgrade: 'websocket' }, { - onConnect: () => { - console.log('Undici Client - onConnect') + onRequestStart: () => { + console.log('Undici Client - onRequestStart') }, - onError: (error) => { - console.log('onError') // shouldn't print + onResponseError: () => { + console.log('onResponseError') // shouldn't print }, - onUpgrade: (statusCode, headers, socket) => { - console.log('Undici Client - onUpgrade') - console.log(`onUpgrade Headers: ${headers}`) + onRequestUpgrade: (_controller, statusCode, headers, socket) => { + console.log('Undici Client - onRequestUpgrade') + console.log(`onRequestUpgrade Headers: ${JSON.stringify(headers)}`) socket.on('data', buffer => { console.log(buffer.toString('utf8')) }) @@ -339,21 +374,21 @@ client.dispatch({ }, body: JSON.stringify({ message: 'Hello' }) }, { - onConnect: () => { + onRequestStart: () => { console.log('Connected!') }, - onError: (error) => { + onResponseError: (_controller, error) => { console.error(error) }, - onHeaders: (statusCode, headers) => { - console.log(`onHeaders | statusCode: ${statusCode} | headers: ${headers}`) + onResponseStart: (_controller, statusCode, headers) => { + console.log(`onResponseStart | statusCode: ${statusCode} | headers: ${JSON.stringify(headers)}`) }, - onData: (chunk) => { - console.log('onData: chunk received') + onResponseData: (_controller, chunk) => { + console.log('onResponseData: chunk received') data.push(chunk) }, - onComplete: (trailers) => { - console.log(`onComplete | trailers: ${trailers}`) + onResponseEnd: (_controller, trailers) => { + console.log(`onResponseEnd | trailers: ${JSON.stringify(trailers)}`) const res = Buffer.concat(data).toString('utf8') console.log(`Response Data: ${res}`) client.close() diff --git a/docs/docs/api/H2CClient.md b/docs/docs/api/H2CClient.md index 6558ad5f4a4..19603ebe1ce 100644 --- a/docs/docs/api/H2CClient.md +++ b/docs/docs/api/H2CClient.md @@ -260,4 +260,4 @@ console.log("requests completed"); ### Event: `'error'` -Invoked for users errors such as throwing in the `onError` handler. +Invoked for user errors such as throwing in the `onResponseError` handler. diff --git a/docs/docs/api/RedirectHandler.md b/docs/docs/api/RedirectHandler.md index ca24c6917b8..d1dd9d993f9 100644 --- a/docs/docs/api/RedirectHandler.md +++ b/docs/docs/api/RedirectHandler.md @@ -31,57 +31,62 @@ Returns: `RedirectHandler` ### Methods -#### `onConnect(abort)` +#### `onRequestStart(controller, context)` -Called when the connection is established. +Called when the request starts. Parameters: -- **abort** `function` - The abort function. +- **controller** `DispatchController` - The request controller. +- **context** `object` - The dispatch context. -#### `onUpgrade(statusCode, headers, socket)` +#### `onRequestUpgrade(controller, statusCode, headers, socket)` Called when an upgrade is requested. Parameters: +- **controller** `DispatchController` - The request controller. - **statusCode** `number` - The HTTP status code. - **headers** `object` - The headers received in the response. - **socket** `object` - The socket object. -#### `onError(error)` +#### `onResponseError(controller, error)` Called when an error occurs. Parameters: +- **controller** `DispatchController` - The request controller. - **error** `Error` - The error that occurred. -#### `onHeaders(statusCode, headers, resume, statusText)` +#### `onResponseStart(controller, statusCode, headers, statusText)` Called when headers are received. Parameters: +- **controller** `DispatchController` - The request controller. - **statusCode** `number` - The HTTP status code. - **headers** `object` - The headers received in the response. -- **resume** `function` - The resume function. - **statusText** `string` - The status text. -#### `onData(chunk)` +#### `onResponseData(controller, chunk)` Called when data is received. Parameters: +- **controller** `DispatchController` - The request controller. - **chunk** `Buffer` - The data chunk received. -#### `onComplete(trailers)` +#### `onResponseEnd(controller, trailers)` Called when the request is complete. Parameters: +- **controller** `DispatchController` - The request controller. - **trailers** `object` - The trailers received. #### `onBodySent(chunk)` diff --git a/docs/docs/api/RetryHandler.md b/docs/docs/api/RetryHandler.md index d7b3e88d0f7..b420a569a54 100644 --- a/docs/docs/api/RetryHandler.md +++ b/docs/docs/api/RetryHandler.md @@ -82,17 +82,16 @@ const handler = new RetryHandler( return client.dispatch(...args); }, handler: { - onConnect() {}, - onBodySent() {}, - onHeaders(status, _rawHeaders, resume, _statusMessage) { + onRequestStart() {}, + onBodySent(chunk) {}, + onResponseStart(_controller, status, headers) { // do something with headers }, - onData(chunk) { + onResponseData(_controller, chunk) { chunks.push(chunk); - return true; }, - onComplete() {}, - onError() { + onResponseEnd() {}, + onResponseError(_controller, err) { // handle error properly }, }, @@ -107,12 +106,12 @@ const client = new Client(`http://localhost:${server.address().port}`); const handler = new RetryHandler(dispatchOptions, { dispatch: client.dispatch.bind(client), handler: { - onConnect() {}, - onBodySent() {}, - onHeaders(status, _rawHeaders, resume, _statusMessage) {}, - onData(chunk) {}, - onComplete() {}, - onError(err) {}, + onRequestStart() {}, + onBodySent(chunk) {}, + onResponseStart(_controller, status, headers) {}, + onResponseData(_controller, chunk) {}, + onResponseEnd() {}, + onResponseError(_controller, err) {}, }, }); ``` diff --git a/docs/examples/proxy/proxy.js b/docs/examples/proxy/proxy.js index 8826bc722ce..14d91f5edb3 100644 --- a/docs/examples/proxy/proxy.js +++ b/docs/examples/proxy/proxy.js @@ -49,34 +49,36 @@ class HTTPHandler { }) } - onConnect (abort) { + onRequestStart (controller) { if (this.req.aborted) { - abort() + controller.abort() } else { - this.abort = abort - this.res.on('close', abort) + this.abort = (reason) => controller.abort(reason) + this.res.on('close', this.abort) } } - onHeaders (statusCode, headers, resume) { + onResponseStart (controller, statusCode) { if (statusCode < 200) { return } - this.resume = resume - this.res.on('drain', resume) + this.resume = () => controller.resume() + this.res.on('drain', this.resume) this.res.writeHead(statusCode, getHeaders({ - headers, + headers: controller.rawHeaders ?? [], proxyName: this.proxyName, httpVersion: this.httpVersion })) } - onData (chunk) { - return this.res.write(chunk) + onResponseData (controller, chunk) { + if (this.res.write(chunk) === false) { + controller.pause() + } } - onComplete () { + onResponseEnd () { this.res.off('close', this.abort) this.res.off('drain', this.resume) @@ -84,7 +86,7 @@ class HTTPHandler { this.callback() } - onError (err) { + onResponseError (_controller, err) { this.res.off('close', this.abort) this.res.off('drain', this.resume) @@ -108,16 +110,16 @@ class WSHandler { }) } - onConnect (abort) { + onRequestStart (controller) { if (this.socket.destroyed) { - abort() + controller.abort() } else { - this.abort = abort - this.socket.on('close', abort) + this.abort = (reason) => controller.abort(reason) + this.socket.on('close', this.abort) } } - onUpgrade (statusCode, headers, socket) { + onRequestUpgrade (controller, statusCode, _headers, socket) { this.socket.off('close', this.abort) // TODO: Check statusCode? @@ -128,8 +130,8 @@ class WSHandler { setupSocket(socket) - headers = getHeaders({ - headers, + const headers = getHeaders({ + headers: controller.rawHeaders ?? [], proxyName: this.proxyName, httpVersion: this.httpVersion }) @@ -144,7 +146,7 @@ class WSHandler { pipeline(socket, this.socket, socket, this.callback) } - onError (err) { + onResponseError (_controller, err) { this.socket.off('close', this.abort) this.callback(err) diff --git a/index.js b/index.js index 708a8ee80c5..04e73389211 100644 --- a/index.js +++ b/index.js @@ -6,6 +6,7 @@ const Pool = require('./lib/dispatcher/pool') const BalancedPool = require('./lib/dispatcher/balanced-pool') const RoundRobinPool = require('./lib/dispatcher/round-robin-pool') const Agent = require('./lib/dispatcher/agent') +const Dispatcher1Wrapper = require('./lib/dispatcher/dispatcher1-wrapper') const ProxyAgent = require('./lib/dispatcher/proxy-agent') const Socks5ProxyAgent = require('./lib/dispatcher/socks5-proxy-agent') const EnvHttpProxyAgent = require('./lib/dispatcher/env-http-proxy-agent') @@ -35,6 +36,7 @@ module.exports.Pool = Pool module.exports.BalancedPool = BalancedPool module.exports.RoundRobinPool = RoundRobinPool module.exports.Agent = Agent +module.exports.Dispatcher1Wrapper = Dispatcher1Wrapper module.exports.ProxyAgent = ProxyAgent module.exports.Socks5ProxyAgent = Socks5ProxyAgent module.exports.EnvHttpProxyAgent = EnvHttpProxyAgent diff --git a/lib/api/api-connect.js b/lib/api/api-connect.js index c8b86dd7d53..b545a3eb5c1 100644 --- a/lib/api/api-connect.js +++ b/lib/api/api-connect.js @@ -32,45 +32,48 @@ class ConnectHandler extends AsyncResource { addSignal(this, signal) } - onConnect (abort, context) { + onRequestStart (controller, context) { if (this.reason) { - abort(this.reason) + controller.abort(this.reason) return } assert(this.callback) - this.abort = abort + this.abort = (reason) => controller.abort(reason) this.context = context } - onHeaders () { + onResponseStart () { throw new SocketError('bad connect', null) } - onUpgrade (statusCode, rawHeaders, socket) { + onRequestUpgrade (controller, statusCode, headers, socket) { const { callback, opaque, context } = this removeSignal(this) this.callback = null - let headers = rawHeaders + let responseHeaders = headers + const rawHeaders = controller?.rawHeaders // Indicates is an HTTP2Session - if (headers != null) { - headers = this.responseHeaders === 'raw' ? util.parseRawHeaders(rawHeaders) : util.parseHeaders(rawHeaders) + if (responseHeaders != null) { + responseHeaders = this.responseHeaders === 'raw' + ? (Array.isArray(rawHeaders) ? util.parseRawHeaders(rawHeaders) : []) + : headers } this.runInAsyncScope(callback, null, null, { statusCode, - headers, + headers: responseHeaders, socket, opaque, context }) } - onError (err) { + onResponseError (_controller, err) { const { callback, opaque } = this removeSignal(this) @@ -96,7 +99,6 @@ function connect (opts, callback) { try { const connectHandler = new ConnectHandler(opts, callback) const connectOptions = { ...opts, method: 'CONNECT' } - this.dispatch(connectOptions, connectHandler) } catch (err) { if (typeof callback !== 'function') { diff --git a/lib/api/api-pipeline.js b/lib/api/api-pipeline.js index 77f3520a83f..c8bd7414932 100644 --- a/lib/api/api-pipeline.js +++ b/lib/api/api-pipeline.js @@ -146,40 +146,46 @@ class PipelineHandler extends AsyncResource { addSignal(this, signal) } - onConnect (abort, context) { + onRequestStart (controller, context) { const { res } = this if (this.reason) { - abort(this.reason) + controller.abort(this.reason) return } assert(!res, 'pipeline cannot be retried') - this.abort = abort + this.abort = (reason) => controller.abort(reason) this.context = context } - onHeaders (statusCode, rawHeaders, resume) { + onResponseStart (controller, statusCode, headers, _statusMessage) { const { opaque, handler, context } = this if (statusCode < 200) { if (this.onInfo) { - const headers = this.responseHeaders === 'raw' ? util.parseRawHeaders(rawHeaders) : util.parseHeaders(rawHeaders) - this.onInfo({ statusCode, headers }) + const rawHeaders = controller?.rawHeaders + const responseHeaders = this.responseHeaders === 'raw' + ? (Array.isArray(rawHeaders) ? util.parseRawHeaders(rawHeaders) : []) + : headers + this.onInfo({ statusCode, headers: responseHeaders }) } return } - this.res = new PipelineResponse(resume) + this.res = new PipelineResponse(() => controller.resume()) let body try { this.handler = null - const headers = this.responseHeaders === 'raw' ? util.parseRawHeaders(rawHeaders) : util.parseHeaders(rawHeaders) + const rawHeaders = controller?.rawHeaders + const responseHeaders = this.responseHeaders === 'raw' + ? (Array.isArray(rawHeaders) ? util.parseRawHeaders(rawHeaders) : []) + : headers body = this.runInAsyncScope(handler, null, { statusCode, - headers, + headers: responseHeaders, opaque, body: this.res, context @@ -222,17 +228,20 @@ class PipelineHandler extends AsyncResource { this.body = body } - onData (chunk) { + onResponseData (controller, chunk) { const { res } = this - return res.push(chunk) + + if (res.push(chunk) === false) { + controller.pause() + } } - onComplete (trailers) { + onResponseEnd (_controller, _trailers) { const { res } = this res.push(null) } - onError (err) { + onResponseError (_controller, err) { const { ret } = this this.handler = null util.destroy(ret, err) diff --git a/lib/api/api-request.js b/lib/api/api-request.js index f6d15f75b0e..1d56fb0c9ad 100644 --- a/lib/api/api-request.js +++ b/lib/api/api-request.js @@ -54,6 +54,7 @@ class RequestHandler extends AsyncResource { this.body = body this.trailers = {} this.context = null + this.controller = null this.onInfo = onInfo || null this.highWaterMark = highWaterMark this.reason = null @@ -73,36 +74,40 @@ class RequestHandler extends AsyncResource { } } - onConnect (abort, context) { + onRequestStart (controller, context) { if (this.reason) { - abort(this.reason) + controller.abort(this.reason) return } assert(this.callback) - this.abort = abort + this.controller = controller + this.abort = (reason) => controller.abort(reason) this.context = context } - onHeaders (statusCode, rawHeaders, resume, statusMessage) { - const { callback, opaque, abort, context, responseHeaders, highWaterMark } = this + onResponseStart (controller, statusCode, headers, statusText) { + const { callback, opaque, context, responseHeaders, highWaterMark } = this - const headers = responseHeaders === 'raw' ? util.parseRawHeaders(rawHeaders) : util.parseHeaders(rawHeaders) + const rawHeaders = controller?.rawHeaders + const responseHeaderData = responseHeaders === 'raw' + ? (Array.isArray(rawHeaders) ? util.parseRawHeaders(rawHeaders) : []) + : headers if (statusCode < 200) { if (this.onInfo) { - this.onInfo({ statusCode, headers }) + this.onInfo({ statusCode, headers: responseHeaderData }) } return } - const parsedHeaders = responseHeaders === 'raw' ? util.parseHeaders(rawHeaders) : headers - const contentType = parsedHeaders['content-type'] - const contentLength = parsedHeaders['content-length'] + const parsedHeaders = headers + const contentType = parsedHeaders?.['content-type'] + const contentLength = parsedHeaders?.['content-length'] const res = new Readable({ - resume, - abort, + resume: () => controller.resume(), + abort: (reason) => controller.abort(reason), contentType, contentLength: this.method !== 'HEAD' && contentLength ? Number(contentLength) @@ -121,8 +126,8 @@ class RequestHandler extends AsyncResource { try { this.runInAsyncScope(callback, null, null, { statusCode, - statusText: statusMessage, - headers, + statusText, + headers: responseHeaderData, trailers: this.trailers, opaque, body: res, @@ -144,16 +149,35 @@ class RequestHandler extends AsyncResource { } } - onData (chunk) { - return this.res.push(chunk) + onResponseData (controller, chunk) { + if (!this.res) { + return + } + + if (this.res.push(chunk) === false) { + controller.pause() + } } - onComplete (trailers) { - util.parseHeaders(trailers, this.trailers) - this.res.push(null) + onResponseEnd (_controller, trailers) { + if (trailers && typeof trailers === 'object') { + for (const key of Object.keys(trailers)) { + if (key === '__proto__') { + Object.defineProperty(this.trailers, key, { + value: trailers[key], + enumerable: true, + configurable: true, + writable: true + }) + } else { + this.trailers[key] = trailers[key] + } + } + } + this.res?.push(null) } - onError (err) { + onResponseError (_controller, err) { const { res, callback, body, opaque } = this if (callback) { diff --git a/lib/api/api-stream.js b/lib/api/api-stream.js index 5d0b3fbe633..daee5681223 100644 --- a/lib/api/api-stream.js +++ b/lib/api/api-stream.js @@ -53,39 +53,44 @@ class StreamHandler extends AsyncResource { this.res = null this.abort = null this.context = null + this.controller = null this.trailers = null this.body = body this.onInfo = onInfo || null if (util.isStream(body)) { body.on('error', (err) => { - this.onError(err) + this.onResponseError(this.controller, err) }) } addSignal(this, signal) } - onConnect (abort, context) { + onRequestStart (controller, context) { if (this.reason) { - abort(this.reason) + controller.abort(this.reason) return } assert(this.callback) - this.abort = abort + this.controller = controller + this.abort = (reason) => controller.abort(reason) this.context = context } - onHeaders (statusCode, rawHeaders, resume, statusMessage) { + onResponseStart (controller, statusCode, headers, _statusMessage) { const { factory, opaque, context, responseHeaders } = this - const headers = responseHeaders === 'raw' ? util.parseRawHeaders(rawHeaders) : util.parseHeaders(rawHeaders) + const rawHeaders = controller?.rawHeaders + const responseHeaderData = responseHeaders === 'raw' + ? (Array.isArray(rawHeaders) ? util.parseRawHeaders(rawHeaders) : []) + : headers if (statusCode < 200) { if (this.onInfo) { - this.onInfo({ statusCode, headers }) + this.onInfo({ statusCode, headers: responseHeaderData }) } return } @@ -98,7 +103,7 @@ class StreamHandler extends AsyncResource { const res = this.runInAsyncScope(factory, null, { statusCode, - headers, + headers: responseHeaderData, opaque, context }) @@ -129,7 +134,7 @@ class StreamHandler extends AsyncResource { } }) - res.on('drain', resume) + res.on('drain', () => controller.resume()) this.res = res @@ -137,16 +142,24 @@ class StreamHandler extends AsyncResource { ? res.writableNeedDrain : res._writableState?.needDrain - return needDrain !== true + if (needDrain === true) { + controller.pause() + } } - onData (chunk) { + onResponseData (controller, chunk) { const { res } = this - return res ? res.write(chunk) : true + if (!res) { + return + } + + if (res.write(chunk) === false) { + controller.pause() + } } - onComplete (trailers) { + onResponseEnd (_controller, trailers) { const { res } = this removeSignal(this) @@ -155,12 +168,14 @@ class StreamHandler extends AsyncResource { return } - this.trailers = util.parseHeaders(trailers) + if (trailers && typeof trailers === 'object') { + this.trailers = trailers + } res.end() } - onError (err) { + onResponseError (_controller, err) { const { res, callback, opaque, body } = this removeSignal(this) diff --git a/lib/api/api-upgrade.js b/lib/api/api-upgrade.js index 2b03f207562..9f1d80c62dc 100644 --- a/lib/api/api-upgrade.js +++ b/lib/api/api-upgrade.js @@ -34,23 +34,23 @@ class UpgradeHandler extends AsyncResource { addSignal(this, signal) } - onConnect (abort, context) { + onRequestStart (controller, context) { if (this.reason) { - abort(this.reason) + controller.abort(this.reason) return } assert(this.callback) - this.abort = abort - this.context = null + this.abort = (reason) => controller.abort(reason) + this.context = context } - onHeaders () { + onResponseStart () { throw new SocketError('bad upgrade', null) } - onUpgrade (statusCode, rawHeaders, socket) { + onRequestUpgrade (controller, statusCode, headers, socket) { assert(socket[kHTTP2Stream] === true ? statusCode === 200 : statusCode === 101) const { callback, opaque, context } = this @@ -58,16 +58,21 @@ class UpgradeHandler extends AsyncResource { removeSignal(this) this.callback = null - const headers = this.responseHeaders === 'raw' ? util.parseRawHeaders(rawHeaders) : util.parseHeaders(rawHeaders) + + const rawHeaders = controller?.rawHeaders + const responseHeaders = this.responseHeaders === 'raw' + ? (Array.isArray(rawHeaders) ? util.parseRawHeaders(rawHeaders) : []) + : headers + this.runInAsyncScope(callback, null, null, { - headers, + headers: responseHeaders, socket, opaque, context }) } - onError (err) { + onResponseError (_controller, err) { const { callback, opaque } = this removeSignal(this) @@ -97,7 +102,6 @@ function upgrade (opts, callback) { method: opts.method || 'GET', upgrade: opts.protocol || 'Websocket' } - this.dispatch(upgradeOpts, upgradeHandler) } catch (err) { if (typeof callback !== 'function') { diff --git a/lib/core/connect.js b/lib/core/connect.js index a49af91486e..605cb9fdd9b 100644 --- a/lib/core/connect.js +++ b/lib/core/connect.js @@ -51,7 +51,7 @@ function buildConnector ({ allowH2, useH2c, maxCachedSessions, socketPath, timeo const options = { path: socketPath, ...opts } const sessionCache = new SessionCache(maxCachedSessions == null ? 100 : maxCachedSessions) timeout = timeout == null ? 10e3 : timeout - allowH2 = allowH2 != null ? allowH2 : false + allowH2 = allowH2 != null ? allowH2 : true return function connect ({ hostname, host, protocol, port, servername, localAddress, httpSocket }, callback) { let socket if (protocol === 'https:') { diff --git a/lib/core/request.js b/lib/core/request.js index 829da6f8fc1..2b2633f3d04 100644 --- a/lib/core/request.js +++ b/lib/core/request.js @@ -16,6 +16,7 @@ const { hasSafeIterator, isBlobLike, serializePathWithQuery, + parseHeaders, assertRequestHandler, getServerName, normalizedMethodRecords, @@ -28,6 +29,55 @@ const { headerNameLowerCasedRecord } = require('./constants') const invalidPathRegex = /[^\u0021-\u00ff]/ const kHandler = Symbol('handler') +const kController = Symbol('controller') +const kResume = Symbol('resume') + +class RequestController { + #paused = false + #reason = null + #aborted = false + #abort + + [kResume] = null + + rawHeaders = null + rawTrailers = null + + constructor (abort) { + this.#abort = abort + } + + pause () { + this.#paused = true + } + + resume () { + if (this.#paused) { + this.#paused = false + this[kResume]?.() + } + } + + abort (reason) { + if (!this.#aborted) { + this.#aborted = true + this.#reason = reason + this.#abort(reason) + } + } + + get aborted () { + return this.#aborted + } + + get reason () { + return this.#reason + } + + get paused () { + return this.#paused + } +} class Request { constructor (origin, { @@ -241,23 +291,26 @@ class Request { } } - onConnect (abort) { + onRequestStart (abort, context) { assert(!this.aborted) assert(!this.completed) + this[kController] = new RequestController(abort) + if (this.error) { - abort(this.error) - } else { - this.abort = abort - return this[kHandler].onConnect(abort) + this[kController].abort(this.error) + return } + + this.abort = abort + return this[kHandler].onRequestStart(this[kController], context) } onResponseStarted () { return this[kHandler].onResponseStarted?.() } - onHeaders (statusCode, headers, resume, statusText) { + onResponseStart (statusCode, headers, resume, statusText) { assert(!this.aborted) assert(!this.completed) @@ -265,36 +318,56 @@ class Request { channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }) } + const controller = this[kController] + if (controller) { + controller[kResume] = resume + controller.rawHeaders = headers + } + + const parsedHeaders = Array.isArray(headers) ? parseHeaders(headers) : headers + try { - return this[kHandler].onHeaders(statusCode, headers, resume, statusText) + this[kHandler].onResponseStart?.(controller, statusCode, parsedHeaders, statusText) + return !controller?.paused } catch (err) { this.abort(err) + return false } } - onData (chunk) { + onResponseData (chunk) { assert(!this.aborted) assert(!this.completed) if (channels.bodyChunkReceived.hasSubscribers) { channels.bodyChunkReceived.publish({ request: this, chunk }) } + + const controller = this[kController] try { - return this[kHandler].onData(chunk) + this[kHandler].onResponseData?.(controller, chunk) + return !controller?.paused } catch (err) { this.abort(err) return false } } - onUpgrade (statusCode, headers, socket) { + onRequestUpgrade (statusCode, headers, socket) { assert(!this.aborted) assert(!this.completed) - return this[kHandler].onUpgrade(statusCode, headers, socket) + const controller = this[kController] + if (controller) { + controller.rawHeaders = headers + } + + const parsedHeaders = Array.isArray(headers) ? parseHeaders(headers) : headers + + return this[kHandler].onRequestUpgrade?.(controller, statusCode, parsedHeaders, socket) } - onComplete (trailers) { + onResponseEnd (trailers) { this.onFinally() assert(!this.aborted) @@ -305,15 +378,22 @@ class Request { channels.trailers.publish({ request: this, trailers }) } + const controller = this[kController] + if (controller) { + controller.rawTrailers = trailers + } + + const parsedTrailers = Array.isArray(trailers) ? parseHeaders(trailers) : trailers + try { - return this[kHandler].onComplete(trailers) + return this[kHandler].onResponseEnd?.(controller, parsedTrailers) } catch (err) { // TODO (fix): This might be a bad idea? - this.onError(err) + this.onResponseError(err) } } - onError (error) { + onResponseError (error) { this.onFinally() if (channels.error.hasSubscribers) { @@ -325,7 +405,9 @@ class Request { } this.aborted = true - return this[kHandler].onError(error) + const controller = this[kController] + + return this[kHandler].onResponseError?.(controller, error) } onFinally () { diff --git a/lib/core/util.js b/lib/core/util.js index 767d586b93a..1090b78969d 100644 --- a/lib/core/util.js +++ b/lib/core/util.js @@ -85,23 +85,9 @@ function isStream (obj) { /** * @param {*} object * @returns {object is Blob} - * based on https://github.com/node-fetch/fetch-blob/blob/8ab587d34080de94140b54f07168451e7d0b655e/index.js#L229-L241 (MIT License) */ function isBlobLike (object) { - if (object === null) { - return false - } else if (object instanceof Blob) { - return true - } else if (typeof object !== 'object') { - return false - } else { - const sTag = object[Symbol.toStringTag] - - return (sTag === 'Blob' || sTag === 'File') && ( - ('stream' in object && typeof object.stream === 'function') || - ('arrayBuffer' in object && typeof object.arrayBuffer === 'function') - ) - } + return object instanceof Blob } /** @@ -507,6 +493,26 @@ function parseRawHeaders (headers) { return ret } +/** + * @param {Record} headers + * @returns {Buffer[]} + */ +function toRawHeaders (headers) { + const rawHeaders = [] + + for (const [name, value] of Object.entries(headers)) { + if (Array.isArray(value)) { + for (const entry of value) { + rawHeaders.push(Buffer.from(name, 'latin1'), Buffer.from(`${entry}`, 'latin1')) + } + } else { + rawHeaders.push(Buffer.from(name, 'latin1'), Buffer.from(`${value}`, 'latin1')) + } + } + + return rawHeaders +} + /** * @param {string[]} headers * @param {Buffer[]} headers @@ -540,38 +546,37 @@ function assertRequestHandler (handler, method, upgrade) { throw new InvalidArgumentError('handler must be an object') } - if (typeof handler.onRequestStart === 'function') { - // TODO (fix): More checks... - return + if (typeof handler.onRequestStart !== 'function') { + throw new InvalidArgumentError('invalid onRequestStart method') } - if (typeof handler.onConnect !== 'function') { - throw new InvalidArgumentError('invalid onConnect method') - } - - if (typeof handler.onError !== 'function') { - throw new InvalidArgumentError('invalid onError method') + if (typeof handler.onResponseError !== 'function') { + throw new InvalidArgumentError('invalid onResponseError method') } if (typeof handler.onBodySent !== 'function' && handler.onBodySent !== undefined) { throw new InvalidArgumentError('invalid onBodySent method') } + if (typeof handler.onRequestSent !== 'function' && handler.onRequestSent !== undefined) { + throw new InvalidArgumentError('invalid onRequestSent method') + } + if (upgrade || method === 'CONNECT') { - if (typeof handler.onUpgrade !== 'function') { - throw new InvalidArgumentError('invalid onUpgrade method') + if (typeof handler.onRequestUpgrade !== 'function') { + throw new InvalidArgumentError('invalid onRequestUpgrade method') } } else { - if (typeof handler.onHeaders !== 'function') { - throw new InvalidArgumentError('invalid onHeaders method') + if (typeof handler.onResponseStart !== 'function') { + throw new InvalidArgumentError('invalid onResponseStart method') } - if (typeof handler.onData !== 'function') { - throw new InvalidArgumentError('invalid onData method') + if (typeof handler.onResponseData !== 'function') { + throw new InvalidArgumentError('invalid onResponseData method') } - if (typeof handler.onComplete !== 'function') { - throw new InvalidArgumentError('invalid onComplete method') + if (typeof handler.onResponseEnd !== 'function') { + throw new InvalidArgumentError('invalid onResponseEnd method') } } } @@ -812,7 +817,7 @@ function removeAllListeners (obj) { */ function errorRequest (client, request, err) { try { - request.onError(err) + request.onResponseError(err) assert(request.aborted) } catch (err) { client.emit('error', err) @@ -961,6 +966,7 @@ module.exports = { removeAllListeners, errorRequest, parseRawHeaders, + toRawHeaders, encodeRawHeaders, parseHeaders, parseKeepAliveTimeout, diff --git a/lib/dispatcher/client-h1.js b/lib/dispatcher/client-h1.js index 939e0814bcd..887b0fb029f 100644 --- a/lib/dispatcher/client-h1.js +++ b/lib/dispatcher/client-h1.js @@ -507,7 +507,7 @@ class Parser { client.emit('disconnect', client[kUrl], [client], new InformationalError('upgrade')) try { - request.onUpgrade(statusCode, headers, socket) + request.onRequestUpgrade(statusCode, headers, socket) } catch (err) { util.destroy(socket, err) } @@ -605,7 +605,7 @@ class Parser { socket[kReset] = true } - const pause = request.onHeaders(statusCode, headers, this.resume, statusText) === false + const pause = request.onResponseStart(statusCode, headers, this.resume, statusText) === false if (request.aborted) { return -1 @@ -657,7 +657,7 @@ class Parser { this.bytesRead += buf.length - if (request.onData(buf) === false) { + if (request.onResponseData(buf) === false) { return constants.ERROR.PAUSED } @@ -703,7 +703,7 @@ class Parser { return -1 } - request.onComplete(headers) + request.onResponseEnd(headers) client[kQueue][client[kRunningIdx]++] = null @@ -1078,7 +1078,7 @@ function writeH1 (client, request) { } try { - request.onConnect(abort) + request.onRequestStart(abort, null) } catch (err) { util.errorRequest(client, request, err) } diff --git a/lib/dispatcher/client-h2.js b/lib/dispatcher/client-h2.js index 0585e7cd925..58ccdfb75ec 100644 --- a/lib/dispatcher/client-h2.js +++ b/lib/dispatcher/client-h2.js @@ -494,7 +494,7 @@ function writeH2 (client, request) { try { // We are already connected, streams are pending. // We can call on connect, and wait for abort - request.onConnect(abort) + request.onRequestStart(abort, null) } catch (err) { util.errorRequest(client, request, err) } @@ -534,7 +534,7 @@ function writeH2 (client, request) { stream.once('response', (headers, _flags) => { const { [HTTP2_HEADER_STATUS]: statusCode, ...realHeaders } = headers - request.onUpgrade(statusCode, parseH2Headers(realHeaders), stream) + request.onRequestUpgrade(statusCode, parseH2Headers(realHeaders), stream) ++session[kOpenStreams] client[kQueue][client[kRunningIdx]++] = null @@ -568,7 +568,7 @@ function writeH2 (client, request) { stream.on('response', headers => { const { [HTTP2_HEADER_STATUS]: statusCode, ...realHeaders } = headers - request.onUpgrade(statusCode, parseH2Headers(realHeaders), stream) + request.onRequestUpgrade(statusCode, parseH2Headers(realHeaders), stream) ++session[kOpenStreams] client[kQueue][client[kRunningIdx]++] = null }) @@ -697,7 +697,7 @@ function writeH2 (client, request) { return } - if (request.onHeaders(Number(statusCode), parseH2Headers(realHeaders), stream.resume.bind(stream), '') === false) { + if (request.onResponseStart(Number(statusCode), parseH2Headers(realHeaders), stream.resume.bind(stream), '') === false) { stream.pause() } @@ -706,7 +706,7 @@ function writeH2 (client, request) { return } - if (request.onData(chunk) === false) { + if (request.onResponseData(chunk) === false) { stream.pause() } }) @@ -717,7 +717,7 @@ function writeH2 (client, request) { // If we received a response, this is a normal completion if (responseReceived) { if (!request.aborted && !request.completed) { - request.onComplete({}) + request.onResponseEnd({}) } client[kQueue][client[kRunningIdx]++] = null @@ -771,8 +771,7 @@ function writeH2 (client, request) { return } - stream.removeAllListeners('data') - request.onComplete(trailers) + request.onResponseEnd(trailers) }) return true diff --git a/lib/dispatcher/dispatcher-base.js b/lib/dispatcher/dispatcher-base.js index a6f47100257..59ad3abd046 100644 --- a/lib/dispatcher/dispatcher-base.js +++ b/lib/dispatcher/dispatcher-base.js @@ -1,7 +1,6 @@ 'use strict' const Dispatcher = require('./dispatcher') -const UnwrapHandler = require('../handler/unwrap-handler') const { ClientDestroyedError, ClientClosedError, @@ -134,8 +133,6 @@ class DispatcherBase extends Dispatcher { throw new InvalidArgumentError('handler must be an object') } - handler = UnwrapHandler.unwrap(handler) - try { if (!opts || typeof opts !== 'object') { throw new InvalidArgumentError('opts must be an object.') @@ -151,11 +148,11 @@ class DispatcherBase extends Dispatcher { return this[kDispatch](opts, handler) } catch (err) { - if (typeof handler.onError !== 'function') { + if (typeof handler.onResponseError !== 'function') { throw err } - handler.onError(err) + handler.onResponseError(null, err) return false } diff --git a/lib/dispatcher/dispatcher.js b/lib/dispatcher/dispatcher.js index 824dfb6d822..ecff2a9b168 100644 --- a/lib/dispatcher/dispatcher.js +++ b/lib/dispatcher/dispatcher.js @@ -1,8 +1,5 @@ 'use strict' const EventEmitter = require('node:events') -const WrapHandler = require('../handler/wrap-handler') - -const wrapInterceptor = (dispatch) => (opts, handler) => dispatch(opts, WrapHandler.wrap(handler)) class Dispatcher extends EventEmitter { dispatch () { @@ -32,7 +29,6 @@ class Dispatcher extends EventEmitter { } dispatch = interceptor(dispatch) - dispatch = wrapInterceptor(dispatch) if (dispatch == null || typeof dispatch !== 'function' || dispatch.length !== 2) { throw new TypeError('invalid interceptor') diff --git a/lib/dispatcher/dispatcher1-wrapper.js b/lib/dispatcher/dispatcher1-wrapper.js new file mode 100644 index 00000000000..b5b69219dd4 --- /dev/null +++ b/lib/dispatcher/dispatcher1-wrapper.js @@ -0,0 +1,101 @@ +'use strict' + +const Dispatcher = require('./dispatcher') +const { InvalidArgumentError } = require('../core/errors') +const { toRawHeaders } = require('../core/util') + +class LegacyHandlerWrapper { + #handler + + constructor (handler) { + this.#handler = handler + } + + onRequestStart (controller, context) { + this.#handler.onConnect?.((reason) => controller.abort(reason), context) + } + + onRequestUpgrade (controller, statusCode, headers, socket) { + const rawHeaders = controller?.rawHeaders ?? toRawHeaders(headers ?? {}) + this.#handler.onUpgrade?.(statusCode, rawHeaders, socket) + } + + onResponseStart (controller, statusCode, headers, statusMessage) { + const rawHeaders = controller?.rawHeaders ?? toRawHeaders(headers ?? {}) + + if (this.#handler.onHeaders?.(statusCode, rawHeaders, () => controller.resume(), statusMessage) === false) { + controller.pause() + } + } + + onResponseData (controller, chunk) { + if (this.#handler.onData?.(chunk) === false) { + controller.pause() + } + } + + onResponseEnd (controller, trailers) { + const rawTrailers = controller?.rawTrailers ?? toRawHeaders(trailers ?? {}) + this.#handler.onComplete?.(rawTrailers) + } + + onResponseError (_controller, err) { + if (!this.#handler.onError) { + throw err + } + + this.#handler.onError(err) + } + + onBodySent (chunk) { + this.#handler.onBodySent?.(chunk) + } + + onRequestSent () { + this.#handler.onRequestSent?.() + } + + onResponseStarted () { + this.#handler.onResponseStarted?.() + } +} + +class Dispatcher1Wrapper extends Dispatcher { + #dispatcher + + constructor (dispatcher) { + super() + + if (!dispatcher || typeof dispatcher.dispatch !== 'function') { + throw new InvalidArgumentError('Argument dispatcher must implement dispatch') + } + + this.#dispatcher = dispatcher + } + + static wrapHandler (handler) { + if (!handler || typeof handler !== 'object') { + throw new InvalidArgumentError('handler must be an object') + } + + if (typeof handler.onRequestStart === 'function') { + return handler + } + + return new LegacyHandlerWrapper(handler) + } + + dispatch (opts, handler) { + return this.#dispatcher.dispatch(opts, Dispatcher1Wrapper.wrapHandler(handler)) + } + + close (...args) { + return this.#dispatcher.close(...args) + } + + destroy (...args) { + return this.#dispatcher.destroy(...args) + } +} + +module.exports = Dispatcher1Wrapper diff --git a/lib/dispatcher/env-http-proxy-agent.js b/lib/dispatcher/env-http-proxy-agent.js index f88437f1936..df2e9fec9f4 100644 --- a/lib/dispatcher/env-http-proxy-agent.js +++ b/lib/dispatcher/env-http-proxy-agent.js @@ -10,6 +10,22 @@ const DEFAULT_PORTS = { 'https:': 443 } +/** + * Normalizes a proxy URL by prepending a scheme if one is missing. + * This matches the behavior of curl and Go's httpproxy package, which + * assume http:// for scheme-less proxy values. + * + * @param {string} proxyUrl - The proxy URL to normalize + * @param {string} defaultScheme - The scheme to prepend if missing ('http' or 'https') + * @returns {string} The normalized proxy URL + */ +function normalizeProxyUrl (proxyUrl, defaultScheme) { + if (!proxyUrl) return proxyUrl + // If the value already contains a scheme (e.g. http://, https://, socks5://), return as-is + if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(proxyUrl)) return proxyUrl + return `${defaultScheme}://${proxyUrl}` +} + class EnvHttpProxyAgent extends DispatcherBase { #noProxyValue = null #noProxyEntries = null @@ -23,14 +39,20 @@ class EnvHttpProxyAgent extends DispatcherBase { this[kNoProxyAgent] = new Agent(agentOpts) - const HTTP_PROXY = httpProxy ?? process.env.http_proxy ?? process.env.HTTP_PROXY + const HTTP_PROXY = normalizeProxyUrl( + httpProxy ?? process.env.http_proxy ?? process.env.HTTP_PROXY, + 'http' + ) if (HTTP_PROXY) { this[kHttpProxyAgent] = new ProxyAgent({ ...agentOpts, uri: HTTP_PROXY }) } else { this[kHttpProxyAgent] = this[kNoProxyAgent] } - const HTTPS_PROXY = httpsProxy ?? process.env.https_proxy ?? process.env.HTTPS_PROXY + const HTTPS_PROXY = normalizeProxyUrl( + httpsProxy ?? process.env.https_proxy ?? process.env.HTTPS_PROXY, + 'https' + ) if (HTTPS_PROXY) { this[kHttpsProxyAgent] = new ProxyAgent({ ...agentOpts, uri: HTTPS_PROXY }) } else { diff --git a/lib/dispatcher/pool-base.js b/lib/dispatcher/pool-base.js index 6c1f2388766..daa19cb812e 100644 --- a/lib/dispatcher/pool-base.js +++ b/lib/dispatcher/pool-base.js @@ -143,7 +143,7 @@ class PoolBase extends DispatcherBase { if (!item) { break } - item.handler.onError(err) + item.handler.onResponseError(null, err) } const destroyAll = new Array(this[kClients].length) diff --git a/lib/dispatcher/proxy-agent.js b/lib/dispatcher/proxy-agent.js index 1cf9cb51f4f..2b11a1e21b7 100644 --- a/lib/dispatcher/proxy-agent.js +++ b/lib/dispatcher/proxy-agent.js @@ -54,15 +54,15 @@ class Http1ProxyWrapper extends DispatcherBase { } [kDispatch] (opts, handler) { - const onHeaders = handler.onHeaders - handler.onHeaders = function (statusCode, data, resume) { + const onResponseStart = handler.onResponseStart + handler.onResponseStart = function (controller, statusCode, data, statusMessage) { if (statusCode === 407) { - if (typeof handler.onError === 'function') { - handler.onError(new InvalidArgumentError('Proxy Authentication Required (407)')) + if (typeof handler.onResponseError === 'function') { + handler.onResponseError(controller, new InvalidArgumentError('Proxy Authentication Required (407)')) } return } - if (onHeaders) onHeaders.call(this, statusCode, data, resume) + if (onResponseStart) onResponseStart.call(this, controller, statusCode, data, statusMessage) } // Rewrite request as an HTTP1 Proxy request, without tunneling. diff --git a/lib/global.js b/lib/global.js index b61d779e498..f518dae3127 100644 --- a/lib/global.js +++ b/lib/global.js @@ -2,9 +2,13 @@ // We include a version number for the Dispatcher API. In case of breaking changes, // this version number must be increased to avoid conflicts. -const globalDispatcher = Symbol.for('undici.globalDispatcher.1') +const globalDispatcher = Symbol.for('undici.globalDispatcher.2') +const legacyGlobalDispatcher = Symbol.for('undici.globalDispatcher.1') const { InvalidArgumentError } = require('./core/errors') const Agent = require('./dispatcher/agent') +const Dispatcher1Wrapper = require('./dispatcher/dispatcher1-wrapper') + +const nodeMajor = Number(process.versions.node.split('.', 1)[0]) if (getGlobalDispatcher() === undefined) { setGlobalDispatcher(new Agent()) @@ -14,12 +18,24 @@ function setGlobalDispatcher (agent) { if (!agent || typeof agent.dispatch !== 'function') { throw new InvalidArgumentError('Argument agent must implement Agent') } + Object.defineProperty(globalThis, globalDispatcher, { value: agent, writable: true, enumerable: false, configurable: false }) + + if (nodeMajor === 22) { + const legacyAgent = agent instanceof Dispatcher1Wrapper ? agent : new Dispatcher1Wrapper(agent) + + Object.defineProperty(globalThis, legacyGlobalDispatcher, { + value: legacyAgent, + writable: true, + enumerable: false, + configurable: false + }) + } } function getGlobalDispatcher () { diff --git a/lib/handler/cache-handler.js b/lib/handler/cache-handler.js index 8cfe073503a..f3316f0f58d 100644 --- a/lib/handler/cache-handler.js +++ b/lib/handler/cache-handler.js @@ -173,7 +173,8 @@ class CacheHandler { } } - const deleteAt = determineDeleteAt(baseTime, cacheControlDirectives, absoluteStaleAt) + const cachedAt = resAge ? now - resAge : now + const deleteAt = determineDeleteAt(baseTime, cachedAt, cacheControlDirectives, absoluteStaleAt) const strippedHeaders = stripNecessaryHeaders(resHeaders, cacheControlDirectives) /** @@ -185,7 +186,7 @@ class CacheHandler { headers: strippedHeaders, vary: varyDirectives, cacheControlDirectives, - cachedAt: resAge ? now - resAge : now, + cachedAt, staleAt: absoluteStaleAt, deleteAt } @@ -485,11 +486,12 @@ function determineStaleAt (cacheType, now, age, resHeaders, responseDate, cacheC } /** - * @param {number} now + * @param {number} baseTime + * @param {number} cachedAt * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives * @param {number} staleAt */ -function determineDeleteAt (now, cacheControlDirectives, staleAt) { +function determineDeleteAt (baseTime, cachedAt, cacheControlDirectives, staleAt) { let staleWhileRevalidate = -Infinity let staleIfError = -Infinity let immutable = -Infinity @@ -503,15 +505,21 @@ function determineDeleteAt (now, cacheControlDirectives, staleAt) { } if (cacheControlDirectives.immutable && staleWhileRevalidate === -Infinity && staleIfError === -Infinity) { - immutable = now + 31536000000 + immutable = cachedAt + 31536000000 } // When no stale directives or immutable flag, add a revalidation buffer // equal to the freshness lifetime so the entry survives past staleAt long // enough to be revalidated instead of silently disappearing. + // + // Response Date headers only have second precision, so baseTime can trail the + // actual cache insertion time by up to ~1s. Pad the buffer by that bounded + // skew so short-lived entries do not disappear exactly when they should be + // revalidated. if (staleWhileRevalidate === -Infinity && staleIfError === -Infinity && immutable === -Infinity) { - const freshnessLifetime = staleAt - now - return staleAt + freshnessLifetime + const freshnessLifetime = staleAt - baseTime + const datePrecisionPadding = Math.min(Math.max(cachedAt - baseTime, 0), 1000) + return staleAt + freshnessLifetime + datePrecisionPadding } return Math.max(staleAt, staleWhileRevalidate, staleIfError, immutable) diff --git a/lib/handler/decorator-handler.js b/lib/handler/decorator-handler.js index 50fbb0cf892..1b53c711324 100644 --- a/lib/handler/decorator-handler.js +++ b/lib/handler/decorator-handler.js @@ -1,7 +1,6 @@ 'use strict' const assert = require('node:assert') -const WrapHandler = require('./wrap-handler') /** * @deprecated @@ -16,7 +15,7 @@ module.exports = class DecoratorHandler { if (typeof handler !== 'object' || handler === null) { throw new TypeError('handler must be an object') } - this.#handler = WrapHandler.wrap(handler) + this.#handler = handler } onRequestStart (...args) { diff --git a/lib/handler/retry-handler.js b/lib/handler/retry-handler.js index 1cbc78981b1..ee2f69a2043 100644 --- a/lib/handler/retry-handler.js +++ b/lib/handler/retry-handler.js @@ -3,7 +3,6 @@ const assert = require('node:assert') const { kRetryHandlerDefaultRetry } = require('../core/symbols') const { RequestRetryError } = require('../core/errors') -const WrapHandler = require('./wrap-handler') const { isDisturbed, parseRangeHeader, @@ -35,7 +34,7 @@ class RetryHandler { this.error = null this.dispatch = dispatch - this.handler = WrapHandler.wrap(handler) + this.handler = handler this.opts = { ...dispatchOpts, body: wrapRequestBody(opts.body) } this.retryOpts = { throwOnError: throwOnError ?? true, diff --git a/lib/handler/unwrap-handler.js b/lib/handler/unwrap-handler.js deleted file mode 100644 index e23b9666cf7..00000000000 --- a/lib/handler/unwrap-handler.js +++ /dev/null @@ -1,100 +0,0 @@ -'use strict' - -const { parseHeaders } = require('../core/util') -const { InvalidArgumentError } = require('../core/errors') - -const kResume = Symbol('resume') - -class UnwrapController { - #paused = false - #reason = null - #aborted = false - #abort - - [kResume] = null - - constructor (abort) { - this.#abort = abort - } - - pause () { - this.#paused = true - } - - resume () { - if (this.#paused) { - this.#paused = false - this[kResume]?.() - } - } - - abort (reason) { - if (!this.#aborted) { - this.#aborted = true - this.#reason = reason - this.#abort(reason) - } - } - - get aborted () { - return this.#aborted - } - - get reason () { - return this.#reason - } - - get paused () { - return this.#paused - } -} - -module.exports = class UnwrapHandler { - #handler - #controller - - constructor (handler) { - this.#handler = handler - } - - static unwrap (handler) { - // TODO (fix): More checks... - return !handler.onRequestStart ? handler : new UnwrapHandler(handler) - } - - onConnect (abort, context) { - this.#controller = new UnwrapController(abort) - this.#handler.onRequestStart?.(this.#controller, context) - } - - onResponseStarted () { - return this.#handler.onResponseStarted?.() - } - - onUpgrade (statusCode, rawHeaders, socket) { - this.#handler.onRequestUpgrade?.(this.#controller, statusCode, parseHeaders(rawHeaders), socket) - } - - onHeaders (statusCode, rawHeaders, resume, statusMessage) { - this.#controller[kResume] = resume - this.#handler.onResponseStart?.(this.#controller, statusCode, parseHeaders(rawHeaders), statusMessage) - return !this.#controller.paused - } - - onData (data) { - this.#handler.onResponseData?.(this.#controller, data) - return !this.#controller.paused - } - - onComplete (rawTrailers) { - this.#handler.onResponseEnd?.(this.#controller, parseHeaders(rawTrailers)) - } - - onError (err) { - if (!this.#handler.onResponseError) { - throw new InvalidArgumentError('invalid onError method') - } - - this.#handler.onResponseError?.(this.#controller, err) - } -} diff --git a/lib/handler/wrap-handler.js b/lib/handler/wrap-handler.js deleted file mode 100644 index 01440f621ae..00000000000 --- a/lib/handler/wrap-handler.js +++ /dev/null @@ -1,105 +0,0 @@ -'use strict' - -const { InvalidArgumentError } = require('../core/errors') - -module.exports = class WrapHandler { - #handler - - constructor (handler) { - this.#handler = handler - } - - static wrap (handler) { - // TODO (fix): More checks... - return handler.onRequestStart ? handler : new WrapHandler(handler) - } - - // Unwrap Interface - - onConnect (abort, context) { - return this.#handler.onConnect?.(abort, context) - } - - onResponseStarted () { - return this.#handler.onResponseStarted?.() - } - - onHeaders (statusCode, rawHeaders, resume, statusMessage) { - return this.#handler.onHeaders?.(statusCode, rawHeaders, resume, statusMessage) - } - - onUpgrade (statusCode, rawHeaders, socket) { - return this.#handler.onUpgrade?.(statusCode, rawHeaders, socket) - } - - onData (data) { - return this.#handler.onData?.(data) - } - - onComplete (trailers) { - return this.#handler.onComplete?.(trailers) - } - - onError (err) { - if (!this.#handler.onError) { - throw err - } - - return this.#handler.onError?.(err) - } - - // Wrap Interface - - onRequestStart (controller, context) { - this.#handler.onConnect?.((reason) => controller.abort(reason), context) - } - - onRequestUpgrade (controller, statusCode, headers, socket) { - const rawHeaders = [] - for (const [key, val] of Object.entries(headers)) { - rawHeaders.push(Buffer.from(key, 'latin1'), toRawHeaderValue(val)) - } - - this.#handler.onUpgrade?.(statusCode, rawHeaders, socket) - } - - onResponseStart (controller, statusCode, headers, statusMessage) { - const rawHeaders = [] - for (const [key, val] of Object.entries(headers)) { - rawHeaders.push(Buffer.from(key, 'latin1'), toRawHeaderValue(val)) - } - - if (this.#handler.onHeaders?.(statusCode, rawHeaders, () => controller.resume(), statusMessage) === false) { - controller.pause() - } - } - - onResponseData (controller, data) { - if (this.#handler.onData?.(data) === false) { - controller.pause() - } - } - - onResponseEnd (controller, trailers) { - const rawTrailers = [] - for (const [key, val] of Object.entries(trailers)) { - rawTrailers.push(Buffer.from(key, 'latin1'), toRawHeaderValue(val)) - } - - this.#handler.onComplete?.(rawTrailers) - } - - onResponseError (controller, err) { - if (!this.#handler.onError) { - throw new InvalidArgumentError('invalid onError method') - } - - this.#handler.onError?.(err) - } -} - -function toRawHeaderValue (value) { - return Array.isArray(value) - ? value.map((item) => Buffer.from(item, 'latin1')) - : Buffer.from(value, 'latin1') -} diff --git a/lib/interceptor/cache.js b/lib/interceptor/cache.js index 81d7cb12cbb..fe56baa82c9 100644 --- a/lib/interceptor/cache.js +++ b/lib/interceptor/cache.js @@ -124,30 +124,39 @@ function handleUncachedResponse ( ) { if (reqCacheControl?.['only-if-cached']) { let aborted = false - try { - if (typeof handler.onConnect === 'function') { - handler.onConnect(() => { - aborted = true - }) - if (aborted) { - return - } + const controller = { + paused: false, + rawHeaders: [], + rawTrailers: [], + pause () { + this.paused = true + }, + resume () { + this.paused = false + }, + abort: (reason) => { + aborted = true + handler.onResponseError?.(controller, reason ?? new AbortError()) } + } - if (typeof handler.onHeaders === 'function') { - handler.onHeaders(504, [], nop, 'Gateway Timeout') - if (aborted) { - return - } + try { + handler.onRequestStart?.(controller, null) + + if (aborted) { + return } - if (typeof handler.onComplete === 'function') { - handler.onComplete([]) + handler.onResponseStart?.(controller, 504, {}, 'Gateway Timeout') + if (aborted) { + return } + + handler.onResponseEnd?.(controller, {}) } catch (err) { - if (typeof handler.onError === 'function') { - handler.onError(err) + if (typeof handler.onResponseError === 'function') { + handler.onResponseError(controller, err) } } @@ -175,6 +184,8 @@ function sendCachedValue (handler, opts, result, age, context, isStale) { assert(!stream.readableDidRead, 'stream should not be readableDidRead') const controller = { + rawHeaders: [], + rawTrailers: [], resume () { stream.resume() }, @@ -227,6 +238,8 @@ function sendCachedValue (handler, opts, result, age, context, isStale) { headers.warning = '110 - "response is stale"' } + controller.rawHeaders = util.toRawHeaders(headers) + handler.onResponseStart?.(controller, result.statusCode, headers, result.statusMessage) if (opts.method === 'HEAD') { diff --git a/lib/interceptor/decompress.js b/lib/interceptor/decompress.js index ee4202a96f7..c43565a0535 100644 --- a/lib/interceptor/decompress.js +++ b/lib/interceptor/decompress.js @@ -181,6 +181,33 @@ class DecompressHandler extends DecoratorHandler { // Remove compression headers since we're decompressing const { 'content-encoding': _, 'content-length': __, ...newHeaders } = headers + if (controller?.rawHeaders) { + const rawHeaders = controller.rawHeaders + + if (Array.isArray(rawHeaders)) { + const filteredHeaders = [] + for (let i = 0; i < rawHeaders.length; i += 2) { + const headerName = rawHeaders[i] + const name = Buffer.isBuffer(headerName) ? headerName.toString('latin1') : `${headerName}` + const lowerName = name.toLowerCase() + + if (lowerName === 'content-encoding' || lowerName === 'content-length') { + continue + } + + filteredHeaders.push(rawHeaders[i], rawHeaders[i + 1]) + } + controller.rawHeaders = filteredHeaders + } else if (typeof rawHeaders === 'object') { + for (const name of Object.keys(rawHeaders)) { + const lowerName = name.toLowerCase() + if (lowerName === 'content-encoding' || lowerName === 'content-length') { + delete rawHeaders[name] + } + } + } + } + if (this.#decompressors.length === 1) { this.#setupSingleDecompressor(controller) } else { diff --git a/lib/mock/mock-utils.js b/lib/mock/mock-utils.js index a51816f4082..1022405d239 100644 --- a/lib/mock/mock-utils.js +++ b/lib/mock/mock-utils.js @@ -9,7 +9,7 @@ const { kGetNetConnect, kTotalDispatchCount } = require('./mock-symbols') -const { serializePathWithQuery } = require('../core/util') +const { serializePathWithQuery, parseHeaders } = require('../core/util') const { STATUS_CODES } = require('node:http') const { types: { @@ -311,7 +311,7 @@ function mockDispatch (opts, handler) { // If specified, trigger dispatch error if (error !== null) { deleteMockDispatch(this[kDispatches], key) - handler.onError(error) + handler.onResponseError(null, error) return true } @@ -319,24 +319,35 @@ function mockDispatch (opts, handler) { let aborted = false let timer = null - function abort (err) { - if (aborted) { - return - } - aborted = true + // Create the controller early so abort can use it + const controller = { + paused: false, + rawHeaders: null, + rawTrailers: null, + pause () { + this.paused = true + }, + resume () { + this.paused = false + }, + abort: (reason) => { + if (aborted) { + return + } + aborted = true - // Clear the pending delayed response if any - if (timer !== null) { - clearTimeout(timer) - timer = null - } + // Clear the pending delayed response if any + if (timer !== null) { + clearTimeout(timer) + timer = null + } - // Notify the handler of the abort - handler.onError(err) + handler.onResponseError?.(controller, reason) + } } - // Call onConnect to allow the handler to register the abort callback - handler.onConnect?.(abort, null) + // Call onRequestStart to allow the handler to receive the controller + handler.onRequestStart?.(controller, null) // Handle the request with a delay if necessary if (typeof delay === 'number' && delay > 0) { @@ -381,14 +392,16 @@ function mockDispatch (opts, handler) { const responseHeaders = generateKeyValues(headers) const responseTrailers = generateKeyValues(trailers) - handler.onHeaders?.(statusCode, responseHeaders, resume, getStatusText(statusCode)) - handler.onData?.(Buffer.from(responseData)) - handler.onComplete?.(responseTrailers) + // Update the controller with response data + controller.rawHeaders = responseHeaders + controller.rawTrailers = responseTrailers + + handler.onResponseStart?.(controller, statusCode, parseHeaders(responseHeaders), getStatusText(statusCode)) + handler.onResponseData?.(controller, Buffer.from(responseData)) + handler.onResponseEnd?.(controller, parseHeaders(responseTrailers)) deleteMockDispatch(mockDispatches, key) } - function resume () {} - return true } diff --git a/lib/mock/snapshot-agent.js b/lib/mock/snapshot-agent.js index 80280111921..a3aee68cb2d 100644 --- a/lib/mock/snapshot-agent.js +++ b/lib/mock/snapshot-agent.js @@ -3,8 +3,8 @@ const Agent = require('../dispatcher/agent') const MockAgent = require('./mock-agent') const { SnapshotRecorder } = require('./snapshot-recorder') -const WrapHandler = require('../handler/wrap-handler') const { InvalidArgumentError, UndiciError } = require('../core/errors') +const util = require('../core/util') const { validateSnapshotMode } = require('./snapshot-utils') const kSnapshotRecorder = Symbol('kSnapshotRecorder') @@ -79,7 +79,6 @@ class SnapshotAgent extends MockAgent { } dispatch (opts, handler) { - handler = WrapHandler.wrap(handler) const mode = this[kSnapshotMode] // Check if URL should be excluded (pass through without mocking/recording) @@ -107,8 +106,8 @@ class SnapshotAgent extends MockAgent { } else { // Playback mode but no snapshot found const error = new UndiciError(`No snapshot found for ${opts.method || 'GET'} ${opts.path}`) - if (handler.onError) { - handler.onError(error) + if (handler.onResponseError) { + handler.onResponseError(null, error) return } throw error @@ -173,6 +172,10 @@ class SnapshotAgent extends MockAgent { }) .then(() => handler.onResponseEnd(controller, trailers)) .catch((error) => handler.onResponseError(controller, error)) + }, + + onResponseError (controller, error) { + return handler.onResponseError(controller, error) } } @@ -192,7 +195,12 @@ class SnapshotAgent extends MockAgent { try { const { response } = snapshot + const rawHeaders = response.headers ? util.toRawHeaders(response.headers) : [] + const rawTrailers = response.trailers ? util.toRawHeaders(response.trailers) : [] + const controller = { + rawHeaders, + rawTrailers, pause () { }, resume () { }, abort (reason) { @@ -206,7 +214,7 @@ class SnapshotAgent extends MockAgent { handler.onRequestStart(controller) - handler.onResponseStart(controller, response.statusCode, response.headers) + handler.onResponseStart(controller, response.statusCode, response.headers, response.statusMessage) // Body is always stored as base64 string const body = Buffer.from(response.body, 'base64') @@ -214,7 +222,7 @@ class SnapshotAgent extends MockAgent { handler.onResponseEnd(controller, response.trailers) } catch (error) { - handler.onError?.(error) + handler.onResponseError?.(null, error) } } diff --git a/lib/web/fetch/index.js b/lib/web/fetch/index.js index 49fbafdc364..7c9f86eb0f5 100644 --- a/lib/web/fetch/index.js +++ b/lib/web/fetch/index.js @@ -2149,7 +2149,7 @@ async function httpNetworkFetch ( body: null, abort: null, - onConnect (abort) { + onRequestStart (controller) { // TODO (fix): Do we need connection here? const { connection } = fetchParams.controller @@ -2159,6 +2159,8 @@ async function httpNetworkFetch ( // TODO: implement connection timing timingInfo.finalConnectionTimingInfo = clampAndCoarsenConnectionTimingInfo(undefined, timingInfo.postRedirectStartTime, fetchParams.crossOriginIsolatedCapability) + const abort = (reason) => controller.abort(reason) + if (connection.destroyed) { abort(new DOMException('The operation was aborted.', 'AbortError')) } else { @@ -2179,11 +2181,12 @@ async function httpNetworkFetch ( timingInfo.finalNetworkResponseStartTime = coarsenedSharedCurrentTime(fetchParams.crossOriginIsolatedCapability) }, - onHeaders (status, rawHeaders, resume, statusText) { + onResponseStart (controller, status, _headers, statusText) { if (status < 200) { - return false + return } + const rawHeaders = controller?.rawHeaders ?? [] const headersList = new HeadersList() for (let i = 0; i < rawHeaders.length; i += 2) { @@ -2199,7 +2202,7 @@ async function httpNetworkFetch ( } const location = headersList.get('location', true) - this.body = new Readable({ read: resume }) + this.body = new Readable({ read: () => controller.resume() }) const willFollow = location && request.redirect === 'follow' && redirectStatusSet.has(status) @@ -2219,7 +2222,7 @@ async function httpNetworkFetch ( const maxContentEncodings = 5 if (codings.length > maxContentEncodings) { reject(new Error(`too many content-encodings in response: ${codings.length}, maximum allowed is ${maxContentEncodings}`)) - return true + return } for (let i = codings.length - 1; i >= 0; --i) { @@ -2256,7 +2259,7 @@ async function httpNetworkFetch ( } } - const onError = this.onError.bind(this) + const onError = (err) => this.onResponseError(controller, err) resolve({ status, @@ -2265,16 +2268,14 @@ async function httpNetworkFetch ( body: decoders.length ? pipeline(this.body, ...decoders, (err) => { if (err) { - this.onError(err) + this.onResponseError(controller, err) } }).on('error', onError) : this.body.on('error', onError) }) - - return true }, - onData (chunk) { + onResponseData (controller, chunk) { if (fetchParams.controller.dump) { return } @@ -2294,20 +2295,22 @@ async function httpNetworkFetch ( // 4. See pullAlgorithm... - return this.body.push(bytes) + if (this.body.push(bytes) === false) { + controller.pause() + } }, - onComplete () { + onResponseEnd () { if (this.abort) { fetchParams.controller.off('terminated', this.abort) } fetchParams.controller.ended = true - this.body.push(null) + this.body?.push(null) }, - onError (error) { + onResponseError (_controller, error) { if (this.abort) { fetchParams.controller.off('terminated', this.abort) } @@ -2319,48 +2322,14 @@ async function httpNetworkFetch ( reject(error) }, - onRequestUpgrade (_controller, status, headers, socket) { - // We need to support 200 for websocket over h2 as per RFC-8441 - // Absence of session means H1 - if ((socket.session != null && status !== 200) || (socket.session == null && status !== 101)) { - return false - } - - const headersList = new HeadersList() - - for (const [name, value] of Object.entries(headers)) { - if (value == null) { - continue - } - - const headerName = name.toLowerCase() - - if (Array.isArray(value)) { - for (const entry of value) { - headersList.append(headerName, String(entry), true) - } - } else { - headersList.append(headerName, String(value), true) - } - } - - resolve({ - status, - statusText: STATUS_CODES[status], - headersList, - socket - }) - - return true - }, - - onUpgrade (status, rawHeaders, socket) { + onRequestUpgrade (controller, status, _headers, socket) { // We need to support 200 for websocket over h2 as per RFC-8441 // Absence of session means H1 if ((socket.session != null && status !== 200) || (socket.session == null && status !== 101)) { return false } + const rawHeaders = controller?.rawHeaders ?? [] const headersList = new HeadersList() for (let i = 0; i < rawHeaders.length; i += 2) { diff --git a/package.json b/package.json index 328390046b8..5fa87812c43 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "undici", - "version": "7.24.7", + "version": "8.0.0", "description": "An HTTP/1.1 client, written from scratch for Node.js", "homepage": "https://undici.nodejs.org", "bugs": { @@ -97,7 +97,7 @@ "test:websocket:autobahn": "node test/autobahn/client.js", "test:websocket:autobahn:report": "node test/autobahn/report.js", "test:wpt:setup": "node test/web-platform-tests/wpt-runner.mjs setup", - "test:wpt": "npm run test:wpt:setup && node test/web-platform-tests/wpt-runner.mjs run /fetch /mimesniff /xhr /websockets /serviceWorkers /eventsource", + "test:wpt": "npm run test:wpt:setup && node test/web-platform-tests/wpt-runner.mjs run /fetch /mimesniff /websockets /serviceWorkers /eventsource", "test:cache-tests": "node test/cache-interceptor/cache-tests.mjs --ci", "coverage": "npm run coverage:clean && cross-env NODE_V8_COVERAGE=./coverage/tmp npm run test:javascript && npm run coverage:report", "coverage:ci": "npm run coverage:clean && cross-env NODE_V8_COVERAGE=./coverage/tmp npm run test:javascript && npm run coverage:report:ci", @@ -113,7 +113,7 @@ "@matteo.collina/tspl": "^0.2.0", "@metcoder95/https-pem": "^1.0.0", "@sinonjs/fake-timers": "^12.0.0", - "@types/node": "^20.19.22", + "@types/node": "^22.0.0", "abort-controller": "^3.0.0", "borp": "^0.20.0", "c8": "^10.0.0", @@ -133,7 +133,7 @@ "ws": "^8.11.0" }, "engines": { - "node": ">=20.18.1" + "node": ">=22.19.0" }, "tsd": { "directory": "test/types", diff --git a/scripts/find-hanging-tests.sh b/scripts/find-hanging-tests.sh new file mode 100755 index 00000000000..759f6642d0c --- /dev/null +++ b/scripts/find-hanging-tests.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +TIMEOUT_SECONDS=${TIMEOUT_SECONDS:-120} +TEST_GLOB=${TEST_GLOB:-"test/*.js"} +LOG_DIR=${LOG_DIR:-"/tmp/undici-test-hang"} + +mkdir -p "$LOG_DIR" + +failures=() +timeouts=() + +while IFS= read -r test_file; do + echo "==> ${test_file}" + log_file="$LOG_DIR/$(basename "$test_file").log" + if timeout "$TIMEOUT_SECONDS" npx borp -p "$test_file" >"$log_file" 2>&1; then + echo "PASS ${test_file}" + else + exit_code=$? + if [[ $exit_code -eq 124 || $exit_code -eq 137 ]]; then + echo "TIMEOUT ${test_file}" + timeouts+=("$test_file") + else + echo "FAIL ${test_file} (exit $exit_code)" + failures+=("$test_file") + fi + fi + echo + sleep 0.2 +done < <(ls $TEST_GLOB | sort) + +echo "=== Summary ===" +if [[ ${#timeouts[@]} -gt 0 ]]; then + echo "Timeouts:" + printf ' - %s\n' "${timeouts[@]}" +else + echo "Timeouts: none" +fi + +if [[ ${#failures[@]} -gt 0 ]]; then + echo "Failures:" + printf ' - %s\n' "${failures[@]}" +else + echo "Failures: none" +fi + +echo "Logs: $LOG_DIR" diff --git a/test/client-request.js b/test/client-request.js index d367435eeff..ff4abf4bb4f 100644 --- a/test/client-request.js +++ b/test/client-request.js @@ -154,8 +154,8 @@ test('request dump with abort signal', async (t) => { t.ok(stackLines[2].startsWith('at AbortController.abort')) t.ok(/client-request.js/.test(stackLines[3])) t.ok(stackLines[4].startsWith('at RequestHandler.runInAsyncScope')) - t.ok(stackLines[5].startsWith('at RequestHandler.onHeaders')) - t.ok(stackLines[6].startsWith('at Request.onHeaders')) + t.ok(stackLines[5].startsWith('at RequestHandler.onResponseStart')) + t.ok(stackLines[6].startsWith('at Request.onResponseStart')) server.close() }) ac.abort() @@ -193,8 +193,8 @@ test('request dump with POJO as invalid signal', async (t) => { t.ok(stackLines[1].startsWith('at BodyReadable.dump')) t.ok(/client-request.js/.test(stackLines[2])) t.ok(stackLines[3].startsWith('at RequestHandler.runInAsyncScope')) - t.ok(stackLines[4].startsWith('at RequestHandler.onHeaders')) - t.ok(stackLines[5].startsWith('at Request.onHeaders')) + t.ok(stackLines[4].startsWith('at RequestHandler.onResponseStart')) + t.ok(stackLines[5].startsWith('at Request.onResponseStart')) server.close() }) }) @@ -233,8 +233,8 @@ test('request dump with aborted signal', async (t) => { t.ok(stackLines[0].startsWith('AbortError: This operation was with purpose aborted')) t.ok(/client-request.js/.test(stackLines[1])) t.ok(stackLines[2].startsWith('at RequestHandler.runInAsyncScope')) - t.ok(stackLines[3].startsWith('at RequestHandler.onHeaders')) - t.ok(stackLines[4].startsWith('at Request.onHeaders')) + t.ok(stackLines[3].startsWith('at RequestHandler.onResponseStart')) + t.ok(stackLines[4].startsWith('at Request.onResponseStart')) server.close() }) ac.abort() diff --git a/test/client-timeout.js b/test/client-timeout.js index a2c18cd8fd0..c115ba5c8f6 100644 --- a/test/client-timeout.js +++ b/test/client-timeout.js @@ -26,21 +26,21 @@ test('refresh timeout on pause', async (t) => { path: '/', method: 'GET' }, { - onConnect () { + onRequestStart () { }, - onHeaders (statusCode, headers, resume) { + onResponseStart (controller) { setTimeout(() => { - resume() + controller.resume() }, 1000) - return false + controller.pause() }, - onData () { + onResponseData () { }, - onComplete () { + onResponseEnd () { }, - onError (err) { + onResponseError (_controller, err) { t.ok(err instanceof errors.BodyTimeoutError) } }) @@ -78,7 +78,7 @@ test('start headers timeout after request body', async (t) => { body, method: 'GET' }, { - onConnect () { + onRequestStart () { process.nextTick(() => { clock.tick(200) }) @@ -89,15 +89,15 @@ test('start headers timeout after request body', async (t) => { }) }) }, - onHeaders (statusCode, headers, resume) { + onResponseStart () { }, - onData () { + onResponseData () { }, - onComplete () { + onResponseEnd () { }, - onError (err) { + onResponseError (_controller, err) { t.equal(body.readableEnded, true) t.ok(err instanceof errors.HeadersTimeoutError) } @@ -141,7 +141,7 @@ test('start headers timeout after async iterator request body', async (t) => { body, method: 'GET' }, { - onConnect () { + onRequestStart () { process.nextTick(() => { clock.tick(200) }) @@ -149,15 +149,15 @@ test('start headers timeout after async iterator request body', async (t) => { res() }) }, - onHeaders (statusCode, headers, resume) { + onResponseStart () { }, - onData () { + onResponseData () { }, - onComplete () { + onResponseEnd () { }, - onError (err) { + onResponseError (_controller, err) { t.ok(err instanceof errors.HeadersTimeoutError) } }) @@ -190,19 +190,19 @@ test('parser resume with no body timeout', async (t) => { path: '/', method: 'GET' }, { - onConnect () { + onRequestStart () { }, - onHeaders (statusCode, headers, resume) { - setTimeout(resume, 2000) - return false + onResponseStart (controller) { + setTimeout(() => controller.resume(), 2000) + controller.pause() }, - onData () { + onResponseData () { }, - onComplete () { + onResponseEnd () { t.ok(true, 'pass') }, - onError (err) { + onResponseError (_controller, err) { t.ifError(err) } }) diff --git a/test/decorator-handler.js b/test/decorator-handler.js index fc3e657f169..06c2e83b011 100644 --- a/test/decorator-handler.js +++ b/test/decorator-handler.js @@ -33,28 +33,28 @@ describe('DecoratorHandler', () => { this.#handler = handler } - onConnect (abort, context) { - return this.#handler?.onConnect?.(abort, context) + onRequestStart (controller, context) { + return this.#handler?.onRequestStart?.(controller, context) } - onHeaders (statusCode, rawHeaders, resume, statusMessage) { - return this.#handler?.onHeaders?.(statusCode, rawHeaders, resume, statusMessage) + onResponseStart (controller, statusCode, headers, statusMessage) { + return this.#handler?.onResponseStart?.(controller, statusCode, headers, statusMessage) } - onUpgrade (statusCode, rawHeaders, socket) { - return this.#handler?.onUpgrade?.(statusCode, rawHeaders, socket) + onRequestUpgrade (controller, statusCode, headers, socket) { + return this.#handler?.onRequestUpgrade?.(controller, statusCode, headers, socket) } - onData (data) { - return this.#handler?.onData?.(data) + onResponseData (controller, data) { + return this.#handler?.onResponseData?.(controller, data) } - onComplete (trailers) { - return this.#handler?.onComplete?.(trailers) + onResponseEnd (controller, trailers) { + return this.#handler?.onResponseEnd?.(controller, trailers) } - onError (err) { - return this.#handler?.onError?.(err) + onResponseError (controller, err) { + return this.#handler?.onResponseError?.(controller, err) } } const Controller = class { @@ -76,13 +76,14 @@ describe('DecoratorHandler', () => { } } - describe('#onConnect', () => { - test('should delegate onConnect-method', t => { - t = tspl(t, { plan: 2 }) + describe('#onRequestStart', () => { + test('should delegate onRequestStart-method', t => { + t = tspl(t, { plan: 3 }) const handler = new Handler( { - onConnect: (abort, ctx) => { - t.equal(typeof abort, 'function') + onRequestStart: (controller, ctx) => { + t.equal(typeof controller, 'object') + t.equal(typeof controller.abort, 'function') t.equal(typeof ctx, 'object') } }) @@ -90,22 +91,22 @@ describe('DecoratorHandler', () => { decorator.onRequestStart(new Controller(), {}) }) - test('should not throw if onConnect-method is not defined in the handler', t => { + test('should not throw if onRequestStart-method is not defined in the handler', t => { t = tspl(t, { plan: 1 }) const decorator = new DecoratorHandler({}) t.doesNotThrow(() => decorator.onRequestStart()) }) }) - describe('#onHeaders', () => { - test('should delegate onHeaders-method', t => { + describe('#onResponseStart', () => { + test('should delegate onResponseStart-method', t => { t = tspl(t, { plan: 4 }) const handler = new Handler( { - onHeaders: (statusCode, headers, resume, statusMessage) => { - t.equal(statusCode, '200') - t.equal(`${headers[0].toString('utf-8')}: ${headers[1].toString('utf-8')}`, 'content-type: application/json') - t.equal(typeof resume, 'function') + onResponseStart: (controller, statusCode, headers, statusMessage) => { + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'application/json') + t.equal(typeof controller.resume, 'function') t.equal(statusMessage, 'OK') } }) @@ -115,7 +116,7 @@ describe('DecoratorHandler', () => { }, 'OK') }) - test('should not throw if onHeaders-method is not defined in the handler', t => { + test('should not throw if onResponseStart-method is not defined in the handler', t => { t = tspl(t, { plan: 1 }) const decorator = new DecoratorHandler({}) t.doesNotThrow(() => decorator.onResponseStart(new Controller(), 200, { @@ -124,14 +125,14 @@ describe('DecoratorHandler', () => { }) }) - describe('#onUpgrade', () => { - test('should delegate onUpgrade-method', t => { + describe('#onRequestUpgrade', () => { + test('should delegate onRequestUpgrade-method', t => { t = tspl(t, { plan: 3 }) const handler = new Handler( { - onUpgrade: (statusCode, headers, socket) => { + onRequestUpgrade: (_controller, statusCode, headers, socket) => { t.equal(statusCode, 301) - t.equal(`${headers[0].toString('utf-8')}: ${headers[1].toString('utf-8')}`, 'content-type: application/json') + t.equal(headers['content-type'], 'application/json') t.equal(typeof socket, 'object') } }) @@ -141,7 +142,7 @@ describe('DecoratorHandler', () => { }, {}) }) - test('should not throw if onUpgrade-method is not defined in the handler', t => { + test('should not throw if onRequestUpgrade-method is not defined in the handler', t => { t = tspl(t, { plan: 1 }) const decorator = new DecoratorHandler({}) t.doesNotThrow(() => decorator.onRequestUpgrade(new Controller(), 301, { @@ -150,12 +151,12 @@ describe('DecoratorHandler', () => { }) }) - describe('#onData', () => { - test('should delegate onData-method', t => { + describe('#onResponseData', () => { + test('should delegate onResponseData-method', t => { t = tspl(t, { plan: 1 }) const handler = new Handler( { - onData: (chunk) => { + onResponseData: (_controller, chunk) => { t.equal('chunk', chunk) } }) @@ -163,39 +164,39 @@ describe('DecoratorHandler', () => { decorator.onResponseData(new Controller(), 'chunk') }) - test('should not throw if onData-method is not defined in the handler', t => { + test('should not throw if onResponseData-method is not defined in the handler', t => { t = tspl(t, { plan: 1 }) const decorator = new DecoratorHandler({}) t.doesNotThrow(() => decorator.onResponseData(new Controller(), 'chunk')) }) }) - describe('#onComplete', () => { - test('should delegate onComplete-method', t => { + describe('#onResponseEnd', () => { + test('should delegate onResponseEnd-method', t => { t = tspl(t, { plan: 1 }) const handler = new Handler( { - onComplete: (trailers) => { - t.equal(`${trailers[0].toString('utf-8')}: ${trailers[1].toString('utf-8')}`, 'x-trailer: trailer') + onResponseEnd: (_controller, trailers) => { + t.equal(trailers['x-trailer'], 'trailer') } }) const decorator = new DecoratorHandler(handler) decorator.onResponseEnd(new Controller(), { 'x-trailer': 'trailer' }) }) - test('should not throw if onComplete-method is not defined in the handler', t => { + test('should not throw if onResponseEnd-method is not defined in the handler', t => { t = tspl(t, { plan: 1 }) const decorator = new DecoratorHandler({}) t.doesNotThrow(() => decorator.onResponseEnd(new Controller(), { 'x-trailer': 'trailer' })) }) }) - describe('#onError', () => { - test('should delegate onError-method', t => { + describe('#onResponseError', () => { + test('should delegate onResponseError-method', t => { t = tspl(t, { plan: 1 }) const handler = new Handler( { - onError: (err) => { + onResponseError: (_controller, err) => { t.equal(err.message, 'Oops!') } }) @@ -203,10 +204,10 @@ describe('DecoratorHandler', () => { decorator.onResponseError(new Controller(), new Error('Oops!')) }) - test('should throw if onError-method is not defined in the handler', t => { + test('should not throw if onResponseError-method is not defined in the handler', t => { t = tspl(t, { plan: 1 }) const decorator = new DecoratorHandler({}) - t.throws(() => decorator.onResponseError(new Controller(), new Error('Oops!'))) + t.doesNotThrow(() => decorator.onResponseError(new Controller(), new Error('Oops!'))) }) }) }) @@ -379,7 +380,7 @@ describe('DecoratorHandler', () => { }) describe('#onResponseError', () => { - test('should delegate onError-method', t => { + test('should delegate onResponseError-method', t => { t = tspl(t, { plan: 2 }) const handler = new Handler( { @@ -392,7 +393,7 @@ describe('DecoratorHandler', () => { decorator.onResponseError(new Controller(), new Error('Oops!')) }) - test('should throw if onError-method is not defined in the handler', t => { + test('should throw if onResponseError-method is not defined in the handler', t => { t = tspl(t, { plan: 1 }) const decorator = new DecoratorHandler({ // To hin and not wrap the instance diff --git a/test/env-http-proxy-agent-nodejs-bundle.js b/test/env-http-proxy-agent-nodejs-bundle.js index 47c2649ff42..4cf238e9f6b 100644 --- a/test/env-http-proxy-agent-nodejs-bundle.js +++ b/test/env-http-proxy-agent-nodejs-bundle.js @@ -2,7 +2,7 @@ const { tspl } = require('@matteo.collina/tspl') const { describe, test, after, before } = require('node:test') -const { EnvHttpProxyAgent, setGlobalDispatcher } = require('../index-fetch') +const { EnvHttpProxyAgent, setGlobalDispatcher, fetch: undiciFetch } = require('../index-fetch') const http = require('node:http') const net = require('node:net') const { once } = require('node:events') @@ -20,7 +20,7 @@ describe('EnvHttpProxyAgent and setGlobalDispatcher', () => { process.env = { ...env } }) - test('should work with global fetch from undici bundled with Node.js', async (t) => { + test('should work with undici fetch from index-fetch', async (t) => { const { strictEqual } = tspl(t, { plan: 3 }) // Instead of using mocks, start a real server and a minimal proxy server @@ -75,8 +75,7 @@ describe('EnvHttpProxyAgent and setGlobalDispatcher', () => { process.env.http_proxy = proxyAddress setGlobalDispatcher(new EnvHttpProxyAgent()) - // eslint-disable-next-line no-restricted-globals - const res = await fetch(serverAddress) + const res = await undiciFetch(serverAddress) strictEqual(await res.text(), 'Hello world') }) }) diff --git a/test/env-http-proxy-agent.js b/test/env-http-proxy-agent.js index 97969cfaf20..bf061704906 100644 --- a/test/env-http-proxy-agent.js +++ b/test/env-http-proxy-agent.js @@ -124,6 +124,61 @@ test('prefers lowercase over uppercase env vars even when empty', async (t) => { return dispatcher.close() }) +test('handles scheme-less HTTP_PROXY by assuming http://', async (t) => { + t = tspl(t, { plan: 2 }) + process.env.HTTP_PROXY = 'example.com:8080' + const dispatcher = new EnvHttpProxyAgent() + t.ok(dispatcher[kHttpProxyAgent] instanceof ProxyAgent) + t.equal(dispatcher[kHttpProxyAgent][kProxy].uri, 'http://example.com:8080/') + return dispatcher.close() +}) + +test('handles scheme-less HTTPS_PROXY by assuming https://', async (t) => { + t = tspl(t, { plan: 2 }) + process.env.HTTPS_PROXY = 'example.com:8443' + const dispatcher = new EnvHttpProxyAgent() + t.ok(dispatcher[kHttpsProxyAgent] instanceof ProxyAgent) + t.equal(dispatcher[kHttpsProxyAgent][kProxy].uri, 'https://example.com:8443/') + return dispatcher.close() +}) + +test('handles scheme-less httpProxy option by assuming http://', async (t) => { + t = tspl(t, { plan: 2 }) + const dispatcher = new EnvHttpProxyAgent({ httpProxy: 'myproxy:9000' }) + t.ok(dispatcher[kHttpProxyAgent] instanceof ProxyAgent) + t.equal(dispatcher[kHttpProxyAgent][kProxy].uri, 'http://myproxy:9000/') + return dispatcher.close() +}) + +test('handles scheme-less httpsProxy option by assuming https://', async (t) => { + t = tspl(t, { plan: 2 }) + const dispatcher = new EnvHttpProxyAgent({ httpsProxy: 'myproxy:9443' }) + t.ok(dispatcher[kHttpsProxyAgent] instanceof ProxyAgent) + t.equal(dispatcher[kHttpsProxyAgent][kProxy].uri, 'https://myproxy:9443/') + return dispatcher.close() +}) + +test('does not modify proxy URLs that already have a scheme', async (t) => { + t = tspl(t, { plan: 4 }) + process.env.HTTP_PROXY = 'http://example.com:8080' + process.env.HTTPS_PROXY = 'http://example.com:8443' + const dispatcher = new EnvHttpProxyAgent() + t.ok(dispatcher[kHttpProxyAgent] instanceof ProxyAgent) + t.equal(dispatcher[kHttpProxyAgent][kProxy].uri, 'http://example.com:8080/') + t.ok(dispatcher[kHttpsProxyAgent] instanceof ProxyAgent) + t.equal(dispatcher[kHttpsProxyAgent][kProxy].uri, 'http://example.com:8443/') + return dispatcher.close() +}) + +test('handles scheme-less hostname-only proxy values', async (t) => { + t = tspl(t, { plan: 2 }) + process.env.HTTP_PROXY = 'myproxy' + const dispatcher = new EnvHttpProxyAgent() + t.ok(dispatcher[kHttpProxyAgent] instanceof ProxyAgent) + t.equal(dispatcher[kHttpProxyAgent][kProxy].uri, 'http://myproxy/') + return dispatcher.close() +}) + test('creates a proxy agent only for https when only https_proxy is set', async (t) => { t = tspl(t, { plan: 5 }) process.env.https_proxy = 'http://example.com:8443' diff --git a/test/fetch/issue-4897.js b/test/fetch/issue-4897.js index aa307cc82d7..1369e16db54 100644 --- a/test/fetch/issue-4897.js +++ b/test/fetch/issue-4897.js @@ -7,7 +7,7 @@ function createAssertingDispatcher (t, expectedPath) { return { dispatch (opts, handler) { t.assert.strictEqual(opts.path, expectedPath) - handler.onError(new Error('stop')) + handler.onResponseError(null, new Error('stop')) return true } } diff --git a/test/http2-body.js b/test/http2-body.js index ac1d7fc2ff7..0db11a8b634 100644 --- a/test/http2-body.js +++ b/test/http2-body.js @@ -111,29 +111,30 @@ test('Should handle h2 request with body (string or buffer) - dispatch', async t body: expectedBody }, { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onError (err) { + onResponseError (_controller, err) { t.ifError(err) }, - onHeaders (statusCode, headers) { + onResponseStart (controller, statusCode) { + const rawHeaders = controller.rawHeaders t.strictEqual(statusCode, 200) - t.strictEqual(headers[0].toString('utf-8'), 'content-type') + t.strictEqual(rawHeaders[0].toString('utf-8'), 'content-type') t.strictEqual( - headers[1].toString('utf-8'), + rawHeaders[1].toString('utf-8'), 'text/plain; charset=utf-8' ) - t.strictEqual(headers[2].toString('utf-8'), 'x-custom-h2') - t.strictEqual(headers[3].toString('utf-8'), 'foo') + t.strictEqual(rawHeaders[2].toString('utf-8'), 'x-custom-h2') + t.strictEqual(rawHeaders[3].toString('utf-8'), 'foo') }, - onData (chunk) { + onResponseData (_controller, chunk) { response.push(chunk) }, onBodySent (body) { t.strictEqual(body.toString('utf-8'), expectedBody) }, - onComplete () { + onResponseEnd () { t.strictEqual(Buffer.concat(response).toString('utf-8'), 'hello h2!') t.strictEqual( Buffer.concat(requestBody).toString('utf-8'), diff --git a/test/http2-dispatcher.js b/test/http2-dispatcher.js index 0c85fc2afab..bef6c623e55 100644 --- a/test/http2-dispatcher.js +++ b/test/http2-dispatcher.js @@ -511,19 +511,13 @@ test('Should send http2 PING frames', async t => { method: 'PUT', body: 'hello' }, { - onConnect () { - - }, - onHeaders () { - return true - }, - onData () { - return true - }, - onComplete (trailers) { + onRequestStart () {}, + onResponseStart () {}, + onResponseData () {}, + onResponseEnd (_controller, trailers) { t.strictEqual(trailers['x-trailer'], 'hello') }, - onError (err) { + onResponseError (_controller, err) { t.ifError(err) } }) @@ -588,19 +582,13 @@ test('Should not send http2 PING frames if interval === 0', async t => { method: 'PUT', body: 'hello' }, { - onConnect () { - - }, - onHeaders () { - return true - }, - onData () { - return true - }, - onComplete (trailers) { + onRequestStart () {}, + onResponseStart () {}, + onResponseData () {}, + onResponseEnd (_controller, trailers) { t.strictEqual(trailers['x-trailer'], 'hello') }, - onError (err) { + onResponseError (_controller, err) { t.ifError(err) } }) @@ -666,19 +654,13 @@ test('Should not send http2 PING frames after connection is closed', async t => method: 'PUT', body: 'hello' }, { - onConnect () { - - }, - onHeaders () { - return true - }, - onData () { - return true - }, - onComplete (trailers) { + onRequestStart () {}, + onResponseStart () {}, + onResponseData () {}, + onResponseEnd (_controller, trailers) { t.strictEqual(trailers['x-trailer'], 'hello') }, - onError (err) { + onResponseError (_controller, err) { t.ifError(err) } }) diff --git a/test/http2-late-data.js b/test/http2-late-data.js index b5f05dc6e09..af4dcfbe409 100644 --- a/test/http2-late-data.js +++ b/test/http2-late-data.js @@ -127,19 +127,16 @@ test('Should ignore late http2 data after request completion', async (t) => { method: 'GET', headers: {} }, { - onConnect () {}, - onHeaders () { - return true - }, - onData () { + onRequestStart () {}, + onResponseStart () {}, + onResponseData () { onDataCalls++ - return true }, - onComplete (trailers) { + onResponseEnd (_controller, trailers) { onCompleteCalls++ t.strictEqual(trailers['x-trailer'], 'hello') }, - onError (err) { + onResponseError (_controller, err) { t.ifError(err) } }) diff --git a/test/http2-trailers.js b/test/http2-trailers.js index 2d49e8b0bc2..673252c8d21 100644 --- a/test/http2-trailers.js +++ b/test/http2-trailers.js @@ -49,19 +49,13 @@ test('Should handle http2 trailers', async t => { method: 'PUT', body: 'hello' }, { - onConnect () { - - }, - onHeaders () { - return true - }, - onData () { - return true - }, - onComplete (trailers) { + onRequestStart () {}, + onResponseStart () {}, + onResponseData () {}, + onResponseEnd (_controller, trailers) { t.strictEqual(trailers['x-trailer'], 'hello') }, - onError (err) { + onResponseError (_controller, err) { t.ifError(err) } }) diff --git a/test/interceptors/cache.js b/test/interceptors/cache.js index 95f8e03fefa..9dce02f07e4 100644 --- a/test/interceptors/cache.js +++ b/test/interceptors/cache.js @@ -6,8 +6,9 @@ const { once } = require('node:events') const { equal, strictEqual, notEqual, fail } = require('node:assert') const { setTimeout: sleep } = require('node:timers/promises') const FakeTimers = require('@sinonjs/fake-timers') -const { Client, interceptors, cacheStores: { MemoryCacheStore } } = require('../../index') +const { Client, interceptors, cacheStores: { MemoryCacheStore, SqliteCacheStore } } = require('../../index') const { makeCacheKey } = require('../../lib/util/cache.js') +const { runtimeFeatures } = require('../../lib/util/runtime-features.js') describe('Cache Interceptor', () => { test('caches request', async () => { @@ -2023,6 +2024,82 @@ describe('Cache Interceptor', () => { equal(cached.deleteAt > cached.staleAt, true, 'deleteAt should be greater than staleAt to allow revalidation') }) + test('sqlite store keeps short-lived entries past Date header precision loss so they can revalidate', { skip: runtimeFeatures.has('sqlite') === false }, async () => { + const clock = FakeTimers.install({ now: 1000, shouldClearNativeTimers: true }) + const store = new SqliteCacheStore() + let requestsToOrigin = 0 + let revalidationHeaders + + const server = createServer({ joinDuplicateHeaders: true }, (req, res) => { + requestsToOrigin++ + + if (requestsToOrigin === 2) { + revalidationHeaders = req.headers + + if (req.headers['if-none-match'] !== '"abcd"' || typeof req.headers['if-modified-since'] !== 'string') { + res.statusCode = 412 + res.end('expected conditional revalidation') + return + } + + res.statusCode = 304 + res.end() + return + } + + res.setHeader('cache-control', 'max-age=2, must-revalidate') + // Deliberately round the response date down to the previous second. + res.setHeader('date', new Date(0).toUTCString()) + res.setHeader('etag', '"abcd"') + res.sendDate = false + res.end('short-lived') + }).listen(0) + + after(async () => { + server.close() + store.close() + await client.close() + clock.uninstall() + }) + + await once(server, 'listening') + + const origin = `http://localhost:${server.address().port}` + const client = new Client(origin) + .compose(interceptors.cache({ store, type: 'private' })) + + const request = { + origin, + method: 'GET', + path: '/date-header-precision' + } + + { + const res = await client.request(request) + strictEqual(await res.body.text(), 'short-lived') + equal(requestsToOrigin, 1) + } + + { + const res = await client.request(request) + strictEqual(await res.body.text(), 'short-lived') + equal(requestsToOrigin, 1) + } + + clock.tick(3001) + + const cached = store.get(makeCacheKey({ ...request, headers: {} })) + notEqual(cached, undefined, 'entry should remain available long enough to be revalidated') + + { + const res = await client.request(request) + strictEqual(await res.body.text(), 'short-lived') + equal(requestsToOrigin, 2) + strictEqual(revalidationHeaders['if-none-match'], '"abcd"') + strictEqual(typeof revalidationHeaders['if-modified-since'], 'string') + } + }) + test('immutable response has deleteAt of ~1 year', async () => { const clock = FakeTimers.install({ now: 1000 }) after(() => clock.uninstall()) diff --git a/test/interceptors/deduplicate.js b/test/interceptors/deduplicate.js index 458bd80bc08..3a6546379e5 100644 --- a/test/interceptors/deduplicate.js +++ b/test/interceptors/deduplicate.js @@ -1248,16 +1248,16 @@ describe('Deduplicate Interceptor', () => { const slowWaitingHandlerErrorPromise = new Promise((resolve, reject) => { client.dispatch(request, { - onConnect () {}, - onHeaders () {}, - onData () { + onRequestStart () {}, + onResponseStart () {}, + onResponseData (controller) { // Pause the waiting handler immediately and never resume it. - return false + controller.pause() }, - onComplete () { + onResponseEnd () { reject(new Error('Expected paused waiting handler to fail')) }, - onError (err) { + onResponseError (_controller, err) { resolve(err) } }) diff --git a/test/issue-3934.js b/test/issue-3934.js index 1dfbb934855..cb59962c09f 100644 --- a/test/issue-3934.js +++ b/test/issue-3934.js @@ -7,7 +7,7 @@ const assert = require('node:assert') const { Agent, RetryAgent, request } = require('..') // https://github.com/nodejs/undici/issues/3934 -test('WrapHandler works with multiple header values', async (t) => { +test('request preserves multiple header values', async (t) => { const server = createServer({ joinDuplicateHeaders: true }, async (_req, res) => { const headers = [ ['set-cookie', 'a'], diff --git a/test/issue-4780.js b/test/issue-4780.js new file mode 100644 index 00000000000..43ae663d3a9 --- /dev/null +++ b/test/issue-4780.js @@ -0,0 +1,62 @@ +'use strict' + +const { strictEqual } = require('node:assert') +const { test } = require('node:test') +const { Dispatcher } = require('..') +const DispatcherBase = require('../lib/dispatcher/dispatcher-base') + +function createController () { + return { + abort () {}, + pause () {}, + resume () {} + } +} + +class NewAPIDispatcher extends Dispatcher { + dispatch (opts, handler) { + const controller = createController() + + handler.onRequestStart?.(controller, {}) + handler.onResponseStart?.(controller, 200, { 'content-type': 'text/plain' }, 'OK') + handler.onResponseData?.(controller, Buffer.from('Hello, world!')) + handler.onResponseEnd?.(controller, {}) + + return true + } + + close () {} + + destroy () {} +} + +class NewAPIDispatcherBase extends DispatcherBase { + dispatch (opts, handler) { + const controller = createController() + + handler.onRequestStart?.(controller, {}) + handler.onResponseStart?.(controller, 200, { 'content-type': 'text/plain' }, 'OK') + handler.onResponseData?.(controller, Buffer.from('Hello, world!')) + handler.onResponseEnd?.(controller, {}) + + return true + } +} + +async function assertRequestSucceeds (dispatcher) { + const response = await dispatcher.request({ + origin: 'http://example.com', + path: '/', + method: 'GET' + }) + + strictEqual(await response.body.text(), 'Hello, world!') +} + +test('https://github.com/nodejs/undici/issues/4780 - request uses new handler API (Dispatcher)', async () => { + await assertRequestSucceeds(new NewAPIDispatcher()) +}) + +test('https://github.com/nodejs/undici/issues/4780 - request uses new handler API (DispatcherBase)', async () => { + await assertRequestSucceeds(new NewAPIDispatcherBase()) +}) diff --git a/test/mock-agent.js b/test/mock-agent.js index dbc8ad1836d..500a8833116 100644 --- a/test/mock-agent.js +++ b/test/mock-agent.js @@ -194,10 +194,11 @@ describe('MockAgent - dispatch', () => { path: '/foo', method: 'GET' }, { - onHeaders: (_statusCode, _headers, resume) => resume(), - onData: () => {}, - onComplete: () => {}, - onError: () => {} + onRequestStart () {}, + onResponseStart () {}, + onResponseData () {}, + onResponseEnd () {}, + onResponseError () {} })) }) @@ -221,10 +222,11 @@ describe('MockAgent - dispatch', () => { path: '/foo', method: 'GET' }, { - onHeaders: (_statusCode, _headers, resume) => resume(), - onData: () => {}, - onComplete: () => {}, - onError: () => {} + onRequestStart () {}, + onResponseStart () {}, + onResponseData () {}, + onResponseEnd () {}, + onResponseError () {} })) }) }) diff --git a/test/mock-client.js b/test/mock-client.js index e89bc516e52..751f157332d 100644 --- a/test/mock-client.js +++ b/test/mock-client.js @@ -60,9 +60,11 @@ describe('MockClient - dispatch', () => { path: '/foo', method: 'GET' }, { - onHeaders: (_statusCode, _headers, resume) => resume(), - onData: () => {}, - onComplete: () => {} + onRequestStart () {}, + onResponseStart () {}, + onResponseData () {}, + onResponseEnd () {}, + onResponseError () {} })) }) @@ -95,9 +97,11 @@ describe('MockClient - dispatch', () => { path: '/foo', method: 'GET' }, { - onHeaders: (_statusCode, _headers, resume) => { throw new Error('kaboom') }, - onData: () => {}, - onComplete: () => {} + onRequestStart () {}, + onResponseStart () { throw new Error('kaboom') }, + onResponseData () {}, + onResponseEnd () {}, + onResponseError () {} }), new Error('kaboom')) }) }) diff --git a/test/mock-interceptor.js b/test/mock-interceptor.js index 1114b9075d3..099c48f2075 100644 --- a/test/mock-interceptor.js +++ b/test/mock-interceptor.js @@ -100,9 +100,11 @@ describe('MockInterceptor - reply options callback', () => { method: 'GET', headers: { foo: 'bar' } }, { - onHeaders: () => { }, - onData: () => { }, - onComplete: () => { } + onRequestStart: () => {}, + onResponseStart: () => {}, + onResponseData: () => {}, + onResponseEnd: () => {}, + onResponseError: () => {} }) }) @@ -140,9 +142,11 @@ describe('MockInterceptor - reply options callback', () => { method: 'GET', headers: { foo: 'bar' } }, { - onHeaders: () => { }, - onData: () => { }, - onComplete: () => { } + onRequestStart: () => {}, + onResponseStart: () => {}, + onResponseData: () => {}, + onResponseEnd: () => {}, + onResponseError: () => {} }) }) @@ -186,36 +190,44 @@ describe('MockInterceptor - reply options callback', () => { path: '/test-return-undefined', method: 'GET' }, { - onHeaders: () => { }, - onData: () => { }, - onComplete: () => { } + onRequestStart: () => {}, + onResponseStart: () => {}, + onResponseData: () => {}, + onResponseEnd: () => {}, + onResponseError: () => {} }), new InvalidArgumentError('reply options callback must return an object')) t.assert.throws(() => mockPool.dispatch({ path: '/test-return-null', method: 'GET' }, { - onHeaders: () => { }, - onData: () => { }, - onComplete: () => { } + onRequestStart: () => {}, + onResponseStart: () => {}, + onResponseData: () => {}, + onResponseEnd: () => {}, + onResponseError: () => {} }), new InvalidArgumentError('reply options callback must return an object')) t.assert.throws(() => mockPool.dispatch({ path: '/test3', method: 'GET' }, { - onHeaders: () => { }, - onData: () => { }, - onComplete: () => { } + onRequestStart: () => {}, + onResponseStart: () => {}, + onResponseData: () => {}, + onResponseEnd: () => {}, + onResponseError: () => {} }), new InvalidArgumentError('responseOptions must be an object')) t.assert.throws(() => mockPool.dispatch({ path: '/test4', method: 'GET' }, { - onHeaders: () => { }, - onData: () => { }, - onComplete: () => { } + onRequestStart: () => {}, + onResponseStart: () => {}, + onResponseData: () => {}, + onResponseEnd: () => {}, + onResponseError: () => {} }), new InvalidArgumentError('statusCode must be defined')) }) }) diff --git a/test/mock-pool.js b/test/mock-pool.js index ca4805dc434..0213397963d 100644 --- a/test/mock-pool.js +++ b/test/mock-pool.js @@ -61,9 +61,11 @@ describe('MockPool - dispatch', () => { path: '/foo', method: 'GET' }, { - onHeaders: (_statusCode, _headers, resume) => resume(), - onData: () => { }, - onComplete: () => { } + onRequestStart () {}, + onResponseStart () {}, + onResponseData () {}, + onResponseEnd () {}, + onResponseError () {} })) }) @@ -96,9 +98,11 @@ describe('MockPool - dispatch', () => { path: '/foo', method: 'GET' }, { - onHeaders: (_statusCode, _headers, resume) => { throw new Error('kaboom') }, - onData: () => { }, - onComplete: () => { } + onRequestStart () {}, + onResponseStart () { throw new Error('kaboom') }, + onResponseData () {}, + onResponseEnd () {}, + onResponseError () {} }), new Error('kaboom')) }) }) diff --git a/test/node-test/agent.js b/test/node-test/agent.js index d9aee46b597..2a8ba20912d 100644 --- a/test/node-test/agent.js +++ b/test/node-test/agent.js @@ -318,13 +318,13 @@ test('agent factory supports URL parameter', async (t) => { const p = tspl(t, { plan: 2 }) const noopHandler = { - onConnect () {}, - onHeaders () {}, - onData () {}, - onComplete () { + onRequestStart () {}, + onResponseStart () {}, + onResponseData () {}, + onResponseEnd () { server.close() }, - onError (err) { + onResponseError (_controller, err) { throw err } } @@ -356,13 +356,13 @@ test('agent factory supports string parameter', async (t) => { const p = tspl(t, { plan: 2 }) const noopHandler = { - onConnect () {}, - onHeaders () {}, - onData () {}, - onComplete () { + onRequestStart () {}, + onResponseStart () {}, + onResponseData () {}, + onResponseEnd () { server.close() }, - onError (err) { + onResponseError (_controller, err) { throw err } } @@ -709,13 +709,13 @@ test('dispatch validations', async t => { const dispatcher = new Agent() const noopHandler = { - onConnect () {}, - onHeaders () {}, - onData () {}, - onComplete () { + onRequestStart () {}, + onResponseStart () {}, + onResponseData () {}, + onResponseEnd () { server.close() }, - onError (err) { + onResponseError (_controller, err) { throw err } } @@ -730,7 +730,7 @@ test('dispatch validations', async t => { p.throws(() => dispatcher.dispatch('ASD', noopHandler), errors.InvalidArgumentError, 'throws on invalid opts argument type') p.throws(() => dispatcher.dispatch({}, noopHandler), errors.InvalidArgumentError, 'throws on invalid opts.origin argument') p.throws(() => dispatcher.dispatch({ origin: '' }, noopHandler), errors.InvalidArgumentError, 'throws on invalid opts.origin argument') - p.throws(() => dispatcher.dispatch({}, {}), errors.InvalidArgumentError, 'throws on invalid handler.onError') + p.throws(() => dispatcher.dispatch({}, {}), errors.InvalidArgumentError, 'throws on invalid handler.onResponseError') server.listen(0, () => { p.doesNotThrow(() => dispatcher.dispatch({ @@ -756,11 +756,11 @@ test('drain', async t => { }) class Handler { - onConnect () {} - onHeaders () {} - onData () {} - onComplete () {} - onError () { + onRequestStart () {} + onResponseStart () {} + onResponseData () {} + onResponseEnd () {} + onResponseError () { p.fail() } } diff --git a/test/node-test/client-abort.js b/test/node-test/client-abort.js index cf0c6fc1553..346e149bdf0 100644 --- a/test/node-test/client-abort.js +++ b/test/node-test/client-abort.js @@ -85,19 +85,19 @@ test('abort', async (t) => { method: 'GET', path: '/' }, { - onConnect (abort) { - setImmediate(abort) + onRequestStart (controller) { + setImmediate(() => controller.abort()) }, - onHeaders () { + onResponseStart () { p.ok(0) }, - onData () { + onResponseData () { p.ok(0) }, - onComplete () { + onResponseEnd () { p.ok(0) }, - onError (err) { + onResponseError (_controller, err) { p.ok(err instanceof errors.RequestAbortedError) } }) @@ -129,23 +129,23 @@ test('abort pipelined', async (t) => { path: '/', blocking: false }, { - onConnect (abort) { + onRequestStart (controller) { // This request will be retried if (counter++ === 1) { - abort() + controller.abort() } p.ok(1) }, - onHeaders () { + onResponseStart () { p.ok(0) }, - onData () { + onResponseData () { p.ok(0) }, - onComplete () { + onResponseEnd () { p.ok(0) }, - onError (err) { + onResponseError (_controller, err) { p.ok(err instanceof errors.RequestAbortedError) } }) @@ -155,19 +155,19 @@ test('abort pipelined', async (t) => { path: '/', blocking: false }, { - onConnect (abort) { - abort() + onRequestStart (controller) { + controller.abort() }, - onHeaders () { + onResponseStart () { p.ok(0) }, - onData () { + onResponseData () { p.ok(0) }, - onComplete () { + onResponseEnd () { p.ok(0) }, - onError (err) { + onResponseError (_controller, err) { p.ok(err instanceof errors.RequestAbortedError) } }) @@ -196,19 +196,19 @@ test('propagate unallowed throws in request.onError', async (t) => { method: 'GET', path: '/' }, { - onConnect (abort) { - setImmediate(abort) + onRequestStart (controller) { + setImmediate(() => controller.abort()) }, - onHeaders () { + onResponseStart () { p.ok(0) }, - onData () { + onResponseData () { p.ok(0) }, - onComplete () { + onResponseEnd () { p.ok(0) }, - onError () { + onResponseError () { throw new OnAbortError('error') } }) diff --git a/test/node-test/client-dispatch.js b/test/node-test/client-dispatch.js index 2ad355f6571..baa9a161bad 100644 --- a/test/node-test/client-dispatch.js +++ b/test/node-test/client-dispatch.js @@ -43,7 +43,7 @@ test('dispatch invalid opts', (t) => { method: 'GET', upgrade: 1 }, { - onError (err) { + onResponseError (_controller, err) { p.ok(err instanceof errors.InvalidArgumentError) p.strictEqual(err.message, 'upgrade must be a string') } @@ -54,7 +54,7 @@ test('dispatch invalid opts', (t) => { method: 'GET', headersTimeout: 'asd' }, { - onError (err) { + onResponseError (_controller, err) { p.ok(err instanceof errors.InvalidArgumentError) p.strictEqual(err.message, 'invalid headersTimeout') } @@ -65,7 +65,7 @@ test('dispatch invalid opts', (t) => { method: 'GET', bodyTimeout: 'asd' }, { - onError (err) { + onResponseError (_controller, err) { p.ok(err instanceof errors.InvalidArgumentError) p.strictEqual(err.message, 'invalid bodyTimeout') } @@ -77,14 +77,14 @@ test('dispatch invalid opts', (t) => { method: 'GET', bodyTimeout: 'asd' }, { - onError (err) { + onResponseError (_controller, err) { p.ok(err instanceof errors.InvalidArgumentError) p.strictEqual(err.message, 'invalid bodyTimeout') } }) client.dispatch(null, { - onError (err) { + onResponseError (_controller, err) { p.ok(err instanceof errors.InvalidArgumentError) p.strictEqual(err.message, 'opts must be an object.') } @@ -122,20 +122,21 @@ test('basic dispatch get', async (t) => { method: 'GET', headers: reqHeaders }, { - onConnect () { + onRequestStart () { }, - onHeaders (statusCode, headers) { + onResponseStart (controller, statusCode) { + const rawHeaders = controller.rawHeaders p.strictEqual(statusCode, 200) - p.strictEqual(Array.isArray(headers), true) + p.strictEqual(Array.isArray(rawHeaders), true) }, - onData (buf) { + onResponseData (_controller, buf) { bufs.push(buf) }, - onComplete (trailers) { - p.deepStrictEqual(trailers, []) + onResponseEnd (controller) { + p.deepStrictEqual(controller.rawTrailers, []) p.strictEqual('hello', Buffer.concat(bufs).toString('utf8')) }, - onError () { + onResponseError () { p.ok(0) } }) @@ -176,28 +177,30 @@ test('trailers dispatch get', async (t) => { method: 'GET', headers: reqHeaders }, { - onConnect () { + onRequestStart () { }, - onHeaders (statusCode, headers) { + onResponseStart (controller, statusCode) { + const rawHeaders = controller.rawHeaders p.strictEqual(statusCode, 200) - p.strictEqual(Array.isArray(headers), true) + p.strictEqual(Array.isArray(rawHeaders), true) { - const contentTypeIdx = headers.findIndex(x => x.toString() === 'Content-Type') - p.strictEqual(headers[contentTypeIdx + 1].toString(), 'text/plain') + const contentTypeIdx = rawHeaders.findIndex(x => x.toString() === 'Content-Type') + p.strictEqual(rawHeaders[contentTypeIdx + 1].toString(), 'text/plain') } }, - onData (buf) { + onResponseData (_controller, buf) { bufs.push(buf) }, - onComplete (trailers) { - p.strictEqual(Array.isArray(trailers), true) + onResponseEnd (controller) { + const rawTrailers = controller.rawTrailers + p.strictEqual(Array.isArray(rawTrailers), true) { - const contentMD5Idx = trailers.findIndex(x => x.toString() === 'Content-MD5') - p.strictEqual(trailers[contentMD5Idx + 1].toString(), 'test') + const contentMD5Idx = rawTrailers.findIndex(x => x.toString() === 'Content-MD5') + p.strictEqual(rawTrailers[contentMD5Idx + 1].toString(), 'test') } p.strictEqual('hello', Buffer.concat(bufs).toString('utf8')) }, - onError () { + onResponseError () { p.ok(0) } }) @@ -206,7 +209,7 @@ test('trailers dispatch get', async (t) => { await p.completed }) -test('dispatch onHeaders error', async (t) => { +test('dispatch onResponseStart error', async (t) => { const p = tspl(t, { plan: 1 }) const server = http.createServer({ joinDuplicateHeaders: true }, (req, res) => { @@ -223,18 +226,18 @@ test('dispatch onHeaders error', async (t) => { path: '/', method: 'GET' }, { - onConnect () { + onRequestStart () { }, - onHeaders (statusCode, headers) { + onResponseStart (_controller, statusCode, _headers) { throw _err }, - onData (buf) { + onResponseData (_controller, buf) { p.ok(0) }, - onComplete (trailers) { + onResponseEnd (_controller, _trailers) { p.ok(0) }, - onError (err) { + onResponseError (_controller, err) { p.strictEqual(err, _err) } }) @@ -243,7 +246,7 @@ test('dispatch onHeaders error', async (t) => { await p.completed }) -test('dispatch onComplete error', async (t) => { +test('dispatch onResponseEnd error', async (t) => { const p = tspl(t, { plan: 2 }) const server = http.createServer({ joinDuplicateHeaders: true }, (req, res) => { @@ -260,18 +263,18 @@ test('dispatch onComplete error', async (t) => { path: '/', method: 'GET' }, { - onConnect () { + onRequestStart () { }, - onHeaders (statusCode, headers) { + onResponseStart (_controller, statusCode, _headers) { p.ok(1) }, - onData (buf) { + onResponseData (_controller, buf) { p.ok(0) }, - onComplete (trailers) { + onResponseEnd (_controller, _trailers) { throw _err }, - onError (err) { + onResponseError (_controller, err) { p.strictEqual(err, _err) } }) @@ -280,7 +283,7 @@ test('dispatch onComplete error', async (t) => { await p.completed }) -test('dispatch onData error', async (t) => { +test('dispatch onResponseData error', async (t) => { const p = tspl(t, { plan: 2 }) const server = http.createServer({ joinDuplicateHeaders: true }, (req, res) => { @@ -297,18 +300,18 @@ test('dispatch onData error', async (t) => { path: '/', method: 'GET' }, { - onConnect () { + onRequestStart () { }, - onHeaders (statusCode, headers) { + onResponseStart (_controller, statusCode, _headers) { p.ok(1) }, - onData (buf) { + onResponseData (_controller, buf) { throw _err }, - onComplete (trailers) { + onResponseEnd (_controller, _trailers) { p.ok(0) }, - onError (err) { + onResponseError (_controller, err) { p.strictEqual(err, _err) } }) @@ -317,7 +320,7 @@ test('dispatch onData error', async (t) => { await p.completed }) -test('dispatch onConnect error', async (t) => { +test('dispatch onRequestStart error', async (t) => { const p = tspl(t, { plan: 1 }) const server = http.createServer({ joinDuplicateHeaders: true }, (req, res) => { @@ -334,19 +337,19 @@ test('dispatch onConnect error', async (t) => { path: '/', method: 'GET' }, { - onConnect () { + onRequestStart () { throw _err }, - onHeaders (statusCode, headers) { + onResponseStart (_controller, statusCode, _headers) { p.ok(0) }, - onData (buf) { + onResponseData (_controller, buf) { p.ok(0) }, - onComplete (trailers) { + onResponseEnd (_controller, _trailers) { p.ok(0) }, - onError (err) { + onResponseError (_controller, err) { p.strictEqual(err, _err) } }) @@ -355,7 +358,7 @@ test('dispatch onConnect error', async (t) => { await p.completed }) -test('connect call onUpgrade once', async (t) => { +test('connect call onRequestUpgrade once', async (t) => { const p = tspl(t, { plan: 2 }) const server = http.createServer({ joinDuplicateHeaders: true }, (c) => { @@ -385,12 +388,12 @@ test('connect call onUpgrade once', async (t) => { method: 'CONNECT', path: '/' }, { - onConnect () { + onRequestStart () { }, - onHeaders (statusCode, headers) { + onResponseStart (_controller, statusCode, _headers) { t.ok(true, 'should not throw') }, - onUpgrade (statusCode, headers, socket) { + onRequestUpgrade (_controller, statusCode, _headers, socket) { p.strictEqual(count++, 0) socket.on('data', (d) => { @@ -404,13 +407,13 @@ test('connect call onUpgrade once', async (t) => { socket.write('Body') socket.end() }, - onData (buf) { + onResponseData (_controller, buf) { p.ok(0) }, - onComplete (trailers) { + onResponseEnd (_controller, _trailers) { p.ok(0) }, - onError () { + onResponseError () { p.ok(0) } }) @@ -419,7 +422,7 @@ test('connect call onUpgrade once', async (t) => { await p.completed }) -test('dispatch onHeaders missing', async (t) => { +test('dispatch onResponseStart missing', async (t) => { const p = tspl(t, { plan: 1 }) const server = http.createServer({ joinDuplicateHeaders: true }, (req, res) => { @@ -435,15 +438,15 @@ test('dispatch onHeaders missing', async (t) => { path: '/', method: 'GET' }, { - onConnect () { + onRequestStart () { }, - onData (buf) { + onResponseData (_controller, buf) { p.ok(0, 'should not throw') }, - onComplete (trailers) { + onResponseEnd (_controller, _trailers) { p.ok(0, 'should not throw') }, - onError (err) { + onResponseError (_controller, err) { p.strictEqual(err.code, 'UND_ERR_INVALID_ARG') } }) @@ -452,7 +455,7 @@ test('dispatch onHeaders missing', async (t) => { await p.completed }) -test('dispatch onData missing', async (t) => { +test('dispatch onResponseData missing', async (t) => { const p = tspl(t, { plan: 1 }) const server = http.createServer({ joinDuplicateHeaders: true }, (req, res) => { @@ -468,15 +471,15 @@ test('dispatch onData missing', async (t) => { path: '/', method: 'GET' }, { - onConnect () { + onRequestStart () { }, - onHeaders (statusCode, headers) { + onResponseStart (_controller, statusCode, _headers) { p.ok(0, 'should not throw') }, - onComplete (trailers) { + onResponseEnd (_controller, _trailers) { p.ok(0, 'should not throw') }, - onError (err) { + onResponseError (_controller, err) { p.strictEqual(err.code, 'UND_ERR_INVALID_ARG') } }) @@ -485,7 +488,7 @@ test('dispatch onData missing', async (t) => { await p.completed }) -test('dispatch onComplete missing', async (t) => { +test('dispatch onResponseEnd missing', async (t) => { const p = tspl(t, { plan: 1 }) const server = http.createServer({ joinDuplicateHeaders: true }, (req, res) => { @@ -501,15 +504,15 @@ test('dispatch onComplete missing', async (t) => { path: '/', method: 'GET' }, { - onConnect () { + onRequestStart () { }, - onHeaders (statusCode, headers) { + onResponseStart (_controller, statusCode, _headers) { p.ok(0) }, - onData (buf) { + onResponseData (_controller, buf) { p.ok(0) }, - onError (err) { + onResponseError (_controller, err) { p.strictEqual(err.code, 'UND_ERR_INVALID_ARG') } }) @@ -518,7 +521,7 @@ test('dispatch onComplete missing', async (t) => { await p.completed }) -test('dispatch onError missing', async (t) => { +test('dispatch onResponseError missing', async (t) => { const p = tspl(t, { plan: 1 }) const server = http.createServer({ joinDuplicateHeaders: true }, (req, res) => { @@ -535,15 +538,15 @@ test('dispatch onError missing', async (t) => { path: '/', method: 'GET' }, { - onConnect () { + onRequestStart () { }, - onHeaders (statusCode, headers) { + onResponseStart (_controller, statusCode, _headers) { p.ok(0) }, - onData (buf) { + onResponseData (_controller, buf) { p.ok(0) }, - onComplete (trailers) { + onResponseEnd (_controller, _trailers) { p.ok(0) } }) @@ -555,7 +558,7 @@ test('dispatch onError missing', async (t) => { await p.completed }) -test('dispatch CONNECT onUpgrade missing', async (t) => { +test('dispatch CONNECT onRequestUpgrade missing', async (t) => { const p = tspl(t, { plan: 2 }) const server = http.createServer({ joinDuplicateHeaders: true }, (req, res) => { @@ -572,13 +575,13 @@ test('dispatch CONNECT onUpgrade missing', async (t) => { method: 'GET', upgrade: 'Websocket' }, { - onConnect () { + onRequestStart () { }, - onHeaders (statusCode, headers) { + onResponseStart (_controller, statusCode, _headers) { }, - onError (err) { + onResponseError (_controller, err) { p.strictEqual(err.code, 'UND_ERR_INVALID_ARG') - p.strictEqual(err.message, 'invalid onUpgrade method') + p.strictEqual(err.message, 'invalid onRequestUpgrade method') } }) }) @@ -586,7 +589,7 @@ test('dispatch CONNECT onUpgrade missing', async (t) => { await p.completed }) -test('dispatch upgrade onUpgrade missing', async (t) => { +test('dispatch upgrade onRequestUpgrade missing', async (t) => { const p = tspl(t, { plan: 2 }) const server = http.createServer({ joinDuplicateHeaders: true }, (req, res) => { @@ -603,13 +606,13 @@ test('dispatch upgrade onUpgrade missing', async (t) => { method: 'GET', upgrade: 'Websocket' }, { - onConnect () { + onRequestStart () { }, - onHeaders (statusCode, headers) { + onResponseStart (_controller, statusCode, _headers) { }, - onError (err) { + onResponseError (_controller, err) { p.strictEqual(err.code, 'UND_ERR_INVALID_ARG') - p.strictEqual(err.message, 'invalid onUpgrade method') + p.strictEqual(err.message, 'invalid onRequestUpgrade method') } }) }) @@ -617,7 +620,7 @@ test('dispatch upgrade onUpgrade missing', async (t) => { await p.completed }) -test('dispatch pool onError missing', async (t) => { +test('dispatch pool onResponseError missing', async (t) => { const p = tspl(t, { plan: 2 }) const server = http.createServer({ joinDuplicateHeaders: true }, (req, res) => { @@ -661,10 +664,10 @@ test('dispatch onBodySent not a function', async (t) => { method: 'GET' }, { onBodySent: '42', - onConnect () {}, - onHeaders () {}, - onData () {}, - onError (err) { + onRequestStart () {}, + onResponseStart () {}, + onResponseData () {}, + onResponseError (_controller, err) { p.strictEqual(err.code, 'UND_ERR_INVALID_ARG') p.strictEqual(err.message, 'invalid onBodySent method') } @@ -697,13 +700,13 @@ test('dispatch onBodySent buffer', async (t) => { onRequestSent () { p.ok(1) }, - onError (err) { + onResponseError (_controller, err) { throw err }, - onConnect () {}, - onHeaders () {}, - onData () {}, - onComplete () { + onRequestStart () {}, + onResponseStart () {}, + onResponseData () {}, + onResponseEnd () { p.ok(1) } }) @@ -738,13 +741,13 @@ test('dispatch onBodySent stream', async (t) => { onRequestSent () { p.ok(1) }, - onError (err) { + onResponseError (_controller, err) { throw err }, - onConnect () {}, - onHeaders () {}, - onData () {}, - onComplete () { + onRequestStart () {}, + onResponseStart () {}, + onResponseData () {}, + onResponseEnd () { p.strictEqual(currentChunk, chunks.length) p.strictEqual(sentBytes, toSendBytes) p.ok(1) @@ -776,13 +779,13 @@ test('dispatch onBodySent async-iterable', (t, done) => { assert.strictEqual(chunks[currentChunk++], chunk) sentBytes += Buffer.byteLength(chunk) }, - onError (err) { + onResponseError (_controller, err) { throw err }, - onConnect () {}, - onHeaders () {}, - onData () {}, - onComplete () { + onRequestStart () {}, + onResponseStart () {}, + onResponseData () {}, + onResponseEnd () { assert.strictEqual(currentChunk, chunks.length) assert.strictEqual(sentBytes, toSendBytes) done() @@ -809,15 +812,15 @@ test('dispatch onBodySent throws error', (t, done) => { onBodySent (chunk) { throw new Error('fail') }, - onError (err) { + onResponseError (_controller, err) { assert.ok(err instanceof Error) assert.strictEqual(err.message, 'fail') done() }, - onConnect () {}, - onHeaders () {}, - onData () {}, - onComplete () {} + onRequestStart () {}, + onResponseStart () {}, + onResponseData () {}, + onResponseEnd () {} }) }) }) @@ -842,8 +845,8 @@ test('dispatches in expected order', async (t) => { method: 'POST', body: 'body' }, { - onConnect () { - dispatches.push('onConnect') + onRequestStart () { + dispatches.push('onRequestStart') }, onBodySent () { dispatches.push('onBodySent') @@ -851,17 +854,17 @@ test('dispatches in expected order', async (t) => { onResponseStarted () { dispatches.push('onResponseStarted') }, - onHeaders () { - dispatches.push('onHeaders') + onResponseStart () { + dispatches.push('onResponseStart') }, - onData () { - dispatches.push('onData') + onResponseData () { + dispatches.push('onResponseData') }, - onComplete () { - dispatches.push('onComplete') - p.deepStrictEqual(dispatches, ['onConnect', 'onBodySent', 'onResponseStarted', 'onHeaders', 'onData', 'onComplete']) + onResponseEnd () { + dispatches.push('onResponseEnd') + p.deepStrictEqual(dispatches, ['onRequestStart', 'onBodySent', 'onResponseStarted', 'onResponseStart', 'onResponseData', 'onResponseEnd']) }, - onError (err) { + onResponseError (_controller, err) { p.ifError(err) } }) @@ -890,17 +893,17 @@ test('onResponseStarted is called with interceptor', async (t) => { path: '/', method: 'GET' }, { - onConnect () {}, + onRequestStart () {}, onResponseStarted () { responseStartedCalled = true }, - onHeaders () {}, - onData () {}, - onComplete () { + onResponseStart () {}, + onResponseData () {}, + onResponseEnd () { p.strictEqual(responseStartedCalled, true) p.ok(true) }, - onError (err) { + onResponseError (_controller, err) { p.ifError(err) } }) @@ -938,8 +941,8 @@ test('dispatches in expected order for http2', async (t) => { method: 'POST', body: 'body' }, { - onConnect () { - dispatches.push('onConnect') + onRequestStart () { + dispatches.push('onRequestStart') }, onBodySent () { dispatches.push('onBodySent') @@ -947,17 +950,17 @@ test('dispatches in expected order for http2', async (t) => { onResponseStarted () { dispatches.push('onResponseStarted') }, - onHeaders () { - dispatches.push('onHeaders') + onResponseStart () { + dispatches.push('onResponseStart') }, - onData () { - dispatches.push('onData') + onResponseData () { + dispatches.push('onResponseData') }, - onComplete () { - dispatches.push('onComplete') - p.deepStrictEqual(dispatches, ['onConnect', 'onBodySent', 'onResponseStarted', 'onHeaders', 'onData', 'onComplete']) + onResponseEnd () { + dispatches.push('onResponseEnd') + p.deepStrictEqual(dispatches, ['onRequestStart', 'onBodySent', 'onResponseStarted', 'onResponseStart', 'onResponseData', 'onResponseEnd']) }, - onError (err) { + onResponseError (_controller, err) { p.ifError(err) } }) @@ -996,8 +999,8 @@ test('Issue#3065 - fix bad destroy handling', async (t) => { method: 'POST', body: 'body' }, { - onConnect () { - dispatches.push('onConnect') + onRequestStart () { + dispatches.push('onRequestStart') }, onBodySent () { dispatches.push('onBodySent') @@ -1005,17 +1008,17 @@ test('Issue#3065 - fix bad destroy handling', async (t) => { onResponseStarted () { dispatches.push('onResponseStarted') }, - onHeaders () { - dispatches.push('onHeaders') + onResponseStart () { + dispatches.push('onResponseStart') }, - onData () { - dispatches.push('onData') + onResponseData () { + dispatches.push('onResponseData') }, - onComplete () { - dispatches.push('onComplete') - p.deepStrictEqual(dispatches, ['onConnect', 'onBodySent', 'onResponseStarted', 'onHeaders', 'onData', 'onComplete']) + onResponseEnd () { + dispatches.push('onResponseEnd') + p.deepStrictEqual(dispatches, ['onRequestStart', 'onBodySent', 'onResponseStarted', 'onResponseStart', 'onResponseData', 'onResponseEnd']) }, - onError (err) { + onResponseError (_controller, err) { p.ifError(err) } }) @@ -1026,8 +1029,8 @@ test('Issue#3065 - fix bad destroy handling', async (t) => { method: 'POST', body: 'body' }, { - onConnect () { - dispatches2.push('onConnect') + onRequestStart () { + dispatches2.push('onRequestStart') }, onBodySent () { dispatches2.push('onBodySent') @@ -1035,17 +1038,17 @@ test('Issue#3065 - fix bad destroy handling', async (t) => { onResponseStarted () { dispatches2.push('onResponseStarted') }, - onHeaders () { - dispatches2.push('onHeaders') + onResponseStart () { + dispatches2.push('onResponseStart') }, - onData () { - dispatches2.push('onData') + onResponseData () { + dispatches2.push('onResponseData') }, - onComplete () { - dispatches2.push('onComplete') - p.deepStrictEqual(dispatches2, ['onConnect', 'onBodySent', 'onResponseStarted', 'onHeaders', 'onData', 'onComplete']) + onResponseEnd () { + dispatches2.push('onResponseEnd') + p.deepStrictEqual(dispatches2, ['onRequestStart', 'onBodySent', 'onResponseStarted', 'onResponseStart', 'onResponseData', 'onResponseEnd']) }, - onError (err) { + onResponseError (_controller, err) { p.ifError(err) } }) @@ -1090,8 +1093,8 @@ test('Issue#3065 - fix bad destroy handling (h2)', async (t) => { method: 'POST', body: 'body' }, { - onConnect () { - dispatches.push('onConnect') + onRequestStart () { + dispatches.push('onRequestStart') }, onBodySent () { dispatches.push('onBodySent') @@ -1099,17 +1102,17 @@ test('Issue#3065 - fix bad destroy handling (h2)', async (t) => { onResponseStarted () { dispatches.push('onResponseStarted') }, - onHeaders () { - dispatches.push('onHeaders1') + onResponseStart () { + dispatches.push('onResponseStart1') }, - onData () { - dispatches.push('onData') + onResponseData () { + dispatches.push('onResponseData') }, - onComplete () { - dispatches.push('onComplete') - p.deepStrictEqual(dispatches, ['onConnect', 'onBodySent', 'onResponseStarted', 'onHeaders1', 'onData', 'onComplete']) + onResponseEnd () { + dispatches.push('onResponseEnd') + p.deepStrictEqual(dispatches, ['onRequestStart', 'onBodySent', 'onResponseStarted', 'onResponseStart1', 'onResponseData', 'onResponseEnd']) }, - onError (err) { + onResponseError (_controller, err) { p.ifError(err) } }) @@ -1120,8 +1123,8 @@ test('Issue#3065 - fix bad destroy handling (h2)', async (t) => { method: 'POST', body: 'body' }, { - onConnect () { - dispatches2.push('onConnect') + onRequestStart () { + dispatches2.push('onRequestStart') }, onBodySent () { dispatches2.push('onBodySent') @@ -1129,17 +1132,17 @@ test('Issue#3065 - fix bad destroy handling (h2)', async (t) => { onResponseStarted () { dispatches2.push('onResponseStarted') }, - onHeaders () { - dispatches2.push('onHeaders2') + onResponseStart () { + dispatches2.push('onResponseStart2') }, - onData () { - dispatches2.push('onData') + onResponseData () { + dispatches2.push('onResponseData') }, - onComplete () { - dispatches2.push('onComplete') - p.deepStrictEqual(dispatches2, ['onConnect', 'onBodySent', 'onResponseStarted', 'onHeaders2', 'onData', 'onComplete']) + onResponseEnd () { + dispatches2.push('onResponseEnd') + p.deepStrictEqual(dispatches2, ['onRequestStart', 'onBodySent', 'onResponseStarted', 'onResponseStart2', 'onResponseData', 'onResponseEnd']) }, - onError (err) { + onResponseError (_controller, err) { p.ifError(err) } }) diff --git a/test/node-test/global-dispatcher-version.js b/test/node-test/global-dispatcher-version.js new file mode 100644 index 00000000000..ecd9d706804 --- /dev/null +++ b/test/node-test/global-dispatcher-version.js @@ -0,0 +1,100 @@ +'use strict' + +const assert = require('node:assert') +const { test } = require('node:test') +const { spawnSync } = require('node:child_process') +const { join } = require('node:path') + +const cwd = join(__dirname, '../..') + +function runNode (source) { + return spawnSync(process.execPath, ['-e', source], { + cwd, + encoding: 'utf8' + }) +} + +test('setGlobalDispatcher does not break Node.js global fetch', () => { + const script = ` + const { Agent, setGlobalDispatcher } = require('./index.js') + const http = require('node:http') + const { once } = require('node:events') + + ;(async () => { + const server = http.createServer((req, res) => res.end('ok')) + server.listen(0) + await once(server, 'listening') + + setGlobalDispatcher(new Agent()) + const url = 'http://127.0.0.1:' + server.address().port + const res = await fetch(url) + process.stdout.write(await res.text()) + + server.close() + })().catch((err) => { + console.error(err?.cause?.stack || err?.stack || err) + process.exit(1) + }) + ` + + const result = runNode(script) + assert.strictEqual(result.status, 0, result.stderr) + assert.strictEqual(result.stdout, 'ok') +}) + +test('setGlobalDispatcher mirrors a v1-compatible dispatcher on Node.js 22', () => { + const script = ` + const { Agent, Dispatcher1Wrapper, setGlobalDispatcher } = require('./index.js') + const nodeMajor = Number(process.versions.node.split('.', 1)[0]) + + setGlobalDispatcher(new Agent()) + + if (nodeMajor !== 22) { + process.stdout.write('skipped') + } else { + const dispatcherV1 = globalThis[Symbol.for('undici.globalDispatcher.1')] + + if (!(dispatcherV1 instanceof Dispatcher1Wrapper)) { + throw new Error('expected v1 global dispatcher to be a Dispatcher1Wrapper on Node.js 22') + } + + process.stdout.write('mirrored') + } + ` + + const result = runNode(script) + assert.strictEqual(result.status, 0, result.stderr) + + const expected = Number(process.versions.node.split('.', 1)[0]) === 22 ? 'mirrored' : 'skipped' + assert.strictEqual(result.stdout, expected) +}) + +test('Dispatcher1Wrapper bridges legacy handlers to a new Agent', () => { + const script = ` + const { Agent, Dispatcher1Wrapper } = require('./index.js') + const http = require('node:http') + const { once } = require('node:events') + + ;(async () => { + const server = http.createServer((req, res) => res.end('ok')) + server.listen(0) + await once(server, 'listening') + + const dispatcherV1 = Symbol.for('undici.globalDispatcher.1') + globalThis[dispatcherV1] = new Dispatcher1Wrapper(new Agent()) + + const url = 'http://127.0.0.1:' + server.address().port + const res = await fetch(url) + process.stdout.write(await res.text()) + + server.close() + })().catch((err) => { + console.error(err?.cause?.stack || err?.stack || err) + process.exit(1) + }) + ` + + const result = runNode(script) + assert.strictEqual(result.status, 0, result.stderr) + assert.strictEqual(result.stdout, 'ok') +}) diff --git a/test/node-test/util.js b/test/node-test/util.js index c3f363ff40a..267486869f8 100644 --- a/test/node-test/util.js +++ b/test/node-test/util.js @@ -32,50 +32,58 @@ test('getServerName', () => { test('assertRequestHandler', () => { assert.throws(() => util.assertRequestHandler(null), InvalidArgumentError, 'handler must be an object') assert.throws(() => util.assertRequestHandler({ - onConnect: null - }), InvalidArgumentError, 'invalid onConnect method') + onRequestStart: null + }), InvalidArgumentError, 'invalid onRequestStart method') assert.throws(() => util.assertRequestHandler({ - onConnect: () => {}, - onError: null - }), InvalidArgumentError, 'invalid onError method') + onRequestStart: () => {}, + onResponseError: null + }), InvalidArgumentError, 'invalid onResponseError method') assert.throws(() => util.assertRequestHandler({ - onConnect: () => {}, - onError: () => {}, + onRequestStart: () => {}, + onResponseError: () => {}, onBodySent: null }), InvalidArgumentError, 'invalid onBodySent method') assert.throws(() => util.assertRequestHandler({ - onConnect: () => {}, - onError: () => {}, + onRequestStart: () => {}, + onResponseError: () => {}, + onRequestSent: null + }), InvalidArgumentError, 'invalid onRequestSent method') + assert.throws(() => util.assertRequestHandler({ + onRequestStart: () => {}, + onResponseError: () => {}, onBodySent: () => {}, - onHeaders: null - }), InvalidArgumentError, 'invalid onHeaders method') + onRequestSent: () => {}, + onResponseStart: null + }), InvalidArgumentError, 'invalid onResponseStart method') assert.throws(() => util.assertRequestHandler({ - onConnect: () => {}, - onError: () => {}, + onRequestStart: () => {}, + onResponseError: () => {}, onBodySent: () => {}, - onHeaders: () => {}, - onData: null - }), InvalidArgumentError, 'invalid onData method') + onRequestSent: () => {}, + onResponseStart: () => {}, + onResponseData: null + }), InvalidArgumentError, 'invalid onResponseData method') assert.throws(() => util.assertRequestHandler({ - onConnect: () => {}, - onError: () => {}, + onRequestStart: () => {}, + onResponseError: () => {}, onBodySent: () => {}, - onHeaders: () => {}, - onData: () => {}, - onComplete: null - }), InvalidArgumentError, 'invalid onComplete method') + onRequestSent: () => {}, + onResponseStart: () => {}, + onResponseData: () => {}, + onResponseEnd: null + }), InvalidArgumentError, 'invalid onResponseEnd method') assert.throws(() => util.assertRequestHandler({ - onConnect: () => {}, - onError: () => {}, + onRequestStart: () => {}, + onResponseError: () => {}, onBodySent: () => {}, - onUpgrade: 'null' - }, 'CONNECT'), InvalidArgumentError, 'invalid onUpgrade method') + onRequestUpgrade: 'null' + }, 'CONNECT'), InvalidArgumentError, 'invalid onRequestUpgrade method') assert.throws(() => util.assertRequestHandler({ - onConnect: () => {}, - onError: () => {}, + onRequestStart: () => {}, + onResponseError: () => {}, onBodySent: () => {}, - onUpgrade: 'null' - }, 'CONNECT', () => {}), InvalidArgumentError, 'invalid onUpgrade method') + onRequestUpgrade: 'null' + }, 'CONNECT', () => {}), InvalidArgumentError, 'invalid onRequestUpgrade method') }) test('parseHeaders', () => { diff --git a/test/node-test/validations.js b/test/node-test/validations.js index 5f25b834dcb..21569392897 100644 --- a/test/node-test/validations.js +++ b/test/node-test/validations.js @@ -7,7 +7,7 @@ const { tspl } = require('@matteo.collina/tspl') test('validations', async t => { let client - const p = tspl(t, { plan: 10 }) + const p = tspl(t, { plan: 12 }) const server = createServer({ joinDuplicateHeaders: true }, (req, res) => { res.setHeader('content-type', 'text/plain') @@ -53,6 +53,18 @@ test('validations', async t => { p.equal(err.code, 'UND_ERR_INVALID_ARG') p.equal(err.message, 'body must be a string, a Buffer, a Readable stream, an iterable, or an async iterable') }) + + client.request({ + path: '/', + method: 'POST', + body: { + [Symbol.toStringTag]: 'Blob', + arrayBuffer: () => {} + } + }, (err, res) => { + p.equal(err.code, 'UND_ERR_INVALID_ARG') + p.equal(err.message, 'body must be a string, a Buffer, a Readable stream, an iterable, or an async iterable') + }) }) }) diff --git a/test/pool.js b/test/pool.js index de575641a86..d6a9607a464 100644 --- a/test/pool.js +++ b/test/pool.js @@ -390,7 +390,7 @@ test('backpressure algorithm', async (t) => { } const noopHandler = { - onError (err) { + onResponseError (_controller, err) { throw err } } @@ -669,18 +669,18 @@ test('pool dispatch', async (t) => { path: '/', method: 'GET' }, { - onConnect () { + onRequestStart () { }, - onHeaders (statusCode, headers) { + onResponseStart (_controller, statusCode, headers) { t.strictEqual(statusCode, 200) }, - onData (chunk) { + onResponseData (_controller, chunk) { buf += chunk }, - onComplete () { + onResponseEnd () { t.strictEqual(buf, 'asd') }, - onError () { + onResponseError () { } }) }) @@ -816,17 +816,17 @@ test('pool dispatch error', async (t) => { path: '/', method: 'GET' }, { - onConnect () { + onRequestStart () { }, - onHeaders (statusCode, headers) { + onResponseStart (_controller, statusCode, headers) { t.strictEqual(statusCode, 200) }, - onData (chunk) { + onResponseData (_controller, chunk) { }, - onComplete () { + onResponseEnd () { t.ok(true, 'pass') }, - onError () { + onResponseError () { } }) @@ -837,16 +837,16 @@ test('pool dispatch error', async (t) => { 'transfer-encoding': 'fail' } }, { - onConnect () { + onRequestStart () { t.fail() }, - onHeaders (statusCode, headers) { + onResponseStart (_controller, statusCode, headers) { t.fail() }, - onData (chunk) { + onResponseData (_controller, chunk) { t.fail() }, - onError (err) { + onResponseError (_controller, err) { t.strictEqual(err.code, 'UND_ERR_INVALID_ARG') } }) @@ -874,17 +874,17 @@ test('pool request abort in queue', async (t) => { path: '/', method: 'GET' }, { - onConnect () { + onRequestStart () { }, - onHeaders (statusCode, headers) { + onResponseStart (_controller, statusCode, headers) { t.strictEqual(statusCode, 200) }, - onData (chunk) { + onResponseData (_controller, chunk) { }, - onComplete () { + onResponseEnd () { t.ok(true, 'pass') }, - onError () { + onResponseError () { } }) @@ -921,17 +921,17 @@ test('pool stream abort in queue', async (t) => { path: '/', method: 'GET' }, { - onConnect () { + onRequestStart () { }, - onHeaders (statusCode, headers) { + onResponseStart (_controller, statusCode, headers) { t.strictEqual(statusCode, 200) }, - onData (chunk) { + onResponseData (_controller, chunk) { }, - onComplete () { + onResponseEnd () { t.ok(true, 'pass') }, - onError () { + onResponseError () { } }) @@ -968,17 +968,17 @@ test('pool pipeline abort in queue', async (t) => { path: '/', method: 'GET' }, { - onConnect () { + onRequestStart () { }, - onHeaders (statusCode, headers) { + onResponseStart (_controller, statusCode, headers) { t.strictEqual(statusCode, 200) }, - onData (chunk) { + onResponseData (_controller, chunk) { }, - onComplete () { + onResponseEnd () { t.ok(true, 'pass') }, - onError () { + onResponseError () { } }) diff --git a/test/retry-handler.js b/test/retry-handler.js index 383b92aebaa..cdf569f3f3f 100644 --- a/test/retry-handler.js +++ b/test/retry-handler.js @@ -61,22 +61,22 @@ test('Should retry status code', async t => { const handler = new RetryHandler(dispatchOptions, { dispatch: client.dispatch.bind(client), handler: { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { + onResponseStart (_controller, status, _headers, _statusMessage) { t.strictEqual(status, 200) return true }, - onData (chunk) { + onResponseData (_controller, chunk) { chunks.push(chunk) return true }, - onComplete () { + onResponseEnd () { t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'hello world!') t.strictEqual(counter, 2) }, - onError () { + onResponseError () { t.fail() } } @@ -156,22 +156,22 @@ test('Should account for network and response errors', async t => { const handler = new RetryHandler(dispatchOptions, { dispatch: client.dispatch.bind(client), handler: { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { + onResponseStart (_controller, status, _headers, _statusMessage) { t.strictEqual(status, 200) return true }, - onData (chunk) { + onResponseData (_controller, chunk) { chunks.push(chunk) return true }, - onComplete () { + onResponseEnd () { t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'hello world!') t.strictEqual(counter, 2) }, - onError () { + onResponseError () { t.fail() } } @@ -227,19 +227,19 @@ test('Issue #3288 - request with body (asynciterable)', async t => { const handler = new RetryHandler(dispatchOptions, { dispatch: client.dispatch.bind(client), handler: { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { + onResponseStart (_controller, status, _headers, _statusMessage) { return true }, - onData (chunk) { + onResponseData (_controller, chunk) { return true }, - onComplete () { + onResponseEnd () { t.fail() }, - onError (err) { + onResponseError (_controller, err) { t.equal(err.message, 'Request failed') t.equal(err.statusCode, 500) t.equal(err.data.count, 1) @@ -304,21 +304,21 @@ test('Should use retry-after header for retries', async t => { const handler = new RetryHandler(dispatchOptions, { dispatch: client.dispatch.bind(client), handler: { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { + onResponseStart (_controller, status, _headers, _statusMessage) { t.strictEqual(status, 200) return true }, - onData (chunk) { + onResponseData (_controller, chunk) { chunks.push(chunk) return true }, - onComplete () { + onResponseEnd () { t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'hello world!') }, - onError (err) { + onResponseError (_controller, err) { t.ifError(err) } } @@ -389,21 +389,21 @@ test('Should use retry-after header for retries (date)', async t => { const handler = new RetryHandler(dispatchOptions, { dispatch: client.dispatch.bind(client), handler: { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { + onResponseStart (_controller, status, _headers, _statusMessage) { t.strictEqual(status, 200) return true }, - onData (chunk) { + onResponseData (_controller, chunk) { chunks.push(chunk) return true }, - onComplete () { + onResponseEnd () { t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'hello world!') }, - onError (err) { + onResponseError (_controller, err) { t.ifError(err) } } @@ -471,21 +471,21 @@ test('Should retry with defaults', async t => { const handler = new RetryHandler(dispatchOptions, { dispatch: client.dispatch.bind(client), handler: { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { + onResponseStart (_controller, status, _headers, _statusMessage) { t.strictEqual(status, 200) return true }, - onData (chunk) { + onResponseData (_controller, chunk) { chunks.push(chunk) return true }, - onComplete () { + onResponseEnd () { t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'hello world!') }, - onError (err) { + onResponseError (_controller, err) { t.ifError(err) } } @@ -567,22 +567,22 @@ test('Should handle 206 partial content', async t => { return client.dispatch(...args) }, handler: { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onHeaders (status, _rawHeaders, _resume, _statusMessage) { + onResponseStart (_controller, status, _headers, _statusMessage) { t.strictEqual(status, 200) return true }, - onData (chunk) { + onResponseData (_controller, chunk) { chunks.push(chunk) return true }, - onComplete () { + onResponseEnd () { t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'abcdef') t.strictEqual(counter, 1) }, - onError () { + onResponseError () { t.fail() } } @@ -652,20 +652,20 @@ test('Should handle 206 partial content - bad-etag', async t => { return client.dispatch(...args) }, handler: { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onHeaders (_status, _rawHeaders, _resume, _statusMessage) { + onResponseStart (_controller, _status, _headers, _statusMessage) { return true }, - onData (chunk) { + onResponseData (_controller, chunk) { chunks.push(chunk) return true }, - onComplete () { + onResponseEnd () { t.ifError('should not complete') }, - onError (err) { + onResponseError (_controller, err) { t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'abc') t.strictEqual(err.code, 'UND_ERR_REQ_RETRY') t.strictEqual(err.message, 'ETag mismatch') @@ -940,21 +940,21 @@ test('should not error if request is not meant to be retried', async t => { const handler = new RetryHandler(dispatchOptions, { dispatch: client.dispatch.bind(client), handler: { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { + onResponseStart (_controller, status, _headers, _statusMessage) { t.strictEqual(status, 400) return true }, - onData (chunk) { + onResponseData (_controller, chunk) { chunks.push(chunk) return true }, - onComplete () { + onResponseEnd () { t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'Bad request') }, - onError (err) { + onResponseError (_controller, err) { t.fail(err) } } @@ -1108,22 +1108,22 @@ test('Issue#2986 - Handle custom 206', async t => { return client.dispatch(...args) }, handler: { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { + onResponseStart (_controller, status, _headers, _statusMessage) { t.strictEqual(status, 200) return true }, - onData (chunk) { + onResponseData (_controller, chunk) { chunks.push(chunk) return true }, - onComplete () { + onResponseEnd () { t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'abcdef') t.strictEqual(counter, 1) }, - onError () { + onResponseError () { t.fail() } } @@ -1208,22 +1208,22 @@ test('Issue#3128 - Support if-match', async t => { return client.dispatch(...args) }, handler: { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { + onResponseStart (_controller, status, _headers, _statusMessage) { t.strictEqual(status, 200) return true }, - onData (chunk) { + onResponseData (_controller, chunk) { chunks.push(chunk) return true }, - onComplete () { + onResponseEnd () { t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'abcdef') t.strictEqual(counter, 1) }, - onError () { + onResponseError () { t.fail() } } @@ -1308,22 +1308,22 @@ test('Issue#3128 - Should ignore weak etags', async t => { return client.dispatch(...args) }, handler: { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { + onResponseStart (_controller, status, _headers, _statusMessage) { t.strictEqual(status, 200) return true }, - onData (chunk) { + onResponseData (_controller, chunk) { chunks.push(chunk) return true }, - onComplete () { + onResponseEnd () { t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'abcdef') t.strictEqual(counter, 1) }, - onError () { + onResponseError () { t.fail() } } @@ -1408,22 +1408,22 @@ test('Weak etags are ignored on range-requests', async t => { return client.dispatch(...args) }, handler: { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { + onResponseStart (_controller, status, _headers, _statusMessage) { t.strictEqual(status, 200) return true }, - onData (chunk) { + onResponseData (_controller, chunk) { chunks.push(chunk) return true }, - onComplete () { + onResponseEnd () { t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'abcdef') t.strictEqual(counter, 1) }, - onError () { + onResponseError () { t.fail() } } @@ -1503,21 +1503,21 @@ test('Should throw RequestRetryError when Content-Range mismatch', async t => { return client.dispatch(...args) }, handler: { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onHeaders (status, _rawHeaders, _resume, _statusMessage) { + onResponseStart (_controller, status, _headers, _statusMessage) { t.strictEqual(status, 200) return true }, - onData (chunk) { + onResponseData (_controller, chunk) { chunks.push(chunk) return true }, - onComplete () { + onResponseEnd () { t.ifError('should not complete') }, - onError (err) { + onResponseError (_controller, err) { t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'abc') t.strictEqual(err.code, 'UND_ERR_REQ_RETRY') t.strictEqual(err.message, 'Content-Range mismatch') @@ -1596,21 +1596,21 @@ test('Should use retry-after header for retries (date) but date format is wrong' const handler = new RetryHandler(dispatchOptions, { dispatch: client.dispatch.bind(client), handler: { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { + onResponseStart (_controller, status, _headers, _statusMessage) { t.strictEqual(status, 200) return true }, - onData (chunk) { + onResponseData (_controller, chunk) { chunks.push(chunk) return true }, - onComplete () { + onResponseEnd () { t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'hello world!') }, - onError (err) { + onResponseError (_controller, err) { t.ifError(err) } } diff --git a/test/retry-handler2.js b/test/retry-handler2.js index f7b778de586..71821da7088 100644 --- a/test/retry-handler2.js +++ b/test/retry-handler2.js @@ -186,22 +186,22 @@ test('Should retry status code without throwing an error | throwOnError: false', const handler = new RetryHandler(dispatchOptions, { dispatch: client.dispatch.bind(client), handler: { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { + onResponseStart (_controller, status, _headers, _statusMessage) { t.strictEqual(status, 200) return true }, - onData (chunk) { + onResponseData (_controller, chunk) { chunks.push(chunk) return true }, - onComplete () { + onResponseEnd () { t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'hello world!') t.strictEqual(counter, 2) }, - onError () { + onResponseError () { t.fail() } } @@ -282,22 +282,22 @@ test('Should account for network and response errors | throwOnError: false', asy const handler = new RetryHandler(dispatchOptions, { dispatch: client.dispatch.bind(client), handler: { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { + onResponseStart (_controller, status, _headers, _statusMessage) { t.strictEqual(status, 200) return true }, - onData (chunk) { + onResponseData (_controller, chunk) { chunks.push(chunk) return true }, - onComplete () { + onResponseEnd () { t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'hello world!') t.strictEqual(counter, 2) }, - onError () { + onResponseError () { t.fail() } } @@ -358,21 +358,21 @@ test('Issue #3288 - request with body (asynciterable) should fail, without throw const handler = new RetryHandler(dispatchOptions, { dispatch: client.dispatch.bind(client), handler: { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { + onResponseStart (_controller, status, _headers, _statusMessage) { t.ok(true, 'pass') }, - onData (chunk) { + onResponseData (_controller, chunk) { chunks.push(chunk) return true }, - onComplete () { + onResponseEnd () { const data = Buffer.concat(chunks).toString('utf-8') t.strictEqual(data, '{"message": "failed"}') }, - onError () { + onResponseError () { t.fail() } } @@ -438,21 +438,21 @@ test('Should use retry-after header for retries | throwOnError: false', async t const handler = new RetryHandler(dispatchOptions, { dispatch: client.dispatch.bind(client), handler: { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { + onResponseStart (_controller, status, _headers, _statusMessage) { t.strictEqual(status, 200) return true }, - onData (chunk) { + onResponseData (_controller, chunk) { chunks.push(chunk) return true }, - onComplete () { + onResponseEnd () { t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'hello world!') }, - onError () { + onResponseError () { t.fail() } } @@ -526,21 +526,21 @@ test('Should use retry-after header for retries (date) | throwOnError: false', a const handler = new RetryHandler(dispatchOptions, { dispatch: client.dispatch.bind(client), handler: { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { + onResponseStart (_controller, status, _headers, _statusMessage) { t.strictEqual(status, 200) return true }, - onData (chunk) { + onResponseData (_controller, chunk) { chunks.push(chunk) return true }, - onComplete () { + onResponseEnd () { t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'hello world!') }, - onError () { + onResponseError () { t.fail() } } @@ -611,21 +611,21 @@ test('Should retry with defaults | throwOnError: false', async t => { const handler = new RetryHandler(dispatchOptions, { dispatch: client.dispatch.bind(client), handler: { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { + onResponseStart (_controller, status, _headers, _statusMessage) { t.strictEqual(status, 200) return true }, - onData (chunk) { + onResponseData (_controller, chunk) { chunks.push(chunk) return true }, - onComplete () { + onResponseEnd () { t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'hello world!') }, - onError () { + onResponseError () { t.fail() } } @@ -708,22 +708,22 @@ test('Should handle 206 partial content | throwOnError: false', async t => { return client.dispatch(...args) }, handler: { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onHeaders (status, _rawHeaders, _resume, _statusMessage) { + onResponseStart (_controller, status, _headers, _statusMessage) { t.strictEqual(status, 200) return true }, - onData (chunk) { + onResponseData (_controller, chunk) { chunks.push(chunk) return true }, - onComplete () { + onResponseEnd () { t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'abcdef') t.strictEqual(counter, 1) }, - onError () { + onResponseError () { t.fail() } } @@ -796,20 +796,20 @@ test('Should handle 206 partial content - bad-etag | throwOnError: false', async return client.dispatch(...args) }, handler: { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onHeaders (_status, _rawHeaders, _resume, _statusMessage) { + onResponseStart (_controller, _status, _headers, _statusMessage) { return true }, - onData (chunk) { + onResponseData (_controller, chunk) { chunks.push(chunk) return true }, - onComplete () { + onResponseEnd () { t.ifError('should not complete') }, - onError (err) { + onResponseError (_controller, err) { t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'abc') t.strictEqual(err.code, 'UND_ERR_REQ_RETRY') t.strictEqual(err.message, 'ETag mismatch') @@ -1090,21 +1090,21 @@ test('should not error if request is not meant to be retried | throwOnError: fal const handler = new RetryHandler(dispatchOptions, { dispatch: client.dispatch.bind(client), handler: { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { + onResponseStart (_controller, status, _headers, _statusMessage) { t.strictEqual(status, 400) return true }, - onData (chunk) { + onResponseData (_controller, chunk) { chunks.push(chunk) return true }, - onComplete () { + onResponseEnd () { t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'Bad request') }, - onError (err) { + onResponseError (_controller, err) { t.fail(err) } } @@ -1260,22 +1260,22 @@ test('Issue#2986 - Handle custom 206 | throwOnError: false', async t => { return client.dispatch(...args) }, handler: { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { + onResponseStart (_controller, status, _headers, _statusMessage) { t.strictEqual(status, 200) return true }, - onData (chunk) { + onResponseData (_controller, chunk) { chunks.push(chunk) return true }, - onComplete () { + onResponseEnd () { t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'abcdef') t.strictEqual(counter, 1) }, - onError () { + onResponseError () { t.fail() } } @@ -1361,22 +1361,22 @@ test('Issue#3128 - Support if-match | throwOnError: false', async t => { return client.dispatch(...args) }, handler: { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { + onResponseStart (_controller, status, _headers, _statusMessage) { t.strictEqual(status, 200) return true }, - onData (chunk) { + onResponseData (_controller, chunk) { chunks.push(chunk) return true }, - onComplete () { + onResponseEnd () { t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'abcdef') t.strictEqual(counter, 1) }, - onError () { + onResponseError () { t.fail() } } @@ -1462,22 +1462,22 @@ test('Issue#3128 - Should ignore weak etags | throwOnError: false', async t => { return client.dispatch(...args) }, handler: { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { + onResponseStart (_controller, status, _headers, _statusMessage) { t.strictEqual(status, 200) return true }, - onData (chunk) { + onResponseData (_controller, chunk) { chunks.push(chunk) return true }, - onComplete () { + onResponseEnd () { t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'abcdef') t.strictEqual(counter, 1) }, - onError () { + onResponseError () { t.fail() } } @@ -1563,22 +1563,22 @@ test('Weak etags are ignored on range-requests | throwOnError: false', async t = return client.dispatch(...args) }, handler: { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { + onResponseStart (_controller, status, _headers, _statusMessage) { t.strictEqual(status, 200) return true }, - onData (chunk) { + onResponseData (_controller, chunk) { chunks.push(chunk) return true }, - onComplete () { + onResponseEnd () { t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'abcdef') t.strictEqual(counter, 1) }, - onError () { + onResponseError () { t.fail() } } @@ -1659,21 +1659,21 @@ test('Should throw RequestRetryError when Content-Range mismatch | throwOnError: return client.dispatch(...args) }, handler: { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onHeaders (status, _rawHeaders, _resume, _statusMessage) { + onResponseStart (_controller, status, _headers, _statusMessage) { t.strictEqual(status, 200) return true }, - onData (chunk) { + onResponseData (_controller, chunk) { chunks.push(chunk) return true }, - onComplete () { + onResponseEnd () { t.ifError('should not complete') }, - onError (err) { + onResponseError (_controller, err) { t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'abc') t.strictEqual(err.code, 'UND_ERR_REQ_RETRY') t.strictEqual(err.message, 'Content-Range mismatch') @@ -1749,21 +1749,21 @@ test('Should use retry-after header for retries (date) but date format is wrong const handler = new RetryHandler(dispatchOptions, { dispatch: client.dispatch.bind(client), handler: { - onConnect () { + onRequestStart () { t.ok(true, 'pass') }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { + onResponseStart (_controller, status, _headers, _statusMessage) { t.strictEqual(status, 200) return true }, - onData (chunk) { + onResponseData (_controller, chunk) { chunks.push(chunk) return true }, - onComplete () { + onResponseEnd () { t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'hello world!') }, - onError (err) { + onResponseError (_controller, err) { t.ifError(err) } } diff --git a/test/util.js b/test/util.js index ac988997421..b450365fd55 100644 --- a/test/util.js +++ b/test/util.js @@ -30,7 +30,7 @@ describe('isBlobLike', () => { [Symbol.toStringTag]: 'Blob', stream: () => { } } - strictEqual(isBlobLike(blobLikeStream), true) + strictEqual(isBlobLike(blobLikeStream), false) }) test('fileLikeStream', () => { @@ -38,7 +38,7 @@ describe('isBlobLike', () => { stream: () => { }, [Symbol.toStringTag]: 'File' } - strictEqual(isBlobLike(fileLikeStream), true) + strictEqual(isBlobLike(fileLikeStream), false) }) test('fileLikeArrayBuffer', () => { @@ -46,7 +46,7 @@ describe('isBlobLike', () => { [Symbol.toStringTag]: 'Blob', arrayBuffer: () => { } } - strictEqual(isBlobLike(blobLikeArrayBuffer), true) + strictEqual(isBlobLike(blobLikeArrayBuffer), false) }) test('blobLikeArrayBuffer', () => { @@ -54,7 +54,7 @@ describe('isBlobLike', () => { [Symbol.toStringTag]: 'File', arrayBuffer: () => { } } - strictEqual(isBlobLike(fileLikeArrayBuffer), true) + strictEqual(isBlobLike(fileLikeArrayBuffer), false) }) test('string', () => { diff --git a/test/web-platform-tests/expectation.json b/test/web-platform-tests/expectation.json index 07b578e8e18..2457053927b 100644 --- a/test/web-platform-tests/expectation.json +++ b/test/web-platform-tests/expectation.json @@ -563,11 +563,12 @@ { "name": "Empty string integrity for opaque response", "success": false, - "message": "promise_test: Unhandled rejection with value: object \"TypeError: fetch failed\"" + "message": "assert_equals: Opaque response's status is 0 expected 0 but got 200" }, { "name": "SHA-* integrity for opaque response", - "success": true + "success": false, + "message": "assert_unreached: Should have rejected: undefined Reached unreachable code" } ] }, @@ -635,10 +636,10 @@ { "name": "Fetch https://web-platform.test:8443/fetch/api/resources/top.txt with no-cors mode", "success": false, - "message": "promise_test: Unhandled rejection with value: object \"TypeError: fetch failed\"" + "message": "assert_equals: Opaque filter: status is 0 expected 0 but got 200" }, { - "name": "Fetch http://web-platform.test:55268/fetch/api/resources/top.txt with no-cors mode", + "name": "Fetch http://web-platform.test:60029/fetch/api/resources/top.txt with no-cors mode", "success": false, "message": "assert_equals: Opaque filter: status is 0 expected 0 but got 200" } @@ -657,7 +658,8 @@ }, { "name": "Fetch https://web-platform.test:8443/fetch/api/resources/top.txt with same-origin mode", - "success": true + "success": false, + "message": "assert_unreached: Should have rejected: undefined Reached unreachable code" }, { "name": "Fetch http://www1.web-platform.test:8000/fetch/api/resources/top.txt with same-origin mode", @@ -1239,8 +1241,7 @@ }, { "name": "Fetch with POST with Float16Array body", - "success": false, - "message": "Float16Array is not defined" + "success": true }, { "name": "Fetch with POST with Float32Array body", @@ -1296,14 +1297,14 @@ "success": true, "cases": [ { - "name": "Access-Control-Request-Private-Network is a forbidden request header", + "name": "Adding invalid request header \"Access-Control-Request-Private-Network: KO\"", "success": false, - "message": "assert_not_equals: Access-Control-Request-Private-Network does not have the value we defined got disallowed value \"\"" + "message": "assert_equals: expected (object) null but got (string) \"KO\"" }, { - "name": "Adding invalid request header \"Access-Control-Request-Private-Network: KO\"", + "name": "Access-Control-Request-Private-Network is a forbidden request header", "success": false, - "message": "assert_equals: expected (object) null but got (string) \"KO\"" + "message": "assert_not_equals: Access-Control-Request-Private-Network does not have the value we defined got disallowed value \"\"" } ] }, @@ -1889,8 +1890,7 @@ }, { "name": "Fetch with POST with Float16Array body", - "success": false, - "message": "Float16Array is not defined" + "success": true }, { "name": "Fetch with POST with Float32Array body", @@ -2407,11 +2407,11 @@ "success": true }, { - "name": "Testing empty Request Content-Type header", + "name": "Test that Request.headers has the [SameObject] extended attribute", "success": true }, { - "name": "Test that Request.headers has the [SameObject] extended attribute", + "name": "Testing empty Request Content-Type header", "success": true } ] @@ -3647,6 +3647,10 @@ "name": "Check creating a new request from a disturbed request", "success": true }, + { + "name": "Request construction failure should not set \"bodyUsed\"", + "success": true + }, { "name": "Check creating a new request with a new body from a disturbed request", "success": true @@ -3663,10 +3667,6 @@ { "name": "Check consuming a disturbed request", "success": true - }, - { - "name": "Request construction failure should not set \"bodyUsed\"", - "success": true } ] }, @@ -4062,10 +4062,6 @@ "name": "Default Content-Type for Request with buffer source body", "success": true }, - { - "name": "Default Content-Type for Request with FormData body", - "success": true - }, { "name": "Default Content-Type for Request with URLSearchParams body", "success": true @@ -4113,6 +4109,10 @@ { "name": "Can override Content-Type for Request with ReadableStream body", "success": true + }, + { + "name": "Default Content-Type for Request with FormData body", + "success": true } ] }, @@ -4168,10 +4168,6 @@ "name": "Constructing a Request with a stream on which read() is called", "success": true }, - { - "name": "Constructing a Request with a stream on which read() and releaseLock() are called", - "success": true - }, { "name": "Constructing a Request with a Request on which body.getReader() is called", "success": true @@ -4180,10 +4176,6 @@ "name": "Constructing a Request with a Request on which body.getReader().read() is called", "success": true }, - { - "name": "Constructing a Request with a Request on which read() and releaseLock() are called", - "success": true - }, { "name": "It is OK to omit .duplex when the body is null.", "success": true @@ -4247,6 +4239,14 @@ { "name": "It is OK to omit duplex when init.body is not given and input.body is given.", "success": true + }, + { + "name": "Constructing a Request with a stream on which read() and releaseLock() are called", + "success": true + }, + { + "name": "Constructing a Request with a Request on which read() and releaseLock() are called", + "success": true } ] }, @@ -4636,7 +4636,7 @@ { "name": "Check response clone use structureClone for teed ReadableStreams (Float16Arraychunk)", "success": false, - "message": "assert_array_equals: Cloned buffer chunks have the same content value is undefined, expected array" + "message": "assert_not_equals: Buffer of cloned response stream is a clone of the original buffer got disallowed value object \"0,0,0,0,0,0,0,0\"" }, { "name": "Check response clone use structureClone for teed ReadableStreams (Float32Arraychunk)", @@ -4720,6 +4720,14 @@ "response-consume-stream.any.html": { "success": true, "cases": [ + { + "name": "Getting an error Response stream", + "success": true + }, + { + "name": "Getting a redirect Response stream", + "success": true + }, { "name": "Read empty text response's body as readableStream", "success": true @@ -4768,14 +4776,6 @@ "name": "Read form data response's body as readableStream with mode=byob", "success": true }, - { - "name": "Getting an error Response stream", - "success": true - }, - { - "name": "Getting a redirect Response stream", - "success": true - }, { "name": "Reading with offset from Response stream", "success": true @@ -4866,7 +4866,7 @@ { "name": "Consume response's body: from FormData to blob", "success": false, - "message": "assert_equals: Blob body type should be computed from the response Content-Type expected \"multipart/form-data; boundary=----formdata-undici-084518311125\" but got \"multipart/form-data;boundary=----formdata-undici-084518311125\"" + "message": "assert_equals: Blob body type should be computed from the response Content-Type expected \"multipart/form-data; boundary=----formdata-undici-062475584128\" but got \"multipart/form-data;boundary=----formdata-undici-062475584128\"" }, { "name": "Consume response's body: from FormData to text", @@ -5134,6 +5134,10 @@ "name": "Initialize Response with headers values", "success": true }, + { + "name": "Testing null Response body", + "success": true + }, { "name": "Initialize Response's body with application/octet-binary", "success": true @@ -5157,10 +5161,6 @@ { "name": "Testing empty Response Content-Type header", "success": true - }, - { - "name": "Testing null Response body", - "success": true } ] }, @@ -5187,10 +5187,6 @@ "name": "Default Content-Type for Response with buffer source body", "success": true }, - { - "name": "Default Content-Type for Response with FormData body", - "success": true - }, { "name": "Default Content-Type for Response with URLSearchParams body", "success": true @@ -5238,6 +5234,10 @@ { "name": "Can override Content-Type for Response with ReadableStream body", "success": true + }, + { + "name": "Default Content-Type for Response with FormData body", + "success": true } ] }, @@ -5258,51 +5258,51 @@ "success": true, "cases": [ { - "name": "Check response returned by static json() with init undefined", + "name": "Throws TypeError when calling static json() with a status of 204", "success": true }, { - "name": "Check response returned by static json() with init {\"status\":400}", + "name": "Throws TypeError when calling static json() with a status of 205", "success": true }, { - "name": "Check response returned by static json() with init {\"statusText\":\"foo\"}", + "name": "Throws TypeError when calling static json() with a status of 304", "success": true }, { - "name": "Check response returned by static json() with init {\"headers\":{}}", + "name": "Check static json() throws when data is not encodable", "success": true }, { - "name": "Check response returned by static json() with init {\"headers\":{\"content-type\":\"foo/bar\"}}", + "name": "Check static json() throws when data is circular", "success": true }, { - "name": "Check response returned by static json() with init {\"headers\":{\"x-foo\":\"bar\"}}", + "name": "Check response returned by static json() with init undefined", "success": true }, { - "name": "Throws TypeError when calling static json() with a status of 204", + "name": "Check response returned by static json() with init {\"status\":400}", "success": true }, { - "name": "Throws TypeError when calling static json() with a status of 205", + "name": "Check response returned by static json() with init {\"statusText\":\"foo\"}", "success": true }, { - "name": "Throws TypeError when calling static json() with a status of 304", + "name": "Check response returned by static json() with init {\"headers\":{}}", "success": true }, { - "name": "Check static json() encodes JSON objects correctly", + "name": "Check response returned by static json() with init {\"headers\":{\"content-type\":\"foo/bar\"}}", "success": true }, { - "name": "Check static json() throws when data is not encodable", + "name": "Check response returned by static json() with init {\"headers\":{\"x-foo\":\"bar\"}}", "success": true }, { - "name": "Check static json() throws when data is circular", + "name": "Check static json() encodes JSON objects correctly", "success": true }, { @@ -5314,11 +5314,11 @@ "success": true }, { - "name": "Check response returned by static json() with input U+df06U+d834", + "name": "Check response returned by static json() with input \udf06\ud834", "success": true }, { - "name": "Check response returned by static json() with input U+dead", + "name": "Check response returned by static json() with input \udead", "success": true } ] @@ -6094,10 +6094,6 @@ "headers-no-cors.any.html": { "success": true, "cases": [ - { - "name": "Loading data…", - "success": true - }, { "name": "\"no-cors\" Headers object cannot have accept set to sssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss, , sssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss", "success": false, @@ -6227,6 +6223,10 @@ "name": "\"no-cors\" Headers object cannot have unknown/doesitmatter as header", "success": false, "message": "assert_false: expected false got true" + }, + { + "name": "Loading data…", + "success": true } ] }, @@ -6346,6 +6346,18 @@ "general.any.html": { "success": true, "cases": [ + { + "name": "Request objects have a signal property", + "success": true + }, + { + "name": "Signal state is cloned", + "success": true + }, + { + "name": "Clone aborts with original controller", + "success": true + }, { "name": "Aborting rejects with AbortError", "success": true @@ -6422,10 +6434,6 @@ "name": "TypeError from request constructor takes priority - Bad redirect init parameter value", "success": true }, - { - "name": "Request objects have a signal property", - "success": true - }, { "name": "Signal on request object", "success": true @@ -6465,31 +6473,38 @@ }, { "name": "response.arrayBuffer() rejects if already aborted", - "success": true + "success": false, + "message": "assert_array_equals: expected property 0 to be \"arrayBuffer-reject\" but got \"next-microtask\" (expected array [\"arrayBuffer-reject\", \"next-microtask\"] got [\"next-microtask\", \"arrayBuffer-reject\"])" }, { "name": "response.blob() rejects if already aborted", - "success": true + "success": false, + "message": "assert_array_equals: expected property 0 to be \"blob-reject\" but got \"next-microtask\" (expected array [\"blob-reject\", \"next-microtask\"] got [\"next-microtask\", \"blob-reject\"])" }, { "name": "response.bytes() rejects if already aborted", - "success": true + "success": false, + "message": "assert_array_equals: expected property 0 to be \"bytes-reject\" but got \"next-microtask\" (expected array [\"bytes-reject\", \"next-microtask\"] got [\"next-microtask\", \"bytes-reject\"])" }, { "name": "response.formData() rejects if already aborted", - "success": true + "success": false, + "message": "assert_array_equals: expected property 0 to be \"formData-reject\" but got \"next-microtask\" (expected array [\"formData-reject\", \"next-microtask\"] got [\"next-microtask\", \"formData-reject\"])" }, { "name": "response.json() rejects if already aborted", - "success": true + "success": false, + "message": "assert_array_equals: expected property 0 to be \"json-reject\" but got \"next-microtask\" (expected array [\"json-reject\", \"next-microtask\"] got [\"next-microtask\", \"json-reject\"])" }, { "name": "response.text() rejects if already aborted", - "success": true + "success": false, + "message": "assert_array_equals: expected property 0 to be \"text-reject\" but got \"next-microtask\" (expected array [\"text-reject\", \"next-microtask\"] got [\"next-microtask\", \"text-reject\"])" }, { "name": "Call text() twice on aborted response", - "success": true + "success": false, + "message": "promise_rejects_dom: function \"function() { throw e; }\" threw object \"TypeError: Body is unusable: Body has already been read\" that is not a DOMException AbortError: property \"code\" is equal to undefined, expected 20" }, { "name": "Already aborted signal does not make request", @@ -6550,14 +6565,6 @@ { "name": "Readable stream synchronously cancels with AbortError if aborted before reading", "success": true - }, - { - "name": "Signal state is cloned", - "success": true - }, - { - "name": "Clone aborts with original controller", - "success": true } ] }, @@ -6779,16 +6786,17 @@ { "name": "Same domain different protocol different port [no-cors mode]", "success": false, - "message": "promise_test: Unhandled rejection with value: object \"TypeError: fetch failed\"" + "message": "assert_equals: Opaque filter: status is 0 expected 0 but got 200" }, { "name": "Same domain different protocol different port [server forbid CORS]", - "success": true + "success": false, + "message": "assert_unreached: Should have rejected: undefined Reached unreachable code" }, { "name": "Same domain different protocol different port [cors mode]", "success": false, - "message": "promise_test: Unhandled rejection with value: object \"TypeError: fetch failed\"" + "message": "assert_equals: CORS response's type is cors expected \"cors\" but got \"basic\"" }, { "name": "Cross domain basic usage [no-cors mode]", @@ -6823,16 +6831,17 @@ { "name": "Cross domain different protocol [no-cors mode]", "success": false, - "message": "promise_test: Unhandled rejection with value: object \"TypeError: fetch failed\"" + "message": "assert_equals: Opaque filter: status is 0 expected 0 but got 200" }, { "name": "Cross domain different protocol [server forbid CORS]", - "success": true + "success": false, + "message": "assert_unreached: Should have rejected: undefined Reached unreachable code" }, { "name": "Cross domain different protocol [cors mode]", "success": false, - "message": "promise_test: Unhandled rejection with value: object \"TypeError: fetch failed\"" + "message": "assert_equals: CORS response's type is cors expected \"cors\" but got \"basic\"" } ] }, @@ -7030,16 +7039,17 @@ { "name": "[keepalive] Same domain different protocol different port [no-cors mode]", "success": false, - "message": "promise_test: Unhandled rejection with value: object \"TypeError: fetch failed\"" + "message": "assert_equals: Opaque filter: status is 0 expected 0 but got 200" }, { "name": "[keepalive] Same domain different protocol different port [cors mode, server forbid CORS]", - "success": true + "success": false, + "message": "assert_unreached: Should have rejected: undefined Reached unreachable code" }, { "name": "[keepalive] Same domain different protocol different port [cors mode]", "success": false, - "message": "promise_test: Unhandled rejection with value: object \"TypeError: fetch failed\"" + "message": "assert_equals: CORS response's type is cors expected \"cors\" but got \"basic\"" }, { "name": "[keepalive] Cross domain basic usage [no-cors mode]", @@ -7074,16 +7084,17 @@ { "name": "[keepalive] Cross domain different protocol [no-cors mode]", "success": false, - "message": "promise_test: Unhandled rejection with value: object \"TypeError: fetch failed\"" + "message": "assert_equals: Opaque filter: status is 0 expected 0 but got 200" }, { "name": "[keepalive] Cross domain different protocol [cors mode, server forbid CORS]", - "success": true + "success": false, + "message": "assert_unreached: Should have rejected: undefined Reached unreachable code" }, { "name": "[keepalive] Cross domain different protocol [cors mode]", "success": false, - "message": "promise_test: Unhandled rejection with value: object \"TypeError: fetch failed\"" + "message": "assert_equals: CORS response's type is cors expected \"cors\" but got \"basic\"" }, { "name": "[keepalive] Same domain different port GET request in unload [no-cors mode, server forbid CORS]; setting up", @@ -7339,13 +7350,11 @@ }, { "name": "Cross domain different protocol [GET]", - "success": false, - "message": "promise_test: Unhandled rejection with value: object \"TypeError: fetch failed\"" + "success": true }, { "name": "Same domain different protocol different port [GET]", - "success": false, - "message": "promise_test: Unhandled rejection with value: object \"TypeError: fetch failed\"" + "success": true }, { "name": "Cross domain [POST]", @@ -7421,21 +7430,21 @@ }, { "name": "Cross domain different protocol [origin OK]", - "success": false, - "message": "promise_test: Unhandled rejection with value: object \"TypeError: fetch failed\"" + "success": true }, { "name": "Cross domain different protocol [origin KO]", - "success": true + "success": false, + "message": "assert_unreached: Should have rejected: undefined Reached unreachable code" }, { "name": "Same domain different protocol different port [origin OK]", - "success": false, - "message": "promise_test: Unhandled rejection with value: object \"TypeError: fetch failed\"" + "success": true }, { "name": "Same domain different protocol different port [origin KO]", - "success": true + "success": false, + "message": "assert_unreached: Should have rejected: undefined Reached unreachable code" }, { "name": "Cross domain [POST] [origin OK]", @@ -8728,13 +8737,11 @@ }, { "name": "getAuthorizationHeaderValue - same origin redirection", - "success": false, - "message": "promise_test: Unhandled rejection with value: object \"TypeError: fetch failed\"" + "success": true }, { "name": "getAuthorizationHeaderValue - cross origin redirection", - "success": false, - "message": "promise_test: Unhandled rejection with value: object \"TypeError: fetch failed\"" + "success": true } ] }, @@ -8779,10 +8786,6 @@ "idlharness.any.html": { "success": true, "cases": [ - { - "name": "idl_test setup", - "success": true - }, { "name": "idl_test validation", "success": true @@ -8971,106 +8974,10 @@ "name": "Request interface: existence and properties of interface prototype object's @@unscopables property", "success": true }, - { - "name": "Request interface: attribute method", - "success": true - }, - { - "name": "Request interface: attribute url", - "success": true - }, - { - "name": "Request interface: attribute headers", - "success": true - }, - { - "name": "Request interface: attribute destination", - "success": true - }, - { - "name": "Request interface: attribute referrer", - "success": true - }, - { - "name": "Request interface: attribute referrerPolicy", - "success": true - }, - { - "name": "Request interface: attribute mode", - "success": true - }, - { - "name": "Request interface: attribute credentials", - "success": true - }, - { - "name": "Request interface: attribute cache", - "success": true - }, - { - "name": "Request interface: attribute redirect", - "success": true - }, - { - "name": "Request interface: attribute integrity", - "success": true - }, - { - "name": "Request interface: attribute keepalive", - "success": true - }, - { - "name": "Request interface: attribute isReloadNavigation", - "success": true - }, - { - "name": "Request interface: attribute isHistoryNavigation", - "success": true - }, - { - "name": "Request interface: attribute signal", - "success": true - }, - { - "name": "Request interface: attribute duplex", - "success": true - }, { "name": "Request interface: operation clone()", "success": true }, - { - "name": "Request interface: attribute body", - "success": true - }, - { - "name": "Request interface: attribute bodyUsed", - "success": true - }, - { - "name": "Request interface: operation arrayBuffer()", - "success": true - }, - { - "name": "Request interface: operation blob()", - "success": true - }, - { - "name": "Request interface: operation bytes()", - "success": true - }, - { - "name": "Request interface: operation formData()", - "success": true - }, - { - "name": "Request interface: operation json()", - "success": true - }, - { - "name": "Request interface: operation text()", - "success": true - }, { "name": "Request must be primary interface of new Request('about:blank')", "success": true @@ -9215,70 +9122,10 @@ "name": "Response interface: operation json(any, optional ResponseInit)", "success": true }, - { - "name": "Response interface: attribute type", - "success": true - }, - { - "name": "Response interface: attribute url", - "success": true - }, - { - "name": "Response interface: attribute redirected", - "success": true - }, - { - "name": "Response interface: attribute status", - "success": true - }, - { - "name": "Response interface: attribute ok", - "success": true - }, - { - "name": "Response interface: attribute statusText", - "success": true - }, - { - "name": "Response interface: attribute headers", - "success": true - }, { "name": "Response interface: operation clone()", "success": true }, - { - "name": "Response interface: attribute body", - "success": true - }, - { - "name": "Response interface: attribute bodyUsed", - "success": true - }, - { - "name": "Response interface: operation arrayBuffer()", - "success": true - }, - { - "name": "Response interface: operation blob()", - "success": true - }, - { - "name": "Response interface: operation bytes()", - "success": true - }, - { - "name": "Response interface: operation formData()", - "success": true - }, - { - "name": "Response interface: operation json()", - "success": true - }, - { - "name": "Response interface: operation text()", - "success": true - }, { "name": "Response must be primary interface of new Response()", "success": true @@ -9411,11 +9258,6 @@ "success": false, "message": "assert_own_property: global object missing non-static operation expected property \"fetchLater\" missing" }, - { - "name": "Window interface: operation fetch(RequestInfo, optional RequestInit)", - "success": false, - "message": "assert_unreached: Should have rejected: calling operation with this = {} didn't throw TypeError Reached unreachable code" - }, { "name": "Window interface: window must inherit property \"fetchLater(RequestInfo, optional DeferredRequestInit)\" with the proper type", "success": false, @@ -9430,6 +9272,171 @@ "name": "Window interface: window must inherit property \"fetch(RequestInfo, optional RequestInit)\" with the proper type", "success": true }, + { + "name": "Request interface: attribute method", + "success": true + }, + { + "name": "Request interface: attribute url", + "success": true + }, + { + "name": "Request interface: attribute headers", + "success": true + }, + { + "name": "Request interface: attribute destination", + "success": true + }, + { + "name": "Request interface: attribute referrer", + "success": true + }, + { + "name": "Request interface: attribute referrerPolicy", + "success": true + }, + { + "name": "Request interface: attribute mode", + "success": true + }, + { + "name": "Request interface: attribute credentials", + "success": true + }, + { + "name": "Request interface: attribute cache", + "success": true + }, + { + "name": "Request interface: attribute redirect", + "success": true + }, + { + "name": "Request interface: attribute integrity", + "success": true + }, + { + "name": "Request interface: attribute keepalive", + "success": true + }, + { + "name": "Request interface: attribute isReloadNavigation", + "success": true + }, + { + "name": "Request interface: attribute isHistoryNavigation", + "success": true + }, + { + "name": "Request interface: attribute signal", + "success": true + }, + { + "name": "Request interface: attribute duplex", + "success": true + }, + { + "name": "Request interface: attribute body", + "success": true + }, + { + "name": "Request interface: attribute bodyUsed", + "success": true + }, + { + "name": "Response interface: attribute type", + "success": true + }, + { + "name": "Response interface: attribute url", + "success": true + }, + { + "name": "Response interface: attribute redirected", + "success": true + }, + { + "name": "Response interface: attribute status", + "success": true + }, + { + "name": "Response interface: attribute ok", + "success": true + }, + { + "name": "Response interface: attribute statusText", + "success": true + }, + { + "name": "Response interface: attribute headers", + "success": true + }, + { + "name": "Response interface: attribute body", + "success": true + }, + { + "name": "Response interface: attribute bodyUsed", + "success": true + }, + { + "name": "idl_test setup", + "success": true + }, + { + "name": "Request interface: operation arrayBuffer()", + "success": true + }, + { + "name": "Request interface: operation blob()", + "success": true + }, + { + "name": "Request interface: operation bytes()", + "success": true + }, + { + "name": "Request interface: operation formData()", + "success": true + }, + { + "name": "Request interface: operation json()", + "success": true + }, + { + "name": "Request interface: operation text()", + "success": true + }, + { + "name": "Response interface: operation arrayBuffer()", + "success": true + }, + { + "name": "Response interface: operation blob()", + "success": true + }, + { + "name": "Response interface: operation bytes()", + "success": true + }, + { + "name": "Response interface: operation formData()", + "success": true + }, + { + "name": "Response interface: operation json()", + "success": true + }, + { + "name": "Response interface: operation text()", + "success": true + }, + { + "name": "Window interface: operation fetch(RequestInfo, optional RequestInit)", + "success": false, + "message": "assert_unreached: Should have rejected: calling operation with this = {} didn't throw TypeError Reached unreachable code" + }, { "name": "Window interface: calling fetch(RequestInfo, optional RequestInit) on window with too few arguments must throw TypeError", "success": false, @@ -11238,27 +11245,27 @@ { "name": "Decompresion using gzip-encoded dictionary works as expected", "success": false, - "message": "promise_test: Unhandled rejection with value: object \"TypeError: fetch failed\"" + "message": "assert_equals: expected \":U5abz16WDg7b8KS93msLPpOB4Vbef1uRzoORYkJw9BY=:\" but got \"\\\"available-dictionary\\\" header is not available\"" }, { "name": "Decompresion using Brotli-encoded dictionary works as expected", "success": false, - "message": "promise_test: Unhandled rejection with value: object \"TypeError: fetch failed\"" + "message": "assert_equals: expected \":U5abz16WDg7b8KS93msLPpOB4Vbef1uRzoORYkJw9BY=:\" but got \"\\\"available-dictionary\\\" header is not available\"" }, { "name": "Decompresion using Zstandard-encoded dictionary works as expected", "success": false, - "message": "promise_test: Unhandled rejection with value: object \"TypeError: fetch failed\"" + "message": "assert_equals: expected \":U5abz16WDg7b8KS93msLPpOB4Vbef1uRzoORYkJw9BY=:\" but got \"\\\"available-dictionary\\\" header is not available\"" }, { "name": "A dcb dictionary-compressed dictionary can be used as a dictionary for future requests.", "success": false, - "message": "promise_test: Unhandled rejection with value: object \"TypeError: fetch failed\"" + "message": "assert_equals: expected \":U5abz16WDg7b8KS93msLPpOB4Vbef1uRzoORYkJw9BY=:\" but got \"\\\"available-dictionary\\\" header is not available\"" }, { "name": "A dcz dictionary-compressed dictionary can be used as a dictionary for future requests.", "success": false, - "message": "promise_test: Unhandled rejection with value: object \"TypeError: fetch failed\"" + "message": "assert_equals: expected \":U5abz16WDg7b8KS93msLPpOB4Vbef1uRzoORYkJw9BY=:\" but got \"\\\"available-dictionary\\\" header is not available\"" } ] }, @@ -11278,7 +11285,7 @@ { "name": "Decompresion of a cross origin resource works as expected", "success": false, - "message": "promise_test: Unhandled rejection with value: object \"TypeError: fetch failed\"" + "message": "assert_equals: expected \":U5abz16WDg7b8KS93msLPpOB4Vbef1uRzoORYkJw9BY=:\" but got \"\\\"available-dictionary\\\" header is not available\"" } ] }, @@ -11343,7 +11350,7 @@ { "name": "Dictionary registration does not invalidate cache entry", "success": false, - "message": "promise_test: Unhandled rejection with value: object \"TypeError: fetch failed\"" + "message": "assert_equals: expected \":U5abz16WDg7b8KS93msLPpOB4Vbef1uRzoORYkJw9BY=:\" but got \"\\\"available-dictionary\\\" header is not available\"" }, { "name": "Expired dictionary is not used", @@ -11631,14 +11638,14 @@ "success": true, "cases": [ { - "name": "fetch() and duplicate Content-Length/Content-Type headers", + "name": "XMLHttpRequest and duplicate Content-Length/Content-Type headers", "success": false, - "message": "promise_test: Unhandled rejection with value: object \"TypeError: fetch failed\"" + "message": "XMLHttpRequest is not defined" }, { - "name": "XMLHttpRequest and duplicate Content-Length/Content-Type headers", + "name": "fetch() and duplicate Content-Length/Content-Type headers", "success": false, - "message": "XMLHttpRequest is not defined" + "message": "promise_test: Unhandled rejection with value: object \"TypeError: fetch failed\"" } ] }, @@ -11844,10 +11851,6 @@ "response.window.html": { "success": true, "cases": [ - { - "name": "Loading JSON…", - "success": true - }, { "name": "