diff --git a/.eslintrc.yml b/.eslintrc.yml index 7e4411498..f482cdc01 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -2,7 +2,7 @@ env: node: true parserOptions: - ecmaVersion: 9 + ecmaVersion: 2020 # Override eslint-config-standard, which incorrectly sets this to "module", # though that setting is only for ES6 modules, not CommonJS modules. sourceType: 'script' diff --git a/lib/create_response.js b/lib/create_response.js index 3952d4a3e..cf9f3d9c7 100644 --- a/lib/create_response.js +++ b/lib/create_response.js @@ -1,7 +1,9 @@ 'use strict' +const zlib = require('node:zlib') const { headersArrayToObject } = require('./common') const { STATUS_CODES } = require('http') +const { pipeline, Readable } = require('node:stream') /** * Creates a Fetch API `Response` instance from the given @@ -16,17 +18,57 @@ const { STATUS_CODES } = require('http') const responseStatusCodesWithoutBody = [204, 205, 304] /** - * @param {IncomingMessage} message + * @param {import('node:http').IncomingMessage} message */ function createResponse(message) { + // https://github.com/Uzlopak/undici/blob/main/lib/fetch/index.js#L2031 + const decoders = [] + const codings = + message.headers['content-encoding'] + ?.toLowerCase() + .split(',') + .map(x => x.trim()) + .reverse() || [] + for (const coding of codings) { + if (coding === 'gzip' || coding === 'x-gzip') { + decoders.push( + zlib.createGunzip({ + flush: zlib.constants.Z_SYNC_FLUSH, + finishFlush: zlib.constants.Z_SYNC_FLUSH, + }), + ) + } else if (coding === 'deflate') { + decoders.push(zlib.createInflate()) + } else if (coding === 'br') { + decoders.push(zlib.createBrotliDecompress()) + } else { + decoders.length = 0 + break + } + } + + const chunks = [] const responseBodyOrNull = responseStatusCodesWithoutBody.includes( message.statusCode, ) ? null : new ReadableStream({ start(controller) { - message.on('data', chunk => controller.enqueue(chunk)) - message.on('end', () => controller.close()) + message.on('data', chunk => chunks.push(chunk)) + message.on('end', () => { + pipeline( + Readable.from(chunks), + ...decoders, + async function* (source) { + for await (const chunk of source) { + yield controller.enqueue(chunk) + } + }, + error => { + error ? controller.error(error) : controller.close() + }, + ) + }) /** * @todo Should also listen to the "error" on the message diff --git a/package-lock.json b/package-lock.json index 36a63638a..e5d901e63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,7 @@ "typescript": "^5.0.4" }, "engines": { - "node": ">= 10.13" + "node": ">= 18" } }, "node_modules/@aashutoshrathi/word-wrap": { diff --git a/package.json b/package.json index 5352e8805..a5fadd596 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "url": "https://github.com/nock/nock/issues" }, "engines": { - "node": ">= 10.13" + "node": ">= 18" }, "main": "./index.js", "types": "types", diff --git a/tests/test_fetch.js b/tests/test_fetch.js index 25d0b6ca6..4c1ca9071 100644 --- a/tests/test_fetch.js +++ b/tests/test_fetch.js @@ -1,5 +1,6 @@ 'use strict' +const zlib = require('zlib') const { expect } = require('chai') const nock = require('..') const assertRejects = require('assert-rejects') @@ -98,4 +99,121 @@ describe('Native Fetch', () => { expect(status).to.equal(404) expect(statusText).to.equal('Not Found') }) + + it('should return mocked response', async () => { + const message = 'Lorem ipsum dolor sit amet' + const scope = nock('http://example.test').get('/foo').reply(200, message) + + const response = await fetch('http://example.test/foo') + + expect(response.status).to.equal(200) + expect(await response.text()).to.equal(message) + scope.done() + }) + + describe('content-encoding', () => { + it('should accept gzipped content', async () => { + const message = 'Lorem ipsum dolor sit amet' + const compressed = zlib.gzipSync(message) + + const scope = nock('http://example.test') + .get('/foo') + .reply(200, compressed, { + 'X-Transfer-Length': String(compressed.length), + 'Content-Length': undefined, + 'Content-Encoding': 'gzip', + }) + const response = await fetch('http://example.test/foo') + + expect(response.status).to.equal(200) + expect(await response.text()).to.equal(message) + scope.done() + }) + + it('should accept deflated content', async () => { + const message = 'Lorem ipsum dolor sit amet' + const compressed = zlib.deflateSync(message) + + const scope = nock('http://example.test') + .get('/foo') + .reply(200, compressed, { + 'X-Transfer-Length': String(compressed.length), + 'Content-Length': undefined, + 'Content-Encoding': 'deflate', + }) + const response = await fetch('http://example.test/foo') + + expect(response.status).to.equal(200) + expect(await response.text()).to.equal(message) + scope.done() + }) + + it('should accept brotli content', async () => { + const message = 'Lorem ipsum dolor sit amet' + const compressed = zlib.brotliCompressSync(message) + + const scope = nock('http://example.test') + .get('/foo') + .reply(200, compressed, { + 'X-Transfer-Length': String(compressed.length), + 'Content-Length': undefined, + 'Content-Encoding': 'br', + }) + const response = await fetch('http://example.test/foo') + + expect(response.status).to.equal(200) + expect(await response.text()).to.equal(message) + scope.done() + }) + + it('should accept gzip and broti content', async () => { + const message = 'Lorem ipsum dolor sit amet' + const compressed = zlib.brotliCompressSync(zlib.gzipSync(message)) + + const scope = nock('http://example.test') + .get('/foo') + .reply(200, compressed, { + 'X-Transfer-Length': String(compressed.length), + 'Content-Length': undefined, + 'Content-Encoding': 'gzip, br', + }) + const response = await fetch('http://example.test/foo') + + expect(response.status).to.equal(200) + expect(await response.text()).to.equal(message) + scope.done() + }) + + it('should pass through the result if a not supported encoding was used', async () => { + const message = 'Lorem ipsum dolor sit amet' + const compressed = Buffer.from(message) + const scope = nock('http://example.test') + .get('/foo') + .reply(200, compressed, { + 'X-Transfer-Length': String(compressed.length), + 'Content-Length': undefined, + 'Content-Encoding': 'invalid', + }) + const response = await fetch('http://example.test/foo') + expect(response.status).to.equal(200) + expect(await response.text()).to.equal(message) + scope.done() + }) + + it('should throw error if wrong encoding is used', async () => { + const message = 'Lorem ipsum dolor sit amet' + const scope = nock('http://example.test') + .get('/foo') + .reply(200, message, { + 'X-Transfer-Length': String(message.length), + 'Content-Length': undefined, + 'Content-Encoding': 'br', + }) + const response = await fetch('http://example.test/foo') + await response.text().catch(e => { + expect(e.message).to.contain('unexpected end of file') + scope.done() + }) + }) + }) })