Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support for brotli ('br') content-encoding #172

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions encoding_negotiator.js
Original file line number Diff line number Diff line change
@@ -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
31 changes: 21 additions & 10 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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
}
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
159 changes: 159 additions & 0 deletions test/compression.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down