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..49066e53 100644 --- a/index.js +++ b/index.js @@ -22,6 +22,7 @@ var debug = require('debug')('compression') var onHeaders = require('on-headers') var vary = require('vary') var zlib = require('zlib') +var objectAssign = require('object-assign') /** * Module exports. @@ -30,6 +31,12 @@ var zlib = require('zlib') module.exports = compression module.exports.filter = shouldCompress +/** + * @const + * whether current node version has brotli support + */ +var hasBrotliSupport = 'createBrotliCompress' in zlib + /** * Module variables. * @private @@ -48,6 +55,17 @@ 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.params = {} + } + + if (opts.params[zlib.constants.BROTLI_PARAM_QUALITY] === undefined) { + opts.params[zlib.constants.BROTLI_PARAM_QUALITY] = 4 + } + } + // options var filter = opts.filter || shouldCompress var threshold = bytes.parse(opts.threshold) @@ -173,14 +191,12 @@ function compression (options) { return } - // compression method - var accept = accepts(req) - var method = accept.encoding(['gzip', 'deflate', 'identity']) + // force proper priorization + var headers = objectAssign({}, req.headers, { 'accept-encoding': prioritize(req.headers['accept-encoding']) }) - // we really don't prefer deflate - if (method === 'deflate' && accept.encoding(['gzip'])) { - method = accept.encoding(['gzip', 'identity']) - } + // compression method + var accept = accepts(objectAssign({}, res, { headers: headers })) + var method = accept.encoding(['br', 'gzip', 'deflate', 'identity']) // negotiation failed if (!method || method === 'identity') { @@ -192,7 +208,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) @@ -286,3 +304,47 @@ function toBuffer (chunk, encoding) { ? Buffer.from(chunk, encoding) : chunk } + +/** + * Most browsers send "br" (brolti) as the last value in + * in the 'Accept-Encoding' header which causes it to be + * depriorited according to the spec. + * + * This is typically not what end users actually want so here + * we force the "br" (brotli) value to first in the list so that + * it will get properly prioritized and used. + * + * It's worth noting that although this is not "spec compliant", + * we belive it follows a well-established convention. + * + * @private + */ +function prioritize (str) { + return str && str.split(',') + .sort(sortAlgs) + .join(',') +} + +/** + * Sort compression algs in order of preference: + * br > gzip > deflate | identity + * + * @private + */ +function sortAlgs (a, b) { + if (a.indexOf('br') >= 0) { + return -1 + } + if (a.indexOf('gzip') >= 0) { + return b.indexOf('br') >= 0 ? 1 : -1 + } + // we need these inverse rules to fix a stable sort bug + // found in node 10.x + if (b.indexOf('br') >= 0) { + return 1 + } + if (b.indexOf('gzip') >= 0) { + return a.indexOf('br') >= 0 ? -1 : 1 + } + return 0 +} 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..5a301442 100644 --- a/test/compression.js +++ b/test/compression.js @@ -9,6 +9,8 @@ var zlib = require('zlib') var compression = require('..') +var hasBrotliSupport = 'createBrotliCompress' in zlib + describe('compression()', function () { it('should skip HEAD', function (done) { var server = createServer({ threshold: 0 }, function (req, res) { @@ -656,6 +658,156 @@ 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() + }) + }) + + 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: br" and passing compression level', function () { + var brotlit = hasBrotliSupport ? it : it.skip + brotlit('should respond with br', function (done) { + var params = {} + params[zlib.constants.BROTLI_PARAM_QUALITY] = 11 + + var server = createServer({ threshold: 0, params: params }, 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, 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', 'gzip, br') + .expect('Content-Encoding', 'br', done) + }) + }) + + describe('when "Accept-Encoding: deflate, gzip, 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, gzip, br') + .expect('Content-Encoding', 'br', done) + }) + }) + + describe('when "Accept-Encoding: gzip;q=1, br;q=0.3"', function () { + var brotlit = hasBrotliSupport ? it : it.skip + brotlit('should respond with gzip', 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', 'gzip;q=1, br;q=0.3') + .expect('Content-Encoding', 'gzip', done) + }) + }) + + describe('when "Accept-Encoding: gzip, br;q=0.8"', function () { + var brotlit = hasBrotliSupport ? it : it.skip + brotlit('should respond with gzip', 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', 'gzip, br;q=0.8') + .expect('Content-Encoding', 'gzip', done) + }) + }) + + describe('when "Accept-Encoding: gzip;q=0.001"', function () { + var brotlit = hasBrotliSupport ? it : it.skip + brotlit('should respond with gzip', 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', 'gzip;q=0.001') + .expect('Content-Encoding', 'gzip', done) + }) + }) + + 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) + }) }) }) @@ -710,6 +862,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)