Repository: nock/nock
Pull request #2591 (2024-02-24 15:06:13)

fix: fetch support compressed responses
fixes https://github.com/nock/nock/issues/2397#issuecomment-1961177318

This is a pretty naive solution that doesn't support more decoders easily (or multiple decoders at all), but I'm not expecting it in streams and such. 
Improvements are welcome. 

mikicho: @Uzlopak I can add support for deflate and br(?), but in this ugly(?) if/else style, it would be complex to support all combinations (it's possible to have more than one compression)
Do you have any ideas on how we can use streams? I tried to do something like:
```js
const decoder = []
const codings = message.headers['content-encoding'].split(',')
for (const coding in codings) {
  if (coding === 'gzip') decoder.push(zlib.createGunzip())
  if (coding === 'deflate') decoder.push(..)
}

new ReadableStream({
  start(controller) {
    message.on('data', chunk => pipeline(Readable.from(chunk), ...decoders. () => {})
  }
})
```

But I got an error. I'm not an expert in streams, and I'm clearly missing something. 
Uzlopak: Maybe here you can get some clues how we do it in undici.

https://github.com/nodejs/undici/pull/2650
mikicho: @Uzlopak Thanks! I succeed to make it work. 
I had to pile up the chunks because it was cleaner(?) then create promises and await for them to complete.

Patch:
diff --git a/.eslintrc.yml b/.eslintrc.yml
index 7e44114..f482cdc 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 3952d4a..cf9f3d9 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 36a6363..e5d901e 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 5352e88..a5fadd5 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",


Test patch:
diff --git a/tests/test_fetch.js b/tests/test_fetch.js
index 25d0b6c..4c1ca90 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()
+      })
+    })
+  })
 })