diff --git a/README.md b/README.md index 680ece87..e2ce8c65 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,9 @@ The following compression codings are supported: - deflate - gzip + - br (brotli) + +**Note** Brotli is supported only since Node.js versions v11.7.0 and v10.16.0. ## Install @@ -44,7 +47,8 @@ as compressing will transform the body. `compression()` accepts these properties in the options object. In addition to those listed below, [zlib](http://nodejs.org/api/zlib.html) options may be -passed in to the options object. +passed in to the options object or +[brotli](https://nodejs.org/api/zlib.html#zlib_class_brotlioptions) options. ##### chunkSize @@ -101,6 +105,20 @@ The default value is `zlib.Z_DEFAULT_MEMLEVEL`, or `8`. See [Node.js documentation](http://nodejs.org/api/zlib.html#zlib_memory_usage_tuning) regarding the usage. +##### params *(brotli only)* - [key-value object containing indexed Brotli parameters](https://nodejs.org/api/zlib.html#zlib_brotli_constants) + + - `zlib.constants.BROTLI_PARAM_MODE` + - `zlib.constants.BROTLI_MODE_GENERIC` (default) + - `zlib.constants.BROTLI_MODE_TEXT`, adjusted for UTF-8 text + - `zlib.constants.BROTLI_MODE_FONT`, adjusted for WOFF 2.0 fonts + - `zlib.constants.BROTLI_PARAM_QUALITY` + - Ranges from `zlib.constants.BROTLI_MIN_QUALITY` to + `zlib.constants.BROTLI_MAX_QUALITY`, with a default of + `4` (which is not node's default but the most optimal). + +Note that here the default is set to compression level 4. This is a balanced setting with a very good speed and a very good +compression ratio. + ##### strategy This is used to tune the compression algorithm. This value only affects the diff --git a/index.js b/index.js index 1d089427..8e42b110 100644 --- a/index.js +++ b/index.js @@ -19,6 +19,7 @@ var Buffer = require('safe-buffer').Buffer var bytes = require('bytes') var compressible = require('compressible') var debug = require('debug')('compression') +var objectAssign = require('object-assign') var onHeaders = require('on-headers') var vary = require('vary') var zlib = require('zlib') @@ -37,6 +38,24 @@ module.exports.filter = shouldCompress var cacheControlNoTransformRegExp = /(?:^|,)\s*?no-transform\s*?(?:,|$)/ +/** + * @const + * whether current node version has brotli support + */ +var hasBrotliSupport = 'brotli' in process.versions + +var supportedEncodings = hasBrotliSupport + ? ['gzip', 'deflate', 'br', 'identity'] + : ['gzip', 'deflate', 'identity'] + +var supportedCompressionsNoDeflate = hasBrotliSupport + ? ['gzip', 'br'] + : ['gzip'] + +var supportedEncodingsNoDeflate = hasBrotliSupport + ? ['gzip', 'br', 'identity'] + : ['gzip', 'identity'] + /** * Compress response data with gzip / deflate. * @@ -48,6 +67,19 @@ var cacheControlNoTransformRegExp = /(?:^|,)\s*?no-transform\s*?(?:,|$)/ function compression (options) { var opts = options || {} + if (hasBrotliSupport) { + // set the default level to a reasonable value with balanced speed/ratio + if (opts.params === undefined) { + opts = objectAssign({}, opts) + opts.params = {} + } + + if (opts.params[zlib.constants.BROTLI_PARAM_QUALITY] === undefined) { + opts.params = objectAssign({}, opts.params) + opts.params[zlib.constants.BROTLI_PARAM_QUALITY] = 4 + } + } + // options var filter = opts.filter || shouldCompress var threshold = bytes.parse(opts.threshold) @@ -175,11 +207,11 @@ function compression (options) { // compression method var accept = accepts(req) - var method = accept.encoding(['gzip', 'deflate', 'identity']) + var method = accept.encoding(supportedEncodings) // we really don't prefer deflate - if (method === 'deflate' && accept.encoding(['gzip'])) { - method = accept.encoding(['gzip', 'identity']) + if (method === 'deflate' && accept.encoding(supportedCompressionsNoDeflate)) { + method = accept.encoding(supportedEncodingsNoDeflate) } // negotiation failed @@ -192,7 +224,9 @@ function compression (options) { debug('%s compression', method) stream = method === 'gzip' ? zlib.createGzip(opts) - : zlib.createDeflate(opts) + : method === 'br' + ? zlib.createBrotliCompress(opts) + : zlib.createDeflate(opts) // add buffered listeners to stream addListeners(stream, stream.on, listeners) diff --git a/package.json b/package.json index 30f8422c..58196d81 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "bytes": "3.0.0", "compressible": "~2.0.17", "debug": "2.6.9", + "object-assign": "4.1.1", "on-headers": "~1.0.2", "safe-buffer": "5.2.0", "vary": "~1.1.2" diff --git a/test/compression.js b/test/compression.js index 6975ea0b..1bcb5680 100644 --- a/test/compression.js +++ b/test/compression.js @@ -9,6 +9,12 @@ var zlib = require('zlib') var compression = require('..') +/** + * @const + * whether current node version has brotli support + */ +var hasBrotliSupport = 'brotli' in process.versions + describe('compression()', function () { it('should skip HEAD', function (done) { var server = createServer({ threshold: 0 }, function (req, res) { @@ -465,6 +471,21 @@ describe('compression()', function () { }) }) + describe('when "Accept-Encoding: br"', function () { + var brotlit = hasBrotliSupport ? it : it.skip + brotlit('should respond with br', function (done) { + var server = createServer({ threshold: 0 }, function (req, res) { + res.setHeader('Content-Type', 'text/plain') + res.end('hello, world') + }) + + request(server) + .get('/') + .set('Accept-Encoding', 'br') + .expect('Content-Encoding', 'br', done) + }) + }) + describe('when "Accept-Encoding: gzip, deflate"', function () { it('should respond with gzip', function (done) { var server = createServer({ threshold: 0 }, function (req, res) { @@ -493,6 +514,21 @@ describe('compression()', function () { }) }) + describe('when "Accept-Encoding: deflate, br"', function () { + var brotlit = hasBrotliSupport ? it : it.skip + brotlit('should respond with br', function (done) { + var server = createServer({ threshold: 0 }, function (req, res) { + res.setHeader('Content-Type', 'text/plain') + res.end('hello, world') + }) + + request(server) + .get('/') + .set('Accept-Encoding', 'deflate, br') + .expect('Content-Encoding', 'br', done) + }) + }) + describe('when "Cache-Control: no-transform" response header', function () { it('should not compress response', function (done) { var server = createServer({ threshold: 0 }, function (req, res) { @@ -631,6 +667,33 @@ describe('compression()', function () { .end() }) + var brotlit = hasBrotliSupport ? it : it.skip + brotlit('should flush small chunks for brotli', function (done) { + var chunks = 0 + var next + var server = createServer({ threshold: 0 }, function (req, res) { + next = writeAndFlush(res, 2, Buffer.from('..')) + res.setHeader('Content-Type', 'text/plain') + next() + }) + + function onchunk (chunk) { + assert.ok(chunks++ < 20) + assert.strictEqual(chunk.toString(), '..') + next() + } + + request(server) + .get('/') + .set('Accept-Encoding', 'br') + .request() + .on('response', unchunk('br', onchunk, function (err) { + if (err) return done(err) + server.close(done) + })) + .end() + }) + it('should flush small chunks for deflate', function (done) { var chunks = 0 var next @@ -710,6 +773,9 @@ function unchunk (encoding, onchunk, onend) { case 'gzip': stream = res.pipe(zlib.createGunzip()) break + case 'br': + stream = res.pipe(zlib.createBrotliDecompress()) + break } stream.on('data', onchunk)