diff --git a/README.md b/README.md index e7f11bc..fbde2b2 100644 --- a/README.md +++ b/README.md @@ -40,17 +40,19 @@ function (mimeType: string): Boolean { An optional function that checks the response content type to decide whether to compress. By default, it uses [compressible](https://github.com/jshttp/compressible). -### options.threshold\ +### options.threshold\ -Minimum response size in bytes to compress. +Minimum response size in bytes to compress or a function that returns such response (see below). Default `1024` bytes or `1kb`. -### options[encoding]\ +### options[encoding]\ The current encodings are, in order of preference: `br`, `gzip`, `deflate`. Setting `options[encoding] = {}` will pass those options to the encoding function. Setting `options[encoding] = false` will disable that encoding. +It can be a function that returns options (see below). + #### options.br [Brotli compression](https://en.wikipedia.org/wiki/Brotli) is supported in node v11.7.0+, which includes it natively. @@ -77,3 +79,33 @@ app.use((ctx, next) => { ctx.body = fs.createReadStream(file) }) ``` + +`ctx.compress` can be an object similar to `options` above, whose properties (`threshold` and encoding options) +override the global `options` for this response and bypass the filter check. + +## Functional properties + +Certain properties (`threshold` and encoding options) can be specified as functions. Such functions will be called +for every response with three arguments: + +* `type` — the same as `ctx.response.type` (provided for convenience) +* `size` — the same as `ctx.response.length` (provided for convenience) +* `ctx` — the whole context object, if you want to do something unique + +It should return a valid value for that property. It is possible to return a function of the same shape, +which will be used to calculate the actual property. + +Example: + +```js +app.use((ctx, next) => { + // ... + ctx.compress = (type, size, ctx) => ({ + br: size && size >= 65536, + gzip: size && size < 65536 + }) + ctx.body = payload +}) +``` + +Read all about `ctx` in https://koajs.com/#context and `ctx.response` in https://koajs.com/#response diff --git a/__tests__/index.js b/__tests__/index.js index 4ab01dd..35bb314 100644 --- a/__tests__/index.js +++ b/__tests__/index.js @@ -498,4 +498,124 @@ describe('Compress', () => { done() }) }) + + it('functional threshold: should not compress', (done) => { + const app = new Koa() + + app.use(compress({ + threshold: () => '1mb' + })) + app.use(sendString) + server = app.listen() + + request(server) + .get('/') + .expect(200) + .end((err, res) => { + if (err) { return done(err) } + + assert.equal(res.headers['content-length'], '2048') + assert.equal(res.headers.vary, 'Accept-Encoding') + assert(!res.headers['content-encoding']) + assert(!res.headers['transfer-encoding']) + assert.equal(res.text, string) + + done() + }) + }) + + it('functional compressors: should not compress', (done) => { + const app = new Koa() + + app.use(compress({ br: false, gzip: (type, size) => /^text\//i.test(type) && size > 1000000 })) + app.use(sendBuffer) + server = app.listen() + + request(server) + .get('/') + .set('Accept-Encoding', 'br, gzip') + .expect(200) + .end((err, res) => { + if (err) { return done(err) } + + assert.equal(res.headers.vary, 'Accept-Encoding') + assert(!res.headers['content-encoding']) + + done() + }) + }) + + it('functional compressors: should compress with gzip', (done) => { + const app = new Koa() + + app.use(compress({ br: false, gzip: false })) + app.use((ctx) => { + ctx.compress = { gzip: (type, size) => size < 1000000 } + ctx.body = string + }) + server = app.listen() + + request(server) + .get('/') + .set('Accept-Encoding', 'br, gzip') + .expect(200) + .end((err, res) => { + if (err) { return done(err) } + + assert.equal(res.headers.vary, 'Accept-Encoding') + assert.equal(res.headers['content-encoding'], 'gzip') + + done() + }) + }) + + it('functional compressors: should NOT compress with gzip', (done) => { + const app = new Koa() + + app.use(compress({ br: false, gzip: false })) + app.use((ctx) => { + ctx.compress = { gzip: (type, size) => size < 100 } + ctx.body = string + }) + server = app.listen() + + request(server) + .get('/') + .set('Accept-Encoding', 'br, gzip') + .expect(200) + .end((err, res) => { + if (err) { return done(err) } + + assert.equal(res.headers.vary, 'Accept-Encoding') + assert(!res.headers['content-encoding']) + + done() + }) + }) + + it('functional compressors: should compress with br', (done) => { + if (!process.versions.brotli) return done() + + const app = new Koa() + + app.use(compress({ br: false, gzip: false })) + app.use((ctx) => { + ctx.compress = { gzip: () => false, br: () => true } + ctx.body = string + }) + server = app.listen() + + request(server) + .get('/') + .set('Accept-Encoding', 'br, gzip') + .expect(200) + .end((err, res) => { + if (err) { return done(err) } + + assert.equal(res.headers.vary, 'Accept-Encoding') + assert.equal(res.headers['content-encoding'], 'br') + + done() + }) + }) }) diff --git a/lib/index.js b/lib/index.js index 1849e90..5738791 100644 --- a/lib/index.js +++ b/lib/index.js @@ -26,11 +26,8 @@ const NO_TRANSFORM_REGEX = /(?:^|,)\s*?no-transform\s*?(?:,|$)/ */ module.exports = (options = {}) => { - let { filter = compressible, threshold = 1024, defaultEncoding = 'identity' } = options - if (typeof threshold === 'string') threshold = bytes(threshold) - - // `options.br = false` would remove it as a preferred encoding - const preferredEncodings = Encodings.preferredEncodings.filter((encoding) => options[encoding] !== false && options[encoding] !== null) + // "global" options + const { filter = compressible, defaultEncoding = 'identity' } = options return async (ctx, next) => { ctx.vary('Accept-Encoding') @@ -39,6 +36,7 @@ module.exports = (options = {}) => { // early exit if there's no content body or the body is already encoded let { body } = ctx + const { type, length: size } = ctx.response if (!body) return if (ctx.res.headersSent || !ctx.writable) return if (ctx.compress === false) return @@ -47,20 +45,32 @@ module.exports = (options = {}) => { if (ctx.response.get('Content-Encoding')) return // forced compression or implied - if (!(ctx.compress === true || filter(ctx.response.type))) return + if (!(ctx.compress || filter(type))) return // don't compress for Cache-Control: no-transform // https://tools.ietf.org/html/rfc7234#section-5.2.1.6 const cacheControl = ctx.response.get('Cache-Control') if (cacheControl && NO_TRANSFORM_REGEX.test(cacheControl)) return + // calculate "local" compression options + const responseOptions = { ...options, ...ctx.compress } + let { threshold = 1024 } = responseOptions + while (typeof threshold === 'function') threshold = threshold(type, size, ctx) + if (typeof threshold === 'string') threshold = bytes(threshold) + // don't compress if the current response is below the threshold - if (threshold && ctx.response.length < threshold) return + if (threshold && size < threshold) return // get the preferred content encoding - const encodings = new Encodings({ - preferredEncodings + Encodings.preferredEncodings.forEach((encoding) => { + // calculate compressor options, if any + if (!(encoding in responseOptions)) return + let compressor = responseOptions[encoding] + while (typeof compressor === 'function') compressor = compressor(type, size, ctx) + responseOptions[encoding] = compressor }) + const preferredEncodings = Encodings.preferredEncodings.filter((encoding) => responseOptions[encoding] !== false && responseOptions[encoding] !== null) + const encodings = new Encodings({ preferredEncodings }) encodings.parseAcceptEncoding(ctx.request.headers['accept-encoding'] || defaultEncoding) const encoding = encodings.getPreferredContentEncoding() @@ -76,7 +86,7 @@ module.exports = (options = {}) => { ctx.res.removeHeader('Content-Length') const compress = Encodings.encodingMethods[encoding] - const stream = ctx.body = compress(options[encoding]) + const stream = ctx.body = compress(responseOptions[encoding]) if (body instanceof Stream) { body.pipe(stream)