Skip to content

Commit

Permalink
fix: fetch support compressed responses (#2591)
Browse files Browse the repository at this point in the history
  • Loading branch information
mikicho committed Feb 24, 2024
1 parent 11adf2c commit 991a8f3
Show file tree
Hide file tree
Showing 5 changed files with 166 additions and 6 deletions.
2 changes: 1 addition & 1 deletion .eslintrc.yml
Expand Up @@ -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'
Expand Down
48 changes: 45 additions & 3 deletions 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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -17,7 +17,7 @@
"url": "https://github.com/nock/nock/issues"
},
"engines": {
"node": ">= 10.13"
"node": ">= 18"
},
"main": "./index.js",
"types": "types",
Expand Down
118 changes: 118 additions & 0 deletions 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')
Expand Down Expand Up @@ -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()
})
})
})
})

0 comments on commit 991a8f3

Please sign in to comment.