diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c1bf4dc92d2..2236ebd8504 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -41,7 +41,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@a4aa98b93cab29d9b1101a6143fb8bce00e2eac4 # v2.7.1 + uses: step-security/harden-runner@f086349bfa2bd1361f7909c78558e816508cdc10 # v2.8.0 with: egress-policy: audit @@ -50,7 +50,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@d39d31e687223d841ef683f52467bd88e9b21c14 # v2.3.3 + uses: github/codeql-action/init@f079b8493333aace61c81488f8bd40919487bd9f # v2.3.3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -60,7 +60,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@d39d31e687223d841ef683f52467bd88e9b21c14 # v2.3.3 + uses: github/codeql-action/autobuild@f079b8493333aace61c81488f8bd40919487bd9f # 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 @@ -73,6 +73,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@d39d31e687223d841ef683f52467bd88e9b21c14 # v2.3.3 + uses: github/codeql-action/analyze@f079b8493333aace61c81488f8bd40919487bd9f # v2.3.3 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 81201fc5c19..17bf07d8f86 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@a4aa98b93cab29d9b1101a6143fb8bce00e2eac4 # v2.7.1 + uses: step-security/harden-runner@f086349bfa2bd1361f7909c78558e816508cdc10 # v2.8.0 with: egress-policy: audit diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 341a5f43bc2..c5b1de051f6 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -34,7 +34,7 @@ jobs: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1 + uses: ossf/scorecard-action@dc50aa9510b46c811795eb24b2f1ba02a914e534 # v2.3.3 with: results_file: results.sarif results_format: sarif @@ -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@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3 + uses: github/codeql-action/upload-sarif@f079b8493333aace61c81488f8bd40919487bd9f # v3.25.7 with: sarif_file: results.sarif diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e5168a23683..168079a9a10 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -59,6 +59,6 @@ jobs: - name: Coverage Report if: inputs.runs-on == 'ubuntu-latest' && inputs.node-version == 20 - uses: codecov/codecov-action@5ecb98a3c6b747ed38dc09f787459979aebb39be # v4.3.1 + uses: codecov/codecov-action@125fc84a9a348dbcf27191600683ec096ec9021c # v4.4.1 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/benchmarks/core/request-instantiation.mjs b/benchmarks/core/request-instantiation.mjs new file mode 100644 index 00000000000..4973a31fcb7 --- /dev/null +++ b/benchmarks/core/request-instantiation.mjs @@ -0,0 +1,12 @@ +import { bench, run } from 'mitata' + +import Request from '../../lib/core/request.js' +import DecoratorHandler from '../../lib/handler/decorator-handler.js' + +const handler = new DecoratorHandler({}) + +bench('new Request()', () => { + return new Request('https://localhost', { path: '/', method: 'get', body: null }, handler) +}) + +await run() diff --git a/build/Dockerfile b/build/Dockerfile index d7f29fdc9b8..8bcab469b60 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-alpine3.19@sha256:9e8f45fc08c709b1fd87baeeed487977f57585f85f3838c01747602cd85a64bb +FROM node:22-alpine3.19@sha256:075a5cc188c3c9a49acacd481a9e8a3c9abf4223f02c658e37fdb8e9fe2c4664 ARG UID=1000 ARG GID=1000 diff --git a/docs/docs/api/Client.md b/docs/docs/api/Client.md index 2276f737c36..03342f59959 100644 --- a/docs/docs/api/Client.md +++ b/docs/docs/api/Client.md @@ -19,7 +19,7 @@ Returns: `Client` > ⚠️ Warning: The `H2` support is experimental. -* **bodyTimeout** `number | null` (optional) - Default: `300e3` - The timeout after which a request will time out, in milliseconds. Monitors time between receiving body data. Use `0` to disable it entirely. Defaults to 300 seconds. +* **bodyTimeout** `number | null` (optional) - Default: `300e3` - The timeout after which a request will time out, in milliseconds. Monitors time between receiving body data. Use `0` to disable it entirely. Defaults to 300 seconds. Please note the `timeout` will be reset if you keep writing data to the scoket everytime. * **headersTimeout** `number | null` (optional) - Default: `300e3` - The amount of time, in milliseconds, the parser will wait to receive the complete HTTP headers while not sending the request. Defaults to 300 seconds. * **keepAliveMaxTimeout** `number | null` (optional) - Default: `600e3` - The maximum allowed `keepAliveTimeout`, in milliseconds, when overridden by *keep-alive* hints from the server. Defaults to 10 minutes. * **keepAliveTimeout** `number | null` (optional) - Default: `4e3` - The timeout, in milliseconds, after which a socket without active requests will time out. Monitors time between activity on a connected socket. This value may be overridden by *keep-alive* hints from the server. See [MDN: HTTP - Headers - Keep-Alive directives](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Keep-Alive#directives) for more details. Defaults to 4 seconds. diff --git a/lib/core/connect.js b/lib/core/connect.js index 4ad2c91d2cb..b388f022298 100644 --- a/lib/core/connect.js +++ b/lib/core/connect.js @@ -73,7 +73,7 @@ if (global.FinalizationRegistry && !(process.env.NODE_V8_COVERAGE || process.env } } -function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, ...opts }) { +function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, session: customSession, ...opts }) { if (maxCachedSessions != null && (!Number.isInteger(maxCachedSessions) || maxCachedSessions < 0)) { throw new InvalidArgumentError('maxCachedSessions must be a positive integer or zero') } @@ -91,7 +91,7 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, ...o servername = servername || options.servername || util.getServerName(host) || null const sessionKey = servername || hostname - const session = sessionCache.get(sessionKey) || null + const session = customSession || sessionCache.get(sessionKey) || null assert(sessionKey) diff --git a/lib/core/request.js b/lib/core/request.js index c44cd0891f5..78003038ba9 100644 --- a/lib/core/request.js +++ b/lib/core/request.js @@ -16,7 +16,8 @@ const { isBlobLike, buildURL, validateHandler, - getServerName + getServerName, + normalizedMethodRecords } = require('./util') const { channels } = require('./diagnostics.js') const { headerNameLowerCasedRecord } = require('./constants') @@ -51,13 +52,13 @@ class Request { method !== 'CONNECT' ) { throw new InvalidArgumentError('path must be an absolute URL or start with a slash') - } else if (invalidPathRegex.exec(path) !== null) { + } else if (invalidPathRegex.test(path)) { throw new InvalidArgumentError('invalid request path') } if (typeof method !== 'string') { throw new InvalidArgumentError('method must be a string') - } else if (!isValidHTTPToken(method)) { + } else if (normalizedMethodRecords[method] === undefined && !isValidHTTPToken(method)) { throw new InvalidArgumentError('invalid request method') } diff --git a/lib/core/util.js b/lib/core/util.js index ddb72d226ce..00f8a9b200a 100644 --- a/lib/core/util.js +++ b/lib/core/util.js @@ -645,6 +645,31 @@ function errorRequest (client, request, err) { const kEnumerableProperty = Object.create(null) kEnumerableProperty.enumerable = true +const normalizedMethodRecordsBase = { + delete: 'DELETE', + DELETE: 'DELETE', + get: 'GET', + GET: 'GET', + head: 'HEAD', + HEAD: 'HEAD', + options: 'OPTIONS', + OPTIONS: 'OPTIONS', + post: 'POST', + POST: 'POST', + put: 'PUT', + PUT: 'PUT' +} + +const normalizedMethodRecords = { + ...normalizedMethodRecordsBase, + patch: 'patch', + PATCH: 'PATCH' +} + +// Note: object prototypes should not be able to be referenced. e.g. `Object#hasOwnProperty`. +Object.setPrototypeOf(normalizedMethodRecordsBase, null) +Object.setPrototypeOf(normalizedMethodRecords, null) + module.exports = { kEnumerableProperty, nop, @@ -683,6 +708,8 @@ module.exports = { isValidHeaderValue, isTokenCharCode, parseRangeHeader, + normalizedMethodRecordsBase, + normalizedMethodRecords, isValidPort, isHttpOrHttpsPrefixed, nodeMajor, diff --git a/lib/dispatcher/client-h1.js b/lib/dispatcher/client-h1.js index c537ed838ec..2f1c96724d3 100644 --- a/lib/dispatcher/client-h1.js +++ b/lib/dispatcher/client-h1.js @@ -978,19 +978,19 @@ function writeH1 (client, request) { /* istanbul ignore else: assertion */ if (!body || bodyLength === 0) { - writeBuffer({ abort, body: null, client, request, socket, contentLength, header, expectsPayload }) + writeBuffer(abort, null, client, request, socket, contentLength, header, expectsPayload) } else if (util.isBuffer(body)) { - writeBuffer({ abort, body, client, request, socket, contentLength, header, expectsPayload }) + writeBuffer(abort, body, client, request, socket, contentLength, header, expectsPayload) } else if (util.isBlobLike(body)) { if (typeof body.stream === 'function') { - writeIterable({ abort, body: body.stream(), client, request, socket, contentLength, header, expectsPayload }) + writeIterable(abort, body.stream(), client, request, socket, contentLength, header, expectsPayload) } else { - writeBlob({ abort, body, client, request, socket, contentLength, header, expectsPayload }) + writeBlob(abort, body, client, request, socket, contentLength, header, expectsPayload) } } else if (util.isStream(body)) { - writeStream({ abort, body, client, request, socket, contentLength, header, expectsPayload }) + writeStream(abort, body, client, request, socket, contentLength, header, expectsPayload) } else if (util.isIterable(body)) { - writeIterable({ abort, body, client, request, socket, contentLength, header, expectsPayload }) + writeIterable(abort, body, client, request, socket, contentLength, header, expectsPayload) } else { assert(false) } @@ -998,7 +998,7 @@ function writeH1 (client, request) { return true } -function writeStream ({ abort, body, client, request, socket, contentLength, header, expectsPayload }) { +function writeStream (abort, body, client, request, socket, contentLength, header, expectsPayload) { assert(contentLength !== 0 || client[kRunning] === 0, 'stream body cannot be pipelined') let finished = false @@ -1101,7 +1101,7 @@ function writeStream ({ abort, body, client, request, socket, contentLength, hea } } -function writeBuffer ({ abort, body, client, request, socket, contentLength, header, expectsPayload }) { +function writeBuffer (abort, body, client, request, socket, contentLength, header, expectsPayload) { try { if (!body) { if (contentLength === 0) { @@ -1131,7 +1131,7 @@ function writeBuffer ({ abort, body, client, request, socket, contentLength, hea } } -async function writeBlob ({ abort, body, client, request, socket, contentLength, header, expectsPayload }) { +async function writeBlob (abort, body, client, request, socket, contentLength, header, expectsPayload) { assert(contentLength === body.size, 'blob body must have content length') try { @@ -1159,7 +1159,7 @@ async function writeBlob ({ abort, body, client, request, socket, contentLength, } } -async function writeIterable ({ abort, body, client, request, socket, contentLength, header, expectsPayload }) { +async function writeIterable (abort, body, client, request, socket, contentLength, header, expectsPayload) { assert(contentLength !== 0 || client[kRunning] === 0, 'iterator body cannot be pipelined') let callback = null diff --git a/lib/dispatcher/client-h2.js b/lib/dispatcher/client-h2.js index c78fe602c48..6c5155717d1 100644 --- a/lib/dispatcher/client-h2.js +++ b/lib/dispatcher/client-h2.js @@ -477,82 +477,80 @@ function writeH2 (client, request) { function writeBodyH2 () { /* istanbul ignore else: assertion */ if (!body || contentLength === 0) { - writeBuffer({ + writeBuffer( abort, + stream, + null, client, request, + client[kSocket], contentLength, - expectsPayload, - h2stream: stream, - body: null, - socket: client[kSocket] - }) + expectsPayload + ) } else if (util.isBuffer(body)) { - writeBuffer({ + writeBuffer( abort, + stream, + body, client, request, + client[kSocket], contentLength, - body, - expectsPayload, - h2stream: stream, - socket: client[kSocket] - }) + expectsPayload + ) } else if (util.isBlobLike(body)) { if (typeof body.stream === 'function') { - writeIterable({ + writeIterable( abort, + stream, + body.stream(), client, request, + client[kSocket], contentLength, - expectsPayload, - h2stream: stream, - body: body.stream(), - socket: client[kSocket] - }) + expectsPayload + ) } else { - writeBlob({ + writeBlob( abort, + stream, body, client, request, + client[kSocket], contentLength, - expectsPayload, - h2stream: stream, - socket: client[kSocket] - }) + expectsPayload + ) } } else if (util.isStream(body)) { - writeStream({ + writeStream( abort, + client[kSocket], + expectsPayload, + stream, body, client, request, - contentLength, - expectsPayload, - socket: client[kSocket], - h2stream: stream, - header: '' - }) + contentLength + ) } else if (util.isIterable(body)) { - writeIterable({ + writeIterable( abort, + stream, body, client, request, + client[kSocket], contentLength, - expectsPayload, - header: '', - h2stream: stream, - socket: client[kSocket] - }) + expectsPayload + ) } else { assert(false) } } } -function writeBuffer ({ abort, h2stream, body, client, request, socket, contentLength, expectsPayload }) { +function writeBuffer (abort, h2stream, body, client, request, socket, contentLength, expectsPayload) { try { if (body != null && util.isBuffer(body)) { assert(contentLength === body.byteLength, 'buffer body must have content length') @@ -575,7 +573,7 @@ function writeBuffer ({ abort, h2stream, body, client, request, socket, contentL } } -function writeStream ({ abort, socket, expectsPayload, h2stream, body, client, request, contentLength }) { +function writeStream (abort, socket, expectsPayload, h2stream, body, client, request, contentLength) { assert(contentLength !== 0 || client[kRunning] === 0, 'stream body cannot be pipelined') // For HTTP/2, is enough to pipe the stream @@ -606,7 +604,7 @@ function writeStream ({ abort, socket, expectsPayload, h2stream, body, client, r } } -async function writeBlob ({ abort, h2stream, body, client, request, socket, contentLength, expectsPayload }) { +async function writeBlob (abort, h2stream, body, client, request, socket, contentLength, expectsPayload) { assert(contentLength === body.size, 'blob body must have content length') try { @@ -634,7 +632,7 @@ async function writeBlob ({ abort, h2stream, body, client, request, socket, cont } } -async function writeIterable ({ abort, h2stream, body, client, request, socket, contentLength, expectsPayload }) { +async function writeIterable (abort, h2stream, body, client, request, socket, contentLength, expectsPayload) { assert(contentLength !== 0 || client[kRunning] === 0, 'iterator body cannot be pipelined') let callback = null diff --git a/lib/handler/retry-handler.js b/lib/handler/retry-handler.js index fcd9f0df513..f7dedfa4bac 100644 --- a/lib/handler/retry-handler.js +++ b/lib/handler/retry-handler.js @@ -202,7 +202,7 @@ class RetryHandler { this.abort( new RequestRetryError('Content-Range mismatch', statusCode, { headers, - count: this.retryCount + data: { count: this.retryCount } }) ) return false @@ -213,7 +213,7 @@ class RetryHandler { this.abort( new RequestRetryError('ETag mismatch', statusCode, { headers, - count: this.retryCount + data: { count: this.retryCount } }) ) return false diff --git a/lib/web/fetch/request.js b/lib/web/fetch/request.js index 07a87f66d47..bc436aa9705 100644 --- a/lib/web/fetch/request.js +++ b/lib/web/fetch/request.js @@ -10,9 +10,7 @@ const nodeUtil = require('node:util') const { isValidHTTPToken, sameOrigin, - normalizeMethod, - environmentSettingsObject, - normalizeMethodRecord + environmentSettingsObject } = require('./util') const { forbiddenMethodsSet, @@ -24,7 +22,7 @@ const { requestCache, requestDuplex } = require('./constants') -const { kEnumerableProperty } = util +const { kEnumerableProperty, normalizedMethodRecordsBase, normalizedMethodRecords } = util const { kHeaders, kSignal, kState, kDispatcher } = require('./symbols') const { webidl } = require('./webidl') const { URLSerializer } = require('./data-url') @@ -349,7 +347,7 @@ class Request { // 1. Let method be init["method"]. let method = init.method - const mayBeNormalized = normalizeMethodRecord[method] + const mayBeNormalized = normalizedMethodRecords[method] if (mayBeNormalized !== undefined) { // Note: Bypass validation DELETE, GET, HEAD, OPTIONS, POST, PUT, PATCH and these lowercase ones @@ -361,12 +359,16 @@ class Request { throw new TypeError(`'${method}' is not a valid HTTP method.`) } - if (forbiddenMethodsSet.has(method.toUpperCase())) { + const upperCase = method.toUpperCase() + + if (forbiddenMethodsSet.has(upperCase)) { throw new TypeError(`'${method}' HTTP method is unsupported.`) } // 3. Normalize method. - method = normalizeMethod(method) + // https://fetch.spec.whatwg.org/#concept-method-normalize + // Note: must be in uppercase + method = normalizedMethodRecordsBase[upperCase] ?? method // 4. Set request’s method to method. request.method = method diff --git a/lib/web/fetch/util.js b/lib/web/fetch/util.js index e533ac957ca..786c153d75e 100644 --- a/lib/web/fetch/util.js +++ b/lib/web/fetch/util.js @@ -6,7 +6,7 @@ const { redirectStatusSet, referrerPolicySet: referrerPolicyTokens, badPortsSet const { getGlobalOrigin } = require('./global') const { collectASequenceOfCodePoints, collectAnHTTPQuotedString, removeChars, parseMIMEType } = require('./data-url') const { performance } = require('node:perf_hooks') -const { isBlobLike, ReadableStreamFrom, isValidHTTPToken } = require('../../core/util') +const { isBlobLike, ReadableStreamFrom, isValidHTTPToken, normalizedMethodRecordsBase } = require('../../core/util') const assert = require('node:assert') const { isUint8Array } = require('node:util/types') const { webidl } = require('./webidl') @@ -791,37 +791,12 @@ function isCancelled (fetchParams) { fetchParams.controller.state === 'terminated' } -const normalizeMethodRecordBase = { - delete: 'DELETE', - DELETE: 'DELETE', - get: 'GET', - GET: 'GET', - head: 'HEAD', - HEAD: 'HEAD', - options: 'OPTIONS', - OPTIONS: 'OPTIONS', - post: 'POST', - POST: 'POST', - put: 'PUT', - PUT: 'PUT' -} - -const normalizeMethodRecord = { - ...normalizeMethodRecordBase, - patch: 'patch', - PATCH: 'PATCH' -} - -// Note: object prototypes should not be able to be referenced. e.g. `Object#hasOwnProperty`. -Object.setPrototypeOf(normalizeMethodRecordBase, null) -Object.setPrototypeOf(normalizeMethodRecord, null) - /** * @see https://fetch.spec.whatwg.org/#concept-method-normalize * @param {string} method */ function normalizeMethod (method) { - return normalizeMethodRecordBase[method.toLowerCase()] ?? method + return normalizedMethodRecordsBase[method.toLowerCase()] ?? method } // https://infra.spec.whatwg.org/#serialize-a-javascript-value-to-a-json-string @@ -1639,7 +1614,6 @@ module.exports = { urlHasHttpsScheme, urlIsHttpHttpsScheme, readAllBytes, - normalizeMethodRecord, simpleRangeHeaderValue, buildContentRange, parseMetadata, diff --git a/lib/web/websocket/websocket.js b/lib/web/websocket/websocket.js index 83d4ee94e30..109d7be2e2f 100644 --- a/lib/web/websocket/websocket.js +++ b/lib/web/websocket/websocket.js @@ -28,8 +28,6 @@ const { types } = require('node:util') const { ErrorEvent, CloseEvent } = require('./events') const { SendQueue } = require('./sender') -let experimentalWarned = false - // https://websockets.spec.whatwg.org/#interface-definition class WebSocket extends EventTarget { #events = { @@ -56,13 +54,6 @@ class WebSocket extends EventTarget { const prefix = 'WebSocket constructor' webidl.argumentLengthCheck(arguments, 1, prefix) - if (!experimentalWarned) { - experimentalWarned = true - process.emitWarning('WebSockets are experimental, expect them to change at any time.', { - code: 'UNDICI-WS' - }) - } - const options = webidl.converters['DOMString or sequence or WebSocketInit'](protocols, prefix, 'options') url = webidl.converters.USVString(url, prefix, 'url') diff --git a/package.json b/package.json index eaaea7add10..b1afc62b119 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "undici", - "version": "6.18.2", + "version": "6.19.0", "description": "An HTTP/1.1 client, written from scratch for Node.js", "homepage": "https://undici.nodejs.org", "bugs": { @@ -107,8 +107,8 @@ "@sinonjs/fake-timers": "^11.1.0", "@types/node": "^18.0.3", "abort-controller": "^3.0.0", - "borp": "^0.14.0", - "c8": "^9.1.0", + "borp": "^0.15.0", + "c8": "^10.0.0", "cross-env": "^7.0.3", "dns-packet": "^5.4.0", "fast-check": "^3.17.1", diff --git a/test/connect-pre-shared-session.js b/test/connect-pre-shared-session.js new file mode 100644 index 00000000000..5ee7b308885 --- /dev/null +++ b/test/connect-pre-shared-session.js @@ -0,0 +1,50 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, after, mock } = require('node:test') +const { Client } = require('..') +const { createServer } = require('node:https') +const pem = require('https-pem') +const tls = require('node:tls') + +test('custom session passed to client will be used in tls connect call', async (t) => { + t = tspl(t, { plan: 4 }) + + const mockConnect = mock.method(tls, 'connect') + + const server = createServer(pem, (req, res) => { + t.strictEqual('/', req.url) + t.strictEqual('GET', req.method) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + after(() => server.close()) + + server.listen(0, async () => { + const session = Buffer.from('test-session') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false, + session + } + }) + after(() => client.close()) + + const { statusCode, headers, body } = await client.request({ + path: '/', + method: 'GET' + }) + + t.strictEqual(statusCode, 200) + t.strictEqual(headers['content-type'], 'text/plain') + + const responseText = await body.text() + t.strictEqual('hello', responseText) + + const connectSession = mockConnect.mock.calls[0].arguments[0].session + t.strictEqual(connectSession, session) + }) + + await t.completed +}) diff --git a/test/imports/undici-import.ts b/test/imports/undici-import.ts index fb7344e49b8..ecab8584f6b 100644 --- a/test/imports/undici-import.ts +++ b/test/imports/undici-import.ts @@ -1,5 +1,10 @@ import { request } from '../../' +import { interceptors } from '../../' async function exampleCode() { + const retry = interceptors.retry() + const rd = interceptors.redirect() + const dump = interceptors.dump() + await request('http://localhost:3000/foo') } diff --git a/test/node-test/debug.js b/test/node-test/debug.js index d7c462f57ae..16de2f2883f 100644 --- a/test/node-test/debug.js +++ b/test/node-test/debug.js @@ -9,7 +9,7 @@ const { tspl } = require('@matteo.collina/tspl') const removeEscapeColorsRE = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g test('debug#websocket', { skip: !process.versions.icu }, async t => { - const assert = tspl(t, { plan: 8 }) + const assert = tspl(t, { plan: 6 }) const child = spawn( process.execPath, [join(__dirname, '../fixtures/websocket.js')], @@ -22,9 +22,6 @@ test('debug#websocket', { skip: !process.versions.icu }, async t => { const chunks = [] const assertions = [ /(WEBSOCKET [0-9]+:) (connecting to)/, - // Skip the chunk that comes with the experimental warning - /(\[UNDICI-WS\])/, - /\(Use `node --trace-warnings \.\.\.` to show where the warning was created\)/, /(WEBSOCKET [0-9]+:) (connected to)/, /(WEBSOCKET [0-9]+:) (sending request)/, /(WEBSOCKET [0-9]+:) (connection opened)/, diff --git a/test/retry-handler.js b/test/retry-handler.js index 83e222861ae..1f33fc532e1 100644 --- a/test/retry-handler.js +++ b/test/retry-handler.js @@ -595,7 +595,7 @@ test('Should handle 206 partial content', async t => { onBodySent () { t.ok(true, 'pass') }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { + onHeaders (status, _rawHeaders, _resume, _statusMessage) { t.strictEqual(status, 200) return true }, @@ -636,7 +636,7 @@ test('Should handle 206 partial content', async t => { }) test('Should handle 206 partial content - bad-etag', async t => { - t = tspl(t, { plan: 6 }) + t = tspl(t, { plan: 8 }) const chunks = [] @@ -683,7 +683,7 @@ test('Should handle 206 partial content - bad-etag', async t => { onBodySent () { t.ok(true, 'pass') }, - onHeaders (status, _rawHeaders, resume, _statusMessage) { + onHeaders (_status, _rawHeaders, _resume, _statusMessage) { t.ok(true, 'pass') return true }, @@ -697,6 +697,8 @@ test('Should handle 206 partial content - bad-etag', async t => { onError (err) { t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'abc') t.strictEqual(err.code, 'UND_ERR_REQ_RETRY') + t.strictEqual(err.message, 'ETag mismatch') + t.deepEqual(err.data, { count: 2 }) } } } @@ -1083,6 +1085,7 @@ test('Should be able to properly pass the minTimeout to the RetryContext when co await t.completed }) + test('Issue#2986 - Handle custom 206', async t => { t = tspl(t, { plan: 8 }) @@ -1504,3 +1507,105 @@ test('Weak etags are ignored on range-requests', async t => { await t.completed }) + +test('Should throw RequestRetryError when Content-Range mismatch', async t => { + t = tspl(t, { plan: 10 }) + + const chunks = [] + + // Took from: https://github.com/nxtedition/nxt-lib/blob/4b001ebc2f22cf735a398f35ff800dd553fe5933/test/undici/retry.js#L47 + let x = 0 + const server = createServer((req, res) => { + if (x === 0) { + t.ok(true, 'pass') + res.setHeader('etag', 'asd') + res.write('abc') + setTimeout(() => { + res.destroy() + }, 1e2) + } else if (x === 1) { + t.deepStrictEqual(req.headers.range, 'bytes=3-') + res.setHeader('content-range', 'bytes bad') // intentionally bad to trigger error + res.setHeader('etag', 'asd') + res.statusCode = 206 + res.end('def') + } + x++ + }) + + const dispatchOptions = { + retryOptions: { + retry: function (err, _, done) { + if (err.code && err.code === 'UND_ERR_DESTROYED') { + return done(false) + } + + if (err.statusCode === 206) return done(err) + + setTimeout(done, 800) + } + }, + method: 'GET', + path: '/', + headers: { + 'content-type': 'application/json' + } + } + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + const handler = new RetryHandler(dispatchOptions, { + dispatch: (...args) => { + return client.dispatch(...args) + }, + handler: { + onRequestSent () { + t.ok(true, 'pass') + }, + onConnect () { + t.ok(true, 'pass') + }, + onBodySent () { + t.ok(true, 'pass') + }, + onHeaders (status, _rawHeaders, _resume, _statusMessage) { + t.strictEqual(status, 200) + return true + }, + onData (chunk) { + chunks.push(chunk) + return true + }, + onComplete () { + t.ifError('should not complete') + }, + onError (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') + t.deepEqual(err.data, { count: 2 }) + } + } + }) + + client.dispatch( + { + method: 'GET', + path: '/', + headers: { + 'content-type': 'application/json' + } + }, + handler + ) + + after(async () => { + await client.close() + + server.close() + await once(server, 'close') + }) + }) + + await t.completed +}) diff --git a/test/types/errors.test-d.ts b/test/types/errors.test-d.ts index 837dbf89443..e6b20251fed 100644 --- a/test/types/errors.test-d.ts +++ b/test/types/errors.test-d.ts @@ -101,6 +101,16 @@ expectAssignable(new errors.ResponseExceede expectAssignable<'ResponseExceededMaxSizeError'>(new errors.ResponseExceededMaxSizeError().name) expectAssignable<'UND_ERR_RES_EXCEEDED_MAX_SIZE'>(new errors.ResponseExceededMaxSizeError().code) +expectAssignable(new errors.RequestRetryError('', 0)) +expectAssignable(new errors.RequestRetryError('', 0)) +expectAssignable<'RequestRetryError'>(new errors.RequestRetryError('', 0).name) +expectAssignable<'UND_ERR_REQ_RETRY'>(new errors.RequestRetryError('', 0).code) + +expectAssignable(new errors.SecureProxyConnectionError()) +expectAssignable(new errors.SecureProxyConnectionError()) +expectAssignable<'SecureProxyConnectionError'>(new errors.SecureProxyConnectionError().name) +expectAssignable<'UND_ERR_PRX_TLS'>(new errors.SecureProxyConnectionError().code) + { // @ts-ignore function f (): errors.HeadersTimeoutError | errors.ConnectTimeoutError { return } diff --git a/types/errors.d.ts b/types/errors.d.ts index 7923ddd9796..f6fb73b5a90 100644 --- a/types/errors.d.ts +++ b/types/errors.d.ts @@ -125,4 +125,25 @@ declare namespace Errors { name: 'ResponseExceededMaxSizeError'; code: 'UND_ERR_RES_EXCEEDED_MAX_SIZE'; } + + export class RequestRetryError extends UndiciError { + constructor ( + message: string, + statusCode: number, + headers?: IncomingHttpHeaders | string[] | null, + body?: null | Record | string + ); + name: 'RequestRetryError'; + code: 'UND_ERR_REQ_RETRY'; + statusCode: number; + data: { + count: number; + }; + headers: Record; + } + + export class SecureProxyConnectionError extends UndiciError { + name: 'SecureProxyConnectionError'; + code: 'UND_ERR_PRX_TLS'; + } } diff --git a/types/index.d.ts b/types/index.d.ts index 9e5eaeb3d54..18657b6aeb7 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -18,6 +18,7 @@ import EnvHttpProxyAgent from './env-http-proxy-agent' import RetryHandler from'./retry-handler' import RetryAgent from'./retry-agent' import { request, pipeline, stream, connect, upgrade } from './api' +import interceptors from './interceptors' export * from './util' export * from './cookies' @@ -32,7 +33,7 @@ export * from './content-type' export * from './cache' export { Interceptable } from './mock-interceptor' -export { Dispatcher, BalancedPool, Pool, Client, buildConnector, errors, Agent, request, stream, pipeline, connect, upgrade, setGlobalDispatcher, getGlobalDispatcher, setGlobalOrigin, getGlobalOrigin, MockClient, MockPool, MockAgent, mockErrors, ProxyAgent, EnvHttpProxyAgent, RedirectHandler, DecoratorHandler, RetryHandler, RetryAgent } +export { Dispatcher, BalancedPool, Pool, Client, buildConnector, errors, Agent, request, stream, pipeline, connect, upgrade, setGlobalDispatcher, getGlobalDispatcher, setGlobalOrigin, getGlobalOrigin, interceptors, MockClient, MockPool, MockAgent, mockErrors, ProxyAgent, EnvHttpProxyAgent, RedirectHandler, DecoratorHandler, RetryHandler, RetryAgent } export default Undici declare namespace Undici {