diff --git a/HISTORY.md b/HISTORY.md index d0c2c59..80833d7 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,6 +1,7 @@ unreleased ========== * Use `res.headersSent` when available + * add brotli support for versions of node that support it * Add the enforceEncoding option for requests without `Accept-Encoding` header 1.7.5 / 2024-10-31 diff --git a/README.md b/README.md index d32645e..c2a9df6 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 @@ -42,7 +45,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 @@ -99,6 +103,11 @@ 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. +##### brotli + +This specifies the options for configuring Brotli. See [Node.js documentation](https://nodejs.org/api/zlib.html#class-brotlioptions) for a complete list of available options. + + ##### strategy This is used to tune the compression algorithm. This value only affects the diff --git a/index.js b/index.js index dcefd81..198c85b 100644 --- a/index.js +++ b/index.js @@ -30,14 +30,21 @@ 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 */ - var cacheControlNoTransformRegExp = /(?:^|,)\s*?no-transform\s*?(?:,|$)/ +var SUPPORTED_ENCODING = hasBrotliSupport ? ['br', 'gzip', 'deflate', 'identity'] : ['gzip', 'deflate', 'identity'] +var PREFERRED_ENCODING = hasBrotliSupport ? ['br', 'gzip'] : ['gzip'] -var encodingSupported = ['*', 'gzip', 'deflate', 'identity'] +var encodingSupported = ['*', 'gzip', 'deflate', 'identity', 'br'] /** * Compress response data with gzip / deflate. @@ -49,6 +56,17 @@ var encodingSupported = ['*', 'gzip', 'deflate', 'identity'] function compression (options) { var opts = options || {} + var optsBrotli = {} + + if (hasBrotliSupport) { + Object.assign(optsBrotli, opts.brotli) + + var brotliParams = {} + brotliParams[zlib.constants.BROTLI_PARAM_QUALITY] = 4 + + // set the default level to a reasonable value with balanced speed/ratio + optsBrotli.params = Object.assign(brotliParams, optsBrotli.params) + } // options var filter = opts.filter || shouldCompress @@ -178,7 +196,7 @@ function compression (options) { // compression method var negotiator = new Negotiator(req) - var method = negotiator.encoding(['gzip', 'deflate', 'identity'], ['gzip']) + var method = negotiator.encoding(SUPPORTED_ENCODING, PREFERRED_ENCODING) // if no method is found, use the default encoding if (!req.headers['accept-encoding'] && encodingSupported.indexOf(enforceEncoding) !== -1) { @@ -195,7 +213,9 @@ function compression (options) { debug('%s compression', method) stream = method === 'gzip' ? zlib.createGzip(opts) - : zlib.createDeflate(opts) + : method === 'br' + ? zlib.createBrotliCompress(optsBrotli) + : zlib.createDeflate(opts) // add buffered listeners to stream addListeners(stream, stream.on, listeners) diff --git a/test/compression.js b/test/compression.js index 61debb4..c2c1115 100644 --- a/test/compression.js +++ b/test/compression.js @@ -19,6 +19,13 @@ try { var compression = require('..') +/** + * @const + * whether current node version has brotli support + */ +var hasBrotliSupport = 'createBrotliCompress' in zlib +var brotli = hasBrotliSupport ? it : it.skip + describe('compression()', function () { it('should skip HEAD', function (done) { var server = createServer({ threshold: 0 }, function (req, res) { @@ -510,6 +517,52 @@ describe('compression()', function () { }) }) + describe('when "Accept-Encoding: br"', function () { + brotli('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 () { + brotli('should respond with br', function (done) { + var params = {} + params[zlib.constants.BROTLI_PARAM_QUALITY] = 11 + + var server = createServer({ threshold: 0, brotli: { 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) + }) + + brotli('shouldn\'t break compression when gzip is requested', function (done) { + var params = {} + params[zlib.constants.BROTLI_PARAM_QUALITY] = 8 + + var server = createServer({ threshold: 0, brotli: { params: params } }, function (req, res) { + res.setHeader('Content-Type', 'text/plain') + res.end('hello, world') + }) + + request(server) + .get('/') + .set('Accept-Encoding', 'gzip') + .expect('Content-Encoding', 'gzip', done) + }) + }) + describe('when "Accept-Encoding: gzip, deflate"', function () { it('should respond with gzip', function (done) { var server = createServer({ threshold: 0 }, function (req, res) { @@ -538,6 +591,105 @@ describe('compression()', function () { }) }) + describe('when "Accept-Encoding: gzip, br"', function () { + var brotli = hasBrotliSupport ? it : it.skip + brotli('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) + }) + + brotli = hasBrotliSupport ? it.skip : it + + brotli('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', 'br, gzip') + .expect('Content-Encoding', 'gzip', done) + }) + }) + + describe('when "Accept-Encoding: deflate, gzip, br"', function () { + brotli('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 () { + brotli('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 () { + brotli('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 () { + brotli('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 () { + brotli('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) { @@ -676,6 +828,32 @@ describe('compression()', function () { .end() }) + brotli('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 @@ -756,6 +934,19 @@ describe('compression()', function () { .expect(200, 'hello, world', done) }) + brotli('should compress when enforceEncoding is brotli', function (done) { + var server = createServer({ threshold: 0, enforceEncoding: 'br' }, function (req, res) { + res.setHeader('Content-Type', 'text/plain') + res.end('hello, world') + }) + + request(server) + .get('/') + .set('Accept-Encoding', '') + .expect('Content-Encoding', 'br') + .expect(200, done) + }) + it('should not compress when enforceEncoding is unknown', function (done) { var server = createServer({ threshold: 0, enforceEncoding: 'bogus' }, function (req, res) { res.setHeader('Content-Type', 'text/plain') @@ -876,6 +1067,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)