diff --git a/README.md b/README.md index 4336ef06836..2ac58b6695e 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ The `body` mixins are the most common way to format the request/response body. M - [`.arrayBuffer()`](https://fetch.spec.whatwg.org/#dom-body-arraybuffer) - [`.blob()`](https://fetch.spec.whatwg.org/#dom-body-blob) +- [`.bytes()`](https://fetch.spec.whatwg.org/#dom-body-bytes) - [`.json()`](https://fetch.spec.whatwg.org/#dom-body-json) - [`.text()`](https://fetch.spec.whatwg.org/#dom-body-text) diff --git a/docs/docs/api/Dispatcher.md b/docs/docs/api/Dispatcher.md index 574030bf686..67819ecd525 100644 --- a/docs/docs/api/Dispatcher.md +++ b/docs/docs/api/Dispatcher.md @@ -488,11 +488,13 @@ The `RequestOptions.method` property should not be value `'CONNECT'`. `body` contains the following additional [body mixin](https://fetch.spec.whatwg.org/#body-mixin) methods and properties: -- `text()` -- `json()` -- `arrayBuffer()` -- `body` -- `bodyUsed` +* [`.arrayBuffer()`](https://fetch.spec.whatwg.org/#dom-body-arraybuffer) +* [`.blob()`](https://fetch.spec.whatwg.org/#dom-body-blob) +* [`.bytes()`](https://fetch.spec.whatwg.org/#dom-body-bytes) +* [`.json()`](https://fetch.spec.whatwg.org/#dom-body-json) +* [`.text()`](https://fetch.spec.whatwg.org/#dom-body-text) +* `body` +* `bodyUsed` `body` can not be consumed twice. For example, calling `text()` after `json()` throws `TypeError`. diff --git a/docs/docs/api/Fetch.md b/docs/docs/api/Fetch.md index c3406f128dc..00c349847dc 100644 --- a/docs/docs/api/Fetch.md +++ b/docs/docs/api/Fetch.md @@ -28,6 +28,7 @@ This API is implemented as per the standard, you can find documentation on [MDN] - [`.arrayBuffer()`](https://fetch.spec.whatwg.org/#dom-body-arraybuffer) - [`.blob()`](https://fetch.spec.whatwg.org/#dom-body-blob) +- [`.bytes()`](https://fetch.spec.whatwg.org/#dom-body-bytes) - [`.formData()`](https://fetch.spec.whatwg.org/#dom-body-formdata) - [`.json()`](https://fetch.spec.whatwg.org/#dom-body-json) - [`.text()`](https://fetch.spec.whatwg.org/#dom-body-text) diff --git a/lib/api/readable.js b/lib/api/readable.js index a65a7fcb557..47fbf3e0ef1 100644 --- a/lib/api/readable.js +++ b/lib/api/readable.js @@ -121,6 +121,11 @@ class BodyReadable extends Readable { return consume(this, 'blob') } + // https://fetch.spec.whatwg.org/#dom-body-bytes + async bytes () { + return consume(this, 'bytes') + } + // https://fetch.spec.whatwg.org/#dom-body-arraybuffer async arrayBuffer () { return consume(this, 'arrayBuffer') @@ -306,6 +311,31 @@ function chunksDecode (chunks, length) { return buffer.utf8Slice(start, bufferLength) } +/** + * @param {Buffer[]} chunks + * @param {number} length + * @returns {Uint8Array} + */ +function chunksConcat (chunks, length) { + if (chunks.length === 0 || length === 0) { + return new Uint8Array(0) + } + if (chunks.length === 1) { + // fast-path + return new Uint8Array(chunks[0]) + } + const buffer = new Uint8Array(Buffer.allocUnsafeSlow(length).buffer) + + let offset = 0 + for (let i = 0; i < chunks.length; ++i) { + const chunk = chunks[i] + buffer.set(chunk, offset) + offset += chunk.length + } + + return buffer +} + function consumeEnd (consume) { const { type, body, resolve, stream, length } = consume @@ -315,17 +345,11 @@ function consumeEnd (consume) { } else if (type === 'json') { resolve(JSON.parse(chunksDecode(body, length))) } else if (type === 'arrayBuffer') { - const dst = new Uint8Array(length) - - let pos = 0 - for (const buf of body) { - dst.set(buf, pos) - pos += buf.byteLength - } - - resolve(dst.buffer) + resolve(chunksConcat(body, length).buffer) } else if (type === 'blob') { resolve(new Blob(body, { type: stream[kContentType] })) + } else if (type === 'bytes') { + resolve(chunksConcat(body, length)) } consumeFinish(consume) diff --git a/lib/dispatcher/client-h1.js b/lib/dispatcher/client-h1.js index 40628e53d4c..2b8fa05da29 100644 --- a/lib/dispatcher/client-h1.js +++ b/lib/dispatcher/client-h1.js @@ -860,7 +860,10 @@ function writeH1 (client, request) { const expectsPayload = ( method === 'PUT' || method === 'POST' || - method === 'PATCH' + method === 'PATCH' || + method === 'QUERY' || + method === 'PROPFIND' || + method === 'PROPPATCH' ) if (util.isFormDataLike(body)) { @@ -1139,7 +1142,7 @@ function writeBuffer (abort, body, client, request, socket, contentLength, heade socket.uncork() request.onBodySent(body) - if (!expectsPayload) { + if (!expectsPayload && request.reset !== false) { socket[kReset] = true } } @@ -1169,7 +1172,7 @@ async function writeBlob (abort, body, client, request, socket, contentLength, h request.onBodySent(buffer) request.onRequestSent() - if (!expectsPayload) { + if (!expectsPayload && request.reset !== false) { socket[kReset] = true } @@ -1270,7 +1273,7 @@ class AsyncWriter { socket.cork() if (bytesWritten === 0) { - if (!expectsPayload) { + if (!expectsPayload && request.reset !== false) { socket[kReset] = true } diff --git a/lib/dispatcher/client-h2.js b/lib/dispatcher/client-h2.js index 6c5155717d1..0448fa00736 100644 --- a/lib/dispatcher/client-h2.js +++ b/lib/dispatcher/client-h2.js @@ -24,7 +24,9 @@ const { kOnError, kMaxConcurrentStreams, kHTTP2Session, - kResume + kResume, + kSize, + kHTTPContext } = require('../core/symbols.js') const kOpenStreams = Symbol('open streams') @@ -160,11 +162,10 @@ async function connectH2 (client, socket) { version: 'h2', defaultPipelining: Infinity, write (...args) { - // TODO (fix): return - writeH2(client, ...args) + return writeH2(client, ...args) }, resume () { - + resumeH2(client) }, destroy (err, callback) { if (closed) { @@ -183,6 +184,20 @@ async function connectH2 (client, socket) { } } +function resumeH2 (client) { + const socket = client[kSocket] + + if (socket?.destroyed === false) { + if (client[kSize] === 0 && client[kMaxConcurrentStreams] === 0) { + socket.unref() + client[kHTTP2Session].unref() + } else { + socket.ref() + client[kHTTP2Session].ref() + } + } +} + function onHttp2SessionError (err) { assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID') @@ -210,17 +225,32 @@ function onHttp2SessionEnd () { * along with the socket right away */ function onHTTP2GoAway (code) { - const err = new RequestAbortedError(`HTTP/2: "GOAWAY" frame received with code ${code}`) + // We cannot recover, so best to close the session and the socket + const err = this[kError] || new SocketError(`HTTP/2: "GOAWAY" frame received with code ${code}`, util.getSocketInfo(this)) + const client = this[kClient] - // We need to trigger the close cycle right away - // We need to destroy the session and the socket - // Requests should be failed with the error after the current one is handled - this[kSocket][kError] = err - this[kClient][kOnError](err) + client[kSocket] = null + client[kHTTPContext] = null - this.unref() + if (this[kHTTP2Session] != null) { + this[kHTTP2Session].destroy(err) + this[kHTTP2Session] = null + } util.destroy(this[kSocket], err) + + // Fail head of pipeline. + const request = client[kQueue][client[kRunningIdx]] + client[kQueue][client[kRunningIdx]++] = null + util.errorRequest(client, request, err) + + client[kPendingIdx] = client[kRunningIdx] + + assert(client[kRunning] === 0) + + client.emit('disconnect', client[kUrl], [client], err) + + client[kResume]() } // https://www.rfc-editor.org/rfc/rfc7230#section-3.3.2 @@ -237,10 +267,6 @@ function writeH2 (client, request) { return false } - if (request.aborted) { - return false - } - const headers = {} for (let n = 0; n < reqHeaders.length; n += 2) { const key = reqHeaders[n + 0] @@ -283,6 +309,8 @@ function writeH2 (client, request) { // We do not destroy the socket as we can continue using the session // the stream get's destroyed and the session remains to create new streams util.destroy(body, err) + client[kQueue][client[kRunningIdx]++] = null + client[kResume]() } try { @@ -293,6 +321,10 @@ function writeH2 (client, request) { util.errorRequest(client, request, err) } + if (request.aborted) { + return false + } + if (method === 'CONNECT') { session.ref() // We are already connected, streams are pending, first request @@ -304,10 +336,12 @@ function writeH2 (client, request) { if (stream.id && !stream.pending) { request.onUpgrade(null, null, stream) ++session[kOpenStreams] + client[kQueue][client[kRunningIdx]++] = null } else { stream.once('ready', () => { request.onUpgrade(null, null, stream) ++session[kOpenStreams] + client[kQueue][client[kRunningIdx]++] = null }) } @@ -428,17 +462,20 @@ function writeH2 (client, request) { // Present specially when using pipeline or stream if (stream.state?.state == null || stream.state.state < 6) { request.onComplete([]) - return } - // Stream is closed or half-closed-remote (6), decrement counter and cleanup - // It does not have sense to continue working with the stream as we do not - // have yet RST_STREAM support on client-side if (session[kOpenStreams] === 0) { + // Stream is closed or half-closed-remote (6), decrement counter and cleanup + // It does not have sense to continue working with the stream as we do not + // have yet RST_STREAM support on client-side + session.unref() } abort(new InformationalError('HTTP/2: stream half-closed (remote)')) + client[kQueue][client[kRunningIdx]++] = null + client[kPendingIdx] = client[kRunningIdx] + client[kResume]() }) stream.once('close', () => { diff --git a/lib/dispatcher/client.js b/lib/dispatcher/client.js index 7e22aa000ba..3dc356618ba 100644 --- a/lib/dispatcher/client.js +++ b/lib/dispatcher/client.js @@ -63,6 +63,8 @@ let deprecatedInterceptorWarned = false const kClosedResolve = Symbol('kClosedResolve') +const noop = () => {} + function getPipelining (client) { return client[kPipelining] ?? client[kHTTPContext]?.defaultPipelining ?? 1 } @@ -442,7 +444,7 @@ async function connect (client) { }) if (client.destroyed) { - util.destroy(socket.on('error', () => {}), new ClientDestroyedError()) + util.destroy(socket.on('error', noop), new ClientDestroyedError()) return } @@ -453,7 +455,7 @@ async function connect (client) { ? await connectH2(client, socket) : await connectH1(client, socket) } catch (err) { - socket.destroy().on('error', () => {}) + socket.destroy().on('error', noop) throw err } diff --git a/lib/dispatcher/pool-base.js b/lib/dispatcher/pool-base.js index ff3108a4da2..d0ba2c3c53a 100644 --- a/lib/dispatcher/pool-base.js +++ b/lib/dispatcher/pool-base.js @@ -113,9 +113,9 @@ class PoolBase extends DispatcherBase { async [kClose] () { if (this[kQueue].isEmpty()) { - return Promise.all(this[kClients].map(c => c.close())) + await Promise.all(this[kClients].map(c => c.close())) } else { - return new Promise((resolve) => { + await new Promise((resolve) => { this[kClosedResolve] = resolve }) } @@ -130,7 +130,7 @@ class PoolBase extends DispatcherBase { item.handler.onError(err) } - return Promise.all(this[kClients].map(c => c.destroy(err))) + await Promise.all(this[kClients].map(c => c.destroy(err))) } [kDispatch] (opts, handler) { diff --git a/lib/dispatcher/proxy-agent.js b/lib/dispatcher/proxy-agent.js index 226b67846da..c439d7cd555 100644 --- a/lib/dispatcher/proxy-agent.js +++ b/lib/dispatcher/proxy-agent.js @@ -23,6 +23,8 @@ function defaultFactory (origin, opts) { return new Pool(origin, opts) } +const noop = () => {} + class ProxyAgent extends DispatcherBase { constructor (opts) { super() @@ -81,7 +83,7 @@ class ProxyAgent extends DispatcherBase { servername: this[kProxyTls]?.servername || proxyHostname }) if (statusCode !== 200) { - socket.on('error', () => {}).destroy() + socket.on('error', noop).destroy() callback(new RequestAbortedError(`Proxy response (${statusCode}) !== 200 when HTTP Tunneling`)) } if (opts.protocol !== 'https:') { diff --git a/lib/web/fetch/constants.js b/lib/web/fetch/constants.js index dad8d0b5776..1f285e06283 100644 --- a/lib/web/fetch/constants.js +++ b/lib/web/fetch/constants.js @@ -1,27 +1,30 @@ 'use strict' -const corsSafeListedMethods = ['GET', 'HEAD', 'POST'] +const corsSafeListedMethods = /** @type {const} */ (['GET', 'HEAD', 'POST']) const corsSafeListedMethodsSet = new Set(corsSafeListedMethods) -const nullBodyStatus = [101, 204, 205, 304] +const nullBodyStatus = /** @type {const} */ ([101, 204, 205, 304]) -const redirectStatus = [301, 302, 303, 307, 308] +const redirectStatus = /** @type {const} */ ([301, 302, 303, 307, 308]) const redirectStatusSet = new Set(redirectStatus) -// https://fetch.spec.whatwg.org/#block-bad-port -const badPorts = [ +/** + * @see https://fetch.spec.whatwg.org/#block-bad-port + */ +const badPorts = /** @type {const} */ ([ '1', '7', '9', '11', '13', '15', '17', '19', '20', '21', '22', '23', '25', '37', '42', '43', '53', '69', '77', '79', '87', '95', '101', '102', '103', '104', '109', '110', '111', '113', '115', '117', '119', '123', '135', '137', '139', '143', '161', '179', '389', '427', '465', '512', '513', '514', '515', '526', '530', '531', '532', '540', '548', '554', '556', '563', '587', '601', '636', '989', '990', '993', '995', '1719', '1720', '1723', '2049', '3659', '4045', '4190', '5060', '5061', '6000', '6566', '6665', '6666', '6667', '6668', '6669', '6679', '6697', '10080' -] - +]) const badPortsSet = new Set(badPorts) -// https://w3c.github.io/webappsec-referrer-policy/#referrer-policies -const referrerPolicy = [ +/** + * @see https://w3c.github.io/webappsec-referrer-policy/#referrer-policies + */ +const referrerPolicy = /** @type {const} */ ([ '', 'no-referrer', 'no-referrer-when-downgrade', @@ -31,29 +34,31 @@ const referrerPolicy = [ 'origin-when-cross-origin', 'strict-origin-when-cross-origin', 'unsafe-url' -] +]) const referrerPolicySet = new Set(referrerPolicy) -const requestRedirect = ['follow', 'manual', 'error'] +const requestRedirect = /** @type {const} */ (['follow', 'manual', 'error']) -const safeMethods = ['GET', 'HEAD', 'OPTIONS', 'TRACE'] +const safeMethods = /** @type {const} */ (['GET', 'HEAD', 'OPTIONS', 'TRACE']) const safeMethodsSet = new Set(safeMethods) -const requestMode = ['navigate', 'same-origin', 'no-cors', 'cors'] +const requestMode = /** @type {const} */ (['navigate', 'same-origin', 'no-cors', 'cors']) -const requestCredentials = ['omit', 'same-origin', 'include'] +const requestCredentials = /** @type {const} */ (['omit', 'same-origin', 'include']) -const requestCache = [ +const requestCache = /** @type {const} */ ([ 'default', 'no-store', 'reload', 'no-cache', 'force-cache', 'only-if-cached' -] +]) -// https://fetch.spec.whatwg.org/#request-body-header-name -const requestBodyHeader = [ +/** + * @see https://fetch.spec.whatwg.org/#request-body-header-name + */ +const requestBodyHeader = /** @type {const} */ ([ 'content-encoding', 'content-language', 'content-location', @@ -63,18 +68,22 @@ const requestBodyHeader = [ // removed in the Headers implementation. However, undici doesn't // filter out headers, so we add it here. 'content-length' -] +]) -// https://fetch.spec.whatwg.org/#enumdef-requestduplex -const requestDuplex = [ +/** + * @see https://fetch.spec.whatwg.org/#enumdef-requestduplex + */ +const requestDuplex = /** @type {const} */ ([ 'half' -] +]) -// http://fetch.spec.whatwg.org/#forbidden-method -const forbiddenMethods = ['CONNECT', 'TRACE', 'TRACK'] +/** + * @see http://fetch.spec.whatwg.org/#forbidden-method + */ +const forbiddenMethods = /** @type {const} */ (['CONNECT', 'TRACE', 'TRACK']) const forbiddenMethodsSet = new Set(forbiddenMethods) -const subresource = [ +const subresource = /** @type {const} */ ([ 'audio', 'audioworklet', 'font', @@ -87,7 +96,7 @@ const subresource = [ 'video', 'xslt', '' -] +]) const subresourceSet = new Set(subresource) module.exports = { diff --git a/package.json b/package.json index 80b5b95e3f1..97535702e65 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "undici", - "version": "6.20.0", + "version": "6.20.1", "description": "An HTTP/1.1 client, written from scratch for Node.js", "homepage": "https://undici.nodejs.org", "bugs": { @@ -78,6 +78,9 @@ "test:fuzzing": "node test/fuzzing/fuzzing.test.js", "test:fetch": "npm run build:node && npm run test:fetch:nobuild", "test:fetch:nobuild": "borp --timeout 180000 --expose-gc --concurrency 1 -p \"test/fetch/*.js\" && npm run test:webidl && npm run test:busboy", + "test:h2": "npm run test:h2:core && npm run test:h2:fetch", + "test:h2:core": "borp -p \"test/http2*.js\"", + "test:h2:fetch": "npm run build:node && borp -p \"test/fetch/http2*.js\"", "test:interceptors": "borp -p \"test/interceptors/*.js\"", "test:jest": "cross-env NODE_V8_COVERAGE= jest", "test:unit": "borp --expose-gc -p \"test/*.js\"", diff --git a/test/client-request.js b/test/client-request.js index 8cbad5ccb48..c67cecdb7f3 100644 --- a/test/client-request.js +++ b/test/client-request.js @@ -655,6 +655,32 @@ test('request arrayBuffer', async (t) => { await t.completed }) +test('request bytes', async (t) => { + t = tspl(t, { plan: 2 }) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + const { body } = await client.request({ + path: '/', + method: 'GET' + }) + const bytes = await body.bytes() + + t.deepStrictEqual(new TextEncoder().encode(JSON.stringify(obj)), bytes) + t.ok(bytes instanceof Uint8Array) + }) + + await t.completed +}) + test('request body', async (t) => { t = tspl(t, { plan: 1 }) diff --git a/test/http2.js b/test/http2.js index a43700574b8..d6840a1bd15 100644 --- a/test/http2.js +++ b/test/http2.js @@ -217,66 +217,6 @@ test('Should support H2 connection(POST Buffer)', async t => { t.strictEqual(Buffer.concat(body).toString('utf8'), 'hello h2!') }) -test('Should support H2 GOAWAY (server-side)', async t => { - const body = [] - const server = createSecureServer(pem) - - server.on('stream', (stream, headers) => { - t.strictEqual(headers['x-my-header'], 'foo') - t.strictEqual(headers[':method'], 'GET') - stream.respond({ - 'content-type': 'text/plain; charset=utf-8', - 'x-custom-h2': 'hello', - ':status': 200 - }) - stream.end('hello h2!') - }) - - server.on('session', session => { - setTimeout(() => { - session.goaway(0) - }, 1000) - }) - - server.listen(0) - await once(server, 'listening') - - const client = new Client(`https://localhost:${server.address().port}`, { - connect: { - rejectUnauthorized: false - }, - allowH2: true - }) - - t = tspl(t, { plan: 9 }) - after(() => server.close()) - after(() => client.close()) - - const response = await client.request({ - path: '/', - method: 'GET', - headers: { - 'x-my-header': 'foo' - } - }) - - response.body.on('data', chunk => { - body.push(chunk) - }) - - await once(response.body, 'end') - t.strictEqual(response.statusCode, 200) - t.strictEqual(response.headers['content-type'], 'text/plain; charset=utf-8') - t.strictEqual(response.headers['x-custom-h2'], 'hello') - t.strictEqual(Buffer.concat(body).toString('utf8'), 'hello h2!') - - const [url, disconnectClient, err] = await once(client, 'disconnect') - - t.ok(url instanceof URL) - t.deepStrictEqual(disconnectClient, [client]) - t.strictEqual(err.message, 'HTTP/2: "GOAWAY" frame received with code 0') -}) - test('Should throw if bad allowH2 has been passed', async t => { t = tspl(t, { plan: 1 }) @@ -852,7 +792,10 @@ test('Should handle h2 request with body (string or buffer) - dispatch', async t onHeaders (statusCode, headers) { t.strictEqual(statusCode, 200) t.strictEqual(headers[0].toString('utf-8'), 'content-type') - t.strictEqual(headers[1].toString('utf-8'), 'text/plain; charset=utf-8') + t.strictEqual( + headers[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') }, @@ -1183,56 +1126,53 @@ test('Agent should support H2 connection', async t => { t.strictEqual(Buffer.concat(body).toString('utf8'), 'hello h2!') }) -test( - 'Should provide pseudo-headers in proper order', - async t => { - t = tspl(t, { plan: 2 }) +test('Should provide pseudo-headers in proper order', async t => { + t = tspl(t, { plan: 2 }) - const server = createSecureServer(pem) - server.on('stream', (stream, _headers, _flags, rawHeaders) => { - t.deepStrictEqual(rawHeaders, [ - ':authority', - `localhost:${server.address().port}`, - ':method', - 'GET', - ':path', - '/', - ':scheme', - 'https' - ]) + const server = createSecureServer(pem) + server.on('stream', (stream, _headers, _flags, rawHeaders) => { + t.deepStrictEqual(rawHeaders, [ + ':authority', + `localhost:${server.address().port}`, + ':method', + 'GET', + ':path', + '/', + ':scheme', + 'https' + ]) - stream.respond({ - 'content-type': 'text/plain; charset=utf-8', - ':status': 200 - }) - stream.end() + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + ':status': 200 }) + stream.end() + }) - server.listen(0) - await once(server, 'listening') + server.listen(0) + await once(server, 'listening') - const client = new Client(`https://localhost:${server.address().port}`, { - connect: { - rejectUnauthorized: false - }, - allowH2: true - }) + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) - after(() => server.close()) - after(() => client.close()) + after(() => server.close()) + after(() => client.close()) - const response = await client.request({ - path: '/', - method: 'GET' - }) + const response = await client.request({ + path: '/', + method: 'GET' + }) - t.strictEqual(response.statusCode, 200) + t.strictEqual(response.statusCode, 200) - await response.body.dump() + await response.body.dump() - await t.complete - } -) + await t.complete +}) test('The h2 pseudo-headers is not included in the headers', async t => { const server = createSecureServer(pem) @@ -1287,16 +1227,20 @@ test('Should throw informational error on half-closed streams (remote)', async t }) t = tspl(t, { plan: 2 }) - after(() => server.close()) - after(() => client.close()) - - await client.request({ - path: '/', - method: 'GET' - }).catch(err => { - t.strictEqual(err.message, 'HTTP/2: stream half-closed (remote)') - t.strictEqual(err.code, 'UND_ERR_INFO') + after(async () => { + server.close() + await client.close() }) + + await client + .request({ + path: '/', + method: 'GET' + }) + .catch(err => { + t.strictEqual(err.message, 'HTTP/2: stream half-closed (remote)') + t.strictEqual(err.code, 'UND_ERR_INFO') + }) }) test('#2364 - Concurrent aborts', async t => { @@ -1325,62 +1269,76 @@ test('#2364 - Concurrent aborts', async t => { allowH2: true }) - t = tspl(t, { plan: 18 }) + t = tspl(t, { plan: 14 }) after(() => server.close()) after(() => client.close()) - const controller = new AbortController() + const signal = AbortSignal.timeout(50) - client.request({ - path: '/1', - method: 'GET', - headers: { - 'x-my-header': 'foo' + client.request( + { + path: '/1', + method: 'GET', + headers: { + 'x-my-header': 'foo' + } + }, + (err, response) => { + t.ifError(err) + t.strictEqual( + response.headers['content-type'], + 'text/plain; charset=utf-8' + ) + t.strictEqual(response.headers['x-custom-h2'], 'hello') + t.strictEqual(response.statusCode, 200) } - }, (err, response) => { - t.ifError(err) - t.strictEqual(response.headers['content-type'], 'text/plain; charset=utf-8') - t.strictEqual(response.headers['x-custom-h2'], 'hello') - t.strictEqual(response.statusCode, 200) - response.body.dump() - }) + ) - client.request({ - path: '/2', - method: 'GET', - headers: { - 'x-my-header': 'foo' + client.request( + { + path: '/2', + method: 'GET', + headers: { + 'x-my-header': 'foo' + }, + signal }, - signal: controller.signal - }, (err, response) => { - t.strictEqual(err.name, 'AbortError') - }) - - client.request({ - path: '/3', - method: 'GET', - headers: { - 'x-my-header': 'foo' + (err, response) => { + t.strictEqual(err.name, 'TimeoutError') } - }, (err, response) => { - t.ifError(err) - t.strictEqual(response.headers['content-type'], 'text/plain; charset=utf-8') - t.strictEqual(response.headers['x-custom-h2'], 'hello') - t.strictEqual(response.statusCode, 200) - response.body.dump() - }) + ) - client.request({ - path: '/4', - method: 'GET', - headers: { - 'x-my-header': 'foo' + client.request( + { + path: '/3', + method: 'GET', + headers: { + 'x-my-header': 'foo' + } }, - signal: controller.signal - }, (err, response) => { - t.strictEqual(err.name, 'AbortError') - }) + (err, response) => { + t.ifError(err) + t.strictEqual( + response.headers['content-type'], + 'text/plain; charset=utf-8' + ) + t.strictEqual(response.headers['x-custom-h2'], 'hello') + t.strictEqual(response.statusCode, 200) + } + ) - controller.abort() + client.request( + { + path: '/4', + method: 'GET', + headers: { + 'x-my-header': 'foo' + }, + signal + }, + (err, response) => { + t.strictEqual(err.name, 'TimeoutError') + } + ) await t.completed }) @@ -1418,8 +1376,8 @@ test('#3046 - GOAWAY Frame', async t => { }) t = tspl(t, { plan: 7 }) - after(() => server.close()) after(() => client.close()) + after(() => server.close()) client.on('disconnect', (url, disconnectClient, err) => { t.ok(url instanceof URL) @@ -1439,10 +1397,56 @@ test('#3046 - GOAWAY Frame', async t => { t.strictEqual(response.headers['x-custom-h2'], 'hello') t.strictEqual(response.statusCode, 200) - t.rejects(response.body.text.bind(response.body), { + t.rejects(response.body.text(), { message: 'HTTP/2: "GOAWAY" frame received with code 0', - code: 'UND_ERR_ABORTED' + code: 'UND_ERR_SOCKET' + }) + + await t.completed +}) + +test('#3671 - Graceful close', async (t) => { + const server = createSecureServer(pem) + + server.on('stream', (stream, headers) => { + setTimeout(() => { + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': 'hello', + ':status': 200 + }) + stream.end('Hello World') + }, 200) }) + server.listen(0) + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t = tspl(t, { plan: 5 }) + after(() => server.close()) + + client.request({ + path: '/', + method: 'GET', + headers: { + 'x-my-header': 'foo' + } + }, async (err, response) => { + t.ifError(err) + t.strictEqual(response.headers['content-type'], 'text/plain; charset=utf-8') + t.strictEqual(response.headers['x-custom-h2'], 'hello') + t.strictEqual(response.statusCode, 200) + t.equal(await response.body.text(), 'Hello World') + }) + + await client.close() + await t.completed }) diff --git a/test/node-test/client-dispatch.js b/test/node-test/client-dispatch.js index f9ed888d44b..296e3b8d075 100644 --- a/test/node-test/client-dispatch.js +++ b/test/node-test/client-dispatch.js @@ -1051,7 +1051,7 @@ test('Issue#3065 - fix bad destroy handling', async (t) => { test('Issue#3065 - fix bad destroy handling (h2)', async (t) => { // Due to we handle the session, the request for h2 will fail on servername change - const p = tspl(t, { plan: 5 }) + const p = tspl(t, { plan: 4 }) const server = createSecureServer(pem) server.on('stream', (stream) => { stream.respond({ @@ -1105,8 +1105,7 @@ test('Issue#3065 - fix bad destroy handling (h2)', async (t) => { p.deepStrictEqual(dispatches, ['onConnect', 'onBodySent', 'onResponseStarted', 'onHeaders1', 'onData', 'onComplete']) }, onError (err) { - p.strictEqual(err.code, 'UND_ERR_INFO') - p.strictEqual(err.message, 'servername changed') + p.ifError(err) } }) diff --git a/test/readable.js b/test/readable.js index dd0631daf8b..e6a6ed0dccd 100644 --- a/test/readable.js +++ b/test/readable.js @@ -83,6 +83,27 @@ describe('Readable', () => { t.deepStrictEqual(arrayBuffer, expected) }) + test('.bytes()', async function (t) { + t = tspl(t, { plan: 1 }) + + function resume () { + } + function abort () { + } + const r = new Readable({ resume, abort }) + + r.push(Buffer.from('hello')) + r.push(Buffer.from(' world')) + + process.nextTick(() => { + r.push(null) + }) + + const bytes = await r.bytes() + + t.deepStrictEqual(bytes, new TextEncoder().encode('hello world')) + }) + test('.json()', async function (t) { t = tspl(t, { plan: 1 }) diff --git a/test/types/readable.test-d.ts b/test/types/readable.test-d.ts index d004b706569..b5d32f6c221 100644 --- a/test/types/readable.test-d.ts +++ b/test/types/readable.test-d.ts @@ -20,6 +20,9 @@ expectAssignable(new BodyReadable()) // blob expectAssignable>(readable.blob()) + // bytes + expectAssignable>(readable.bytes()) + // arrayBuffer expectAssignable>(readable.arrayBuffer()) diff --git a/types/dispatcher.d.ts b/types/dispatcher.d.ts index 0aa2aba00e3..1b4c9c74a5d 100644 --- a/types/dispatcher.d.ts +++ b/types/dispatcher.d.ts @@ -244,6 +244,7 @@ declare namespace Dispatcher { readonly bodyUsed: boolean; arrayBuffer(): Promise; blob(): Promise; + bytes(): Promise; formData(): Promise; json(): Promise; text(): Promise; diff --git a/types/readable.d.ts b/types/readable.d.ts index a5fce8a20d3..c4f052af05e 100644 --- a/types/readable.d.ts +++ b/types/readable.d.ts @@ -25,6 +25,11 @@ declare class BodyReadable extends Readable { */ blob(): Promise + /** Consumes and returns the body as an Uint8Array + * https://fetch.spec.whatwg.org/#dom-body-bytes + */ + bytes(): Promise + /** Consumes and returns the body as an ArrayBuffer * https://fetch.spec.whatwg.org/#dom-body-arraybuffer */