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/encoding_negotiator.js b/encoding_negotiator.js new file mode 100644 index 00000000..b3b0c7d0 --- /dev/null +++ b/encoding_negotiator.js @@ -0,0 +1,78 @@ +var zlib = require('zlib') + +/** + * @const + * whether current node version has brotli support + */ +var hasBrotliSupport = 'createBrotliCompress' in zlib + +var supportedEncodings = hasBrotliSupport + ? ['br', 'gzip', 'deflate', 'identity'] + : ['gzip', 'deflate', 'identity'] + +var preferredEncodings = hasBrotliSupport + ? ['br', 'gzip'] + : ['gzip'] + +function negotiateEncoding (header) { + header = header || '' + + var insts = header.split(',') + var decoded = [] + + for (var i = 0; i < insts.length; i++) { + var inst = insts[i].match(/^\s*?([^\s;]+?)\s*?(?:;(.*))?$/) + if (!inst) continue + + var encoding = inst[1] + if (supportedEncodings.indexOf(encoding) === -1) { + continue + } + + var q = 1 + if (inst[2]) { + var params = inst[2].split(';') + for (var j = 0; j < params.length; j++) { + var p = params[j].trim().split('=') + if (p[0] === 'q') { + q = parseFloat(p[1]) + break + } + } + } + + if (q < 0 || q > 1) { // invalid + continue + } + + decoded.push({ encoding: encoding, q: q, i: i }) + } + + decoded.sort((a, b) => { + if (a.q !== b.q) { + return b.q - a.q // higher quality first + } + + var aPreferred = preferredEncodings.indexOf(a.encoding) + var bPreferred = preferredEncodings.indexOf(b.encoding) + + if (aPreferred === -1 && bPreferred === -1) { + return a.i - b.i // consider the original order + } + + if (aPreferred !== -1 && bPreferred !== -1) { + return aPreferred - bPreferred // consider the preferred order + } + + return aPreferred === -1 ? 1 : -1 // preferred first + }) + + if (decoded.length > 0) { + return decoded[0].encoding + } + + return null +} + +module.exports.hasBrotliSupport = hasBrotliSupport +module.exports.negotiateEncoding = negotiateEncoding diff --git a/index.js b/index.js index 1d089427..1eb9ac79 100644 --- a/index.js +++ b/index.js @@ -14,14 +14,16 @@ * @private */ -var accepts = require('accepts') 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') +var hasBrotliSupport = require('./encoding_negotiator').hasBrotliSupport +var negotiateEncoding = require('./encoding_negotiator').negotiateEncoding /** * Module exports. @@ -48,6 +50,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) @@ -174,16 +189,10 @@ function compression (options) { } // compression method - var accept = accepts(req) - var method = accept.encoding(['gzip', 'deflate', 'identity']) - - // we really don't prefer deflate - if (method === 'deflate' && accept.encoding(['gzip'])) { - method = accept.encoding(['gzip', 'identity']) - } + var method = negotiateEncoding(req.headers['accept-encoding']) || 'identity' // negotiation failed - if (!method || method === 'identity') { + if (method === 'identity') { nocompress('not acceptable') return } @@ -192,7 +201,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..a202e9a7 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,10 @@ "license": "MIT", "repository": "expressjs/compression", "dependencies": { - "accepts": "~1.3.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..028aef05 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 = 'createBrotliCompress' in zlib + describe('compression()', function () { it('should skip HEAD', function (done) { var server = createServer({ threshold: 0 }, function (req, res) { @@ -465,6 +471,39 @@ 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: 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, deflate"', function () { it('should respond with gzip', function (done) { var server = createServer({ threshold: 0 }, function (req, res) { @@ -493,6 +532,96 @@ describe('compression()', function () { }) }) + 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) + }) + }) + 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 +760,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 +866,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)