diff --git a/lib/web/fetch/index.js b/lib/web/fetch/index.js index 435eafdf15f..aad6c54ec5a 100644 --- a/lib/web/fetch/index.js +++ b/lib/web/fetch/index.js @@ -65,8 +65,6 @@ const { webidl } = require('./webidl') const { STATUS_CODES } = require('node:http') const GET_OR_HEAD = ['GET', 'HEAD'] -const noop = () => {} - const defaultUserAgent = typeof __UNDICI_IS_NODE__ !== 'undefined' || typeof esbuildDetection !== 'undefined' ? 'node' : 'undici' @@ -2060,7 +2058,7 @@ async function httpNetworkFetch ( function dispatch ({ body }) { const url = requestCurrentURL(request) - /** @type {import('../..').Agent} */ + /** @type {import('../../..').Agent} */ const agent = fetchParams.controller.dispatcher return new Promise((resolve, reject) => agent.dispatch( @@ -2074,6 +2072,9 @@ async function httpNetworkFetch ( upgrade: request.mode === 'websocket' ? 'websocket' : undefined }, { + /** + * @type {import('node:stream').Readable|null} + */ body: null, abort: null, @@ -2114,35 +2115,37 @@ async function httpNetworkFetch ( /** @type {string[]} */ let codings = [] - let location = '' const headersList = new HeadersList() for (let i = 0; i < rawHeaders.length; i += 2) { headersList.append(bufferToLowerCasedHeaderName(rawHeaders[i]), rawHeaders[i + 1].toString('latin1'), true) } + const contentEncoding = headersList.get('content-encoding', true) if (contentEncoding) { // https://www.rfc-editor.org/rfc/rfc7231#section-3.1.2.1 // "All content-coding values are case-insensitive..." codings = contentEncoding.toLowerCase().split(',').map((x) => x.trim()) } - location = headersList.get('location', true) this.body = new Readable({ read: resume }) - const decoders = [] + /** @type {[src: import('node:stream').Readable, ...target: import('node:stream').Writable[]]} */ + let streams - const willFollow = location && request.redirect === 'follow' && - redirectStatusSet.has(status) + const willFollow = request.redirect === 'follow' && + redirectStatusSet.has(status) && + headersList.get('location', true) // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding if (codings.length !== 0 && request.method !== 'HEAD' && request.method !== 'CONNECT' && !nullBodyStatus.includes(status) && !willFollow) { + streams = [this.body] for (let i = codings.length - 1; i >= 0; --i) { const coding = codings[i] // https://www.rfc-editor.org/rfc/rfc9112.html#section-7.2 if (coding === 'x-gzip' || coding === 'gzip') { - decoders.push(zlib.createGunzip({ + streams.push(zlib.createGunzip({ // Be less strict when decoding compressed responses, since sometimes // servers send slightly invalid responses that are still accepted // by common browsers. @@ -2151,23 +2154,38 @@ async function httpNetworkFetch ( finishFlush: zlib.constants.Z_SYNC_FLUSH })) } else if (coding === 'deflate') { - decoders.push(createInflate()) + streams.push(createInflate({ + flush: zlib.constants.Z_SYNC_FLUSH, + finishFlush: zlib.constants.Z_SYNC_FLUSH + })) } else if (coding === 'br') { - decoders.push(zlib.createBrotliDecompress()) + streams.push(zlib.createBrotliDecompress({ + flush: zlib.constants.BROTLI_OPERATION_FLUSH, + finishFlush: zlib.constants.BROTLI_OPERATION_FLUSH + })) } else { - decoders.length = 0 + // If the server sends the payload with a coding which his not + // supported, the body will be passed through without decoding. + streams = undefined break } } } + const onError = this.onError.bind(this) + const callback = (err) => { + if (err) { + onError(err) + } + } + resolve({ status, statusText, headersList, - body: decoders.length - ? pipeline(this.body, ...decoders, noop) - : this.body.on('error', noop) + body: streams === undefined + ? this.body.on('error', onError) + : pipeline(streams, callback).on('error', onError) }) return true diff --git a/lib/web/fetch/util.js b/lib/web/fetch/util.js index f53991c9440..9bead826aa9 100644 --- a/lib/web/fetch/util.js +++ b/lib/web/fetch/util.js @@ -1338,6 +1338,14 @@ function buildContentRange (rangeStart, rangeEnd, fullLength) { // interpreted as a zlib stream, otherwise it's interpreted as a // raw deflate stream. class InflateStream extends Transform { + #zlibOptions + + /** @param {zlib.ZlibOptions} [zlibOptions] */ + constructor (zlibOptions) { + super() + this.#zlibOptions = zlibOptions + } + _transform (chunk, encoding, callback) { if (!this._inflateStream) { if (chunk.length === 0) { @@ -1345,8 +1353,8 @@ class InflateStream extends Transform { return } this._inflateStream = (chunk[0] & 0x0F) === 0x08 - ? zlib.createInflate() - : zlib.createInflateRaw() + ? zlib.createInflate(this.#zlibOptions) + : zlib.createInflateRaw(this.#zlibOptions) this._inflateStream.on('data', this.push.bind(this)) this._inflateStream.on('end', () => this.push(null)) @@ -1365,8 +1373,12 @@ class InflateStream extends Transform { } } -function createInflate () { - return new InflateStream() +/** + * @param {zlib.ZlibOptions} [zlibOptions] + * @returns {InflateStream} + */ +function createInflate (zlibOptions) { + return new InflateStream(zlibOptions) } /** diff --git a/test/issue-3616.js b/test/issue-3616.js new file mode 100644 index 00000000000..1817a61e0a4 --- /dev/null +++ b/test/issue-3616.js @@ -0,0 +1,48 @@ +'use strict' + +const { createServer } = require('node:http') +const { tspl } = require('@matteo.collina/tspl') +const { describe, test, after } = require('node:test') +const { fetch } = require('..') +const { once } = require('node:events') + +describe('https://github.com/nodejs/undici/issues/3616', () => { + const cases = [ + 'x-gzip', + 'gzip', + 'deflate', + 'br' + ] + + for (const encoding of cases) { + test(encoding, async t => { + t = tspl(t, { plan: 2 }) + const server = createServer((req, res) => { + res.writeHead(200, { + 'Content-Length': '0', + Connection: 'close', + 'Content-Encoding': encoding + }) + res.end() + }) + + after(() => { + server.close() + }) + + server.listen(0) + + await once(server, 'listening') + const result = await fetch(`http://localhost:${server.address().port}/`) + + t.ok(result.body.getReader()) + + process.on('uncaughtException', (reason) => { + t.fail('Uncaught Exception:', reason, encoding) + }) + + await new Promise(resolve => setTimeout(resolve, 100)) + t.ok(true) + }) + } +})