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

Functional and local options #122

Open
wants to merge 4 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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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\<String|Number\>
### options.threshold\<String|Number|Function\>

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]\<Object\>
### options[encoding]\<Object|Function\>

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<span></span>.br

[Brotli compression](https://en.wikipedia.org/wiki/Brotli) is supported in node v11.7.0+, which includes it natively.
Expand All @@ -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` &mdash; the same as `ctx.response.type` (provided for convenience)
* `size` &mdash; the same as `ctx.response.length` (provided for convenience)
* `ctx` &mdash; 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
120 changes: 120 additions & 0 deletions __tests__/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
})
30 changes: 20 additions & 10 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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
Expand All @@ -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()

Expand All @@ -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)
Expand Down