Skip to content

Commit 99d0e4d

Browse files
SvjardEomm
authored andcommitted
feature: per route compression (#92)
1 parent b2a1995 commit 99d0e4d

File tree

6 files changed

+439
-84
lines changed

6 files changed

+439
-84
lines changed

README.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,33 @@ fastify.register(
3737
```
3838
Remember that thanks to the Fastify encapsulation model, you can set a global compression, but run it only in a subset of routes if you wrap them inside a plugin.
3939

40+
### Per Route options
41+
You can specify different options for compression per route by passing in the compression options on the route's configuration.
42+
```javascript
43+
fastify.register(
44+
require('fastify-compress'),
45+
{ global: false }
46+
)
47+
48+
// only compress if the payload is above a certain size and use brotli
49+
fastify.get('/custom-route', {
50+
config: {
51+
compress: {
52+
threshold: 128
53+
brotli: brotli
54+
}
55+
}
56+
}, (req, reply) => {
57+
// ...
58+
})
59+
```
60+
61+
Note: Setting `config.compress = false` on any route will disable compression on the route even if global compression is enabled.
62+
4063
### `reply.compress`
41-
This plugin adds a `compress` method to `reply` that accepts a stream or a string, and compresses it based on the `accept-encoding` header. If a JS object is passed in, it will be stringified to JSON.
64+
This plugin adds a `compress` method to `reply` that accepts a stream or a string, and compresses it based on the `accept-encoding` header. If a JS object is passed in, it will be stringified to JSON.
65+
Note that the compress method is configured with either the per route parameters if the route has a custom configuration or with the global parameters if the the route has no custom parameters but
66+
the plugin was defined as global.
4267

4368
```javascript
4469
const fs = require('fs')
@@ -57,7 +82,9 @@ fastify.listen(3000, function (err) {
5782
console.log(`server listening on ${fastify.server.address().port}`)
5883
})
5984
```
85+
6086
## Options
87+
6188
### Threshold
6289
The minimum byte size for a response to be compressed. Defaults to `1024`.
6390
```javascript

index.d.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,8 @@ import { Stream } from 'stream';
44

55
type EncodingToken = 'br' | 'deflate' | 'gzip' | 'identity'
66

7-
declare const fastifyCompress: Plugin<
8-
Server,
9-
IncomingMessage,
10-
ServerResponse,
11-
{
7+
declare namespace fastifyCompress {
8+
interface FastifyCompressOptions {
129
global?: boolean
1310
threshold?: number
1411
customTypes?: RegExp
@@ -18,6 +15,13 @@ declare const fastifyCompress: Plugin<
1815
onUnsupportedEncoding?: (encoding: string, request: FastifyRequest<ServerResponse>, reply: FastifyReply<ServerResponse>) => string | Buffer | Stream
1916
encodings?: Array<EncodingToken>
2017
}
18+
}
19+
20+
declare const fastifyCompress: Plugin<
21+
Server,
22+
IncomingMessage,
23+
ServerResponse,
24+
fastifyCompress.FastifyCompressOptions
2125
>
2226

2327
export = fastifyCompress

index.js

Lines changed: 143 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -16,159 +16,226 @@ const isDeflate = require('is-deflate')
1616
const encodingNegotiator = require('encoding-negotiator')
1717

1818
function compressPlugin (fastify, opts, next) {
19-
fastify.decorateReply('compress', compress)
19+
const globalParams = processParams(opts)
2020

21-
if (opts.global !== false) {
22-
fastify.addHook('onSend', onSend)
21+
if (opts.encodings && opts.encodings.length < 1) {
22+
next(new Error('The `encodings` option array must have at least 1 item.'))
23+
return
24+
}
25+
26+
if (globalParams.encodings.length < 1) {
27+
next(new Error('None of the passed `encodings` were supported — compression not possible.'))
28+
return
29+
}
30+
31+
fastify.decorateReply('compress', null)
32+
33+
// add onSend hook onto each route as needed
34+
fastify.addHook('onRoute', (routeOptions) => {
35+
if (routeOptions.config && typeof routeOptions.config.compress !== 'undefined') {
36+
if (typeof routeOptions.config.compress === 'object') {
37+
const mergedCompressParams = Object.assign({}, globalParams, processParams(routeOptions.config.compress))
38+
// if the current endpoint has a custom compress configuration ...
39+
buildRouteCompress(fastify, mergedCompressParams, routeOptions)
40+
} else if (routeOptions.config.compress === false) {
41+
// don't apply any compress settings
42+
} else {
43+
throw new Error('Unknown value for route compress configuration')
44+
}
45+
} else if (globalParams.global) {
46+
// if the plugin is set globally ( meaning that all the routes will be compressed )
47+
// As the endpoint, does not have a custom rateLimit configuration, use the global one.
48+
buildRouteCompress(fastify, globalParams, routeOptions)
49+
} else {
50+
// if no options are specified and the plugin is not global, then we still want to decorate
51+
// the reply in this case
52+
buildRouteCompress(fastify, globalParams, routeOptions, true)
53+
}
54+
})
55+
56+
next()
57+
}
58+
59+
function processParams (opts) {
60+
if (!opts) {
61+
return
2362
}
2463

25-
const onUnsupportedEncoding = opts.onUnsupportedEncoding
26-
const inflateIfDeflated = opts.inflateIfDeflated === true
27-
const threshold = typeof opts.threshold === 'number' ? opts.threshold : 1024
28-
const compressibleTypes = opts.customTypes instanceof RegExp ? opts.customTypes : /^text\/|\+json$|\+text$|\+xml$|octet-stream$/
29-
const compressStream = {
64+
const params = {
65+
global: (typeof opts.global === 'boolean') ? opts.global : true
66+
}
67+
68+
params.onUnsupportedEncoding = opts.onUnsupportedEncoding
69+
params.inflateIfDeflated = opts.inflateIfDeflated === true
70+
params.threshold = typeof opts.threshold === 'number' ? opts.threshold : 1024
71+
params.compressibleTypes = opts.customTypes instanceof RegExp ? opts.customTypes : /^text\/|\+json$|\+text$|\+xml$|octet-stream$/
72+
params.compressStream = {
3073
gzip: (opts.zlib || zlib).createGzip || zlib.createGzip,
3174
deflate: (opts.zlib || zlib).createDeflate || zlib.createDeflate
3275
}
33-
const uncompressStream = {
76+
params.uncompressStream = {
3477
gzip: (opts.zlib || zlib).createGunzip || zlib.createGunzip,
3578
deflate: (opts.zlib || zlib).createInflate || zlib.createInflate
3679
}
3780

3881
const supportedEncodings = ['gzip', 'deflate', 'identity']
3982
if (opts.brotli) {
40-
compressStream.br = opts.brotli.compressStream
83+
params.compressStream.br = opts.brotli.compressStream
4184
supportedEncodings.unshift('br')
4285
} else if (zlib.createBrotliCompress) {
43-
compressStream.br = zlib.createBrotliCompress
86+
params.compressStream.br = zlib.createBrotliCompress
4487
supportedEncodings.unshift('br')
4588
}
4689

47-
if (opts.encodings && opts.encodings.length < 1) {
48-
next(new Error('The `encodings` option array must have at least 1 item.'))
49-
}
50-
51-
const encodings = Array.isArray(opts.encodings)
90+
params.encodings = Array.isArray(opts.encodings)
5291
? supportedEncodings
5392
.filter(encoding => opts.encodings.includes(encoding))
5493
.sort((a, b) => opts.encodings.indexOf(a) - supportedEncodings.indexOf(b))
5594
: supportedEncodings
5695

57-
if (encodings.length < 1) {
58-
next(new Error('None of the passed `encodings` were supported — compression not possible.'))
96+
return params
97+
}
98+
99+
function buildRouteCompress (fastify, params, routeOptions, decorateOnly) {
100+
// In order to provide a compress method with the same parameter set as the route itself has
101+
// we do the decorate the reply at the start of the request
102+
if (Array.isArray(routeOptions.onRequest)) {
103+
routeOptions.onRequest.push(onRequest)
104+
} else if (typeof routeOptions.onRequest === 'function') {
105+
routeOptions.onRequest = [routeOptions.onRequest, onRequest]
106+
} else {
107+
routeOptions.onRequest = [onRequest]
59108
}
60109

61-
next()
110+
const compressFn = compress(params)
111+
function onRequest (req, reply, next) {
112+
reply.compress = compressFn
113+
next()
114+
}
115+
116+
if (decorateOnly) {
117+
return
118+
}
62119

63-
function compress (payload) {
120+
if (Array.isArray(routeOptions.onSend)) {
121+
routeOptions.onSend.push(onSend)
122+
} else if (typeof routeOptions.onSend === 'function') {
123+
routeOptions.onSend = [routeOptions.onSend, onSend]
124+
} else {
125+
routeOptions.onSend = [onSend]
126+
}
127+
128+
function onSend (req, reply, payload, next) {
64129
if (payload == null) {
65-
this.res.log.debug('compress: missing payload')
66-
this.send(new Error('Internal server error'))
67-
return
130+
reply.res.log.debug('compress: missing payload')
131+
return next()
68132
}
69133

70134
var stream, encoding
71135
var noCompress =
72136
// don't compress on x-no-compression header
73-
(this.request.headers['x-no-compression'] !== undefined) ||
137+
(req.headers['x-no-compression'] !== undefined) ||
74138
// don't compress if not one of the indicated compressible types
75-
(shouldCompress(this.getHeader('Content-Type') || 'application/json', compressibleTypes) === false) ||
139+
(shouldCompress(reply.getHeader('Content-Type') || 'application/json', params.compressibleTypes) === false) ||
76140
// don't compress on missing or identity `accept-encoding` header
77-
((encoding = getEncodingHeader(encodings, this.request)) == null || encoding === 'identity')
141+
((encoding = getEncodingHeader(params.encodings, req)) == null || encoding === 'identity')
78142

79-
if (encoding == null && onUnsupportedEncoding != null) {
80-
var encodingHeader = this.request.headers['accept-encoding']
81-
82-
var errorPayload
143+
if (encoding == null && params.onUnsupportedEncoding != null) {
144+
var encodingHeader = req.headers['accept-encoding']
83145
try {
84-
errorPayload = onUnsupportedEncoding(encodingHeader, this.request, this)
85-
} catch (ex) {
86-
errorPayload = ex
146+
var errorPayload = params.onUnsupportedEncoding(encodingHeader, reply.request, reply)
147+
return next(null, errorPayload)
148+
} catch (err) {
149+
return next(err)
87150
}
88-
return this.send(errorPayload)
89151
}
90152

91153
if (noCompress) {
92-
if (inflateIfDeflated && isStream(stream = maybeUnzip(payload, this.serialize.bind(this)))) {
154+
if (params.inflateIfDeflated && isStream(stream = maybeUnzip(payload))) {
93155
encoding === undefined
94-
? this.removeHeader('Content-Encoding')
95-
: this.header('Content-Encoding', 'identity')
96-
pump(stream, payload = unzipStream(uncompressStream), onEnd.bind(this))
97-
}
98-
return this.send(payload)
99-
}
100-
101-
if (typeof payload.pipe !== 'function') {
102-
if (!Buffer.isBuffer(payload) && typeof payload !== 'string') {
103-
payload = this.serialize(payload)
156+
? reply.removeHeader('Content-Encoding')
157+
: reply.header('Content-Encoding', 'identity')
158+
pump(stream, payload = unzipStream(params.uncompressStream), onEnd.bind(reply))
104159
}
160+
return next(null, payload)
105161
}
106162

107163
if (typeof payload.pipe !== 'function') {
108-
if (Buffer.byteLength(payload) < threshold) {
109-
return this.send(payload)
164+
if (Buffer.byteLength(payload) < params.threshold) {
165+
return next()
110166
}
111167
payload = intoStream(payload)
112168
}
113169

114-
this
170+
reply
115171
.header('Content-Encoding', encoding)
116172
.removeHeader('content-length')
117173

118-
stream = zipStream(compressStream, encoding)
119-
pump(payload, stream, onEnd.bind(this))
120-
this.send(stream)
174+
stream = zipStream(params.compressStream, encoding)
175+
pump(payload, stream, onEnd.bind(reply))
176+
next(null, stream)
121177
}
178+
}
122179

123-
function onSend (req, reply, payload, next) {
180+
function compress (params) {
181+
return function (payload) {
124182
if (payload == null) {
125-
reply.res.log.debug('compress: missing payload')
126-
return next()
183+
this.res.log.debug('compress: missing payload')
184+
this.send(new Error('Internal server error'))
185+
return
127186
}
128187

129188
var stream, encoding
130189
var noCompress =
131190
// don't compress on x-no-compression header
132-
(req.headers['x-no-compression'] !== undefined) ||
133-
// don't compress if not one of the indiated compressible types
134-
(shouldCompress(reply.getHeader('Content-Type') || 'application/json', compressibleTypes) === false) ||
191+
(this.request.headers['x-no-compression'] !== undefined) ||
192+
// don't compress if not one of the indicated compressible types
193+
(shouldCompress(this.getHeader('Content-Type') || 'application/json', params.compressibleTypes) === false) ||
135194
// don't compress on missing or identity `accept-encoding` header
136-
((encoding = getEncodingHeader(encodings, req)) == null || encoding === 'identity')
195+
((encoding = getEncodingHeader(params.encodings, this.request)) == null || encoding === 'identity')
137196

138-
if (encoding == null && onUnsupportedEncoding != null) {
139-
var encodingHeader = req.headers['accept-encoding']
197+
if (encoding == null && params.onUnsupportedEncoding != null) {
198+
var encodingHeader = this.request.headers['accept-encoding']
199+
200+
var errorPayload
140201
try {
141-
var errorPayload = onUnsupportedEncoding(encodingHeader, reply.request, reply)
142-
return next(null, errorPayload)
143-
} catch (err) {
144-
return next(err)
202+
errorPayload = params.onUnsupportedEncoding(encodingHeader, this.request, this)
203+
} catch (ex) {
204+
errorPayload = ex
145205
}
206+
return this.send(errorPayload)
146207
}
147208

148209
if (noCompress) {
149-
if (inflateIfDeflated && isStream(stream = maybeUnzip(payload))) {
210+
if (params.inflateIfDeflated && isStream(stream = maybeUnzip(payload, this.serialize.bind(this)))) {
150211
encoding === undefined
151-
? reply.removeHeader('Content-Encoding')
152-
: reply.header('Content-Encoding', 'identity')
153-
pump(stream, payload = unzipStream(uncompressStream), onEnd.bind(reply))
212+
? this.removeHeader('Content-Encoding')
213+
: this.header('Content-Encoding', 'identity')
214+
pump(stream, payload = unzipStream(params.uncompressStream), onEnd.bind(this))
154215
}
155-
return next(null, payload)
216+
return this.send(payload)
156217
}
157218

158219
if (typeof payload.pipe !== 'function') {
159-
if (Buffer.byteLength(payload) < threshold) {
160-
return next()
220+
if (!Buffer.isBuffer(payload) && typeof payload !== 'string') {
221+
payload = this.serialize(payload)
222+
}
223+
}
224+
225+
if (typeof payload.pipe !== 'function') {
226+
if (Buffer.byteLength(payload) < params.threshold) {
227+
return this.send(payload)
161228
}
162229
payload = intoStream(payload)
163230
}
164231

165-
reply
232+
this
166233
.header('Content-Encoding', encoding)
167234
.removeHeader('content-length')
168235

169-
stream = zipStream(compressStream, encoding)
170-
pump(payload, stream, onEnd.bind(reply))
171-
next(null, stream)
236+
stream = zipStream(params.compressStream, encoding)
237+
pump(payload, stream, onEnd.bind(this))
238+
this.send(stream)
172239
}
173240
}
174241

@@ -247,6 +314,6 @@ function unzipStream (inflate, maxRecursion) {
247314
}
248315

249316
module.exports = fp(compressPlugin, {
250-
fastify: '>=1.3.0',
317+
fastify: '>=2.11.0',
251318
name: 'fastify-compress'
252319
})

0 commit comments

Comments
 (0)