@fastify/compress 9.1.1 → 9.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.c8rc +1 -1
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lock-threads.yml +1 -1
- package/.github/workflows/release.yml +24 -0
- package/README.md +85 -1
- package/benchmark/sync-compression.mjs +282 -0
- package/index.js +135 -21
- package/lib/utils.js +42 -2
- package/package.json +3 -4
- package/test/global-compress.test.js +161 -2
- package/test/regression/issue-350.test.js +3 -1
- package/test/routes-compress.test.js +26 -0
- package/test/sync-compress.test.js +741 -0
- package/test/utils.test.js +35 -1
- package/types/index.d.ts +2 -0
- package/types/index.tst.ts +3 -1
package/index.js
CHANGED
|
@@ -2,15 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
const zlib = require('node:zlib')
|
|
4
4
|
const { inherits, format } = require('node:util')
|
|
5
|
-
const { pipeline, compose } = require('node:stream')
|
|
5
|
+
const { pipeline, compose, Readable, PassThrough } = require('node:stream')
|
|
6
6
|
|
|
7
7
|
const fp = require('fastify-plugin')
|
|
8
8
|
const encodingNegotiator = require('@fastify/accept-negotiator')
|
|
9
9
|
const mimedb = require('mime-db')
|
|
10
|
-
const { Minipass } = require('minipass')
|
|
11
|
-
const { Readable } = require('readable-stream')
|
|
12
10
|
|
|
13
11
|
const {
|
|
12
|
+
computeSyncThreshold,
|
|
14
13
|
isStream,
|
|
15
14
|
isGzip,
|
|
16
15
|
isDeflate,
|
|
@@ -22,6 +21,10 @@ const {
|
|
|
22
21
|
createPeekTransform
|
|
23
22
|
} = require('./lib/utils')
|
|
24
23
|
|
|
24
|
+
// The default depends on how much parallelism the host offers, which cannot change while
|
|
25
|
+
// the process runs, so it is computed once here rather than per registration or per request
|
|
26
|
+
const defaultSyncThreshold = computeSyncThreshold()
|
|
27
|
+
|
|
25
28
|
const InvalidRequestEncodingError = createError('FST_CP_ERR_INVALID_CONTENT_ENCODING', 'Unsupported Content-Encoding: %s', 415)
|
|
26
29
|
const InvalidRequestCompressedPayloadError = createError('FST_CP_ERR_INVALID_CONTENT', 'Could not decompress the request payload using the provided encoding', 400)
|
|
27
30
|
|
|
@@ -128,13 +131,20 @@ function processCompressParams (opts) {
|
|
|
128
131
|
}
|
|
129
132
|
|
|
130
133
|
params.removeContentLengthHeader = typeof opts.removeContentLengthHeader === 'boolean' ? opts.removeContentLengthHeader : true
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
+
// The recommended defaults apply whatever the value of `global` is: with
|
|
135
|
+
// `global: false` the compression still runs, just through `reply.compress()`,
|
|
136
|
+
// and falling back to zlib's default quality of 11 there is needlessly slow.
|
|
137
|
+
params.brotliOptions = { ...recommendedDefaultBrotliOptions, ...opts.brotliOptions }
|
|
134
138
|
params.zlibOptions = opts.zlibOptions
|
|
135
139
|
params.onUnsupportedEncoding = opts.onUnsupportedEncoding
|
|
136
140
|
params.inflateIfDeflated = opts.inflateIfDeflated === true
|
|
137
141
|
params.threshold = typeof opts.threshold === 'number' ? opts.threshold : 1024
|
|
142
|
+
// Upper size bound of the synchronous compression path. Unlike `threshold`, which is a
|
|
143
|
+
// lower bound, this one is a ceiling: bigger payloads keep using the stream pipeline.
|
|
144
|
+
// The default scales with the parallelism the host can offer the stream path, see
|
|
145
|
+
// `computeSyncThreshold()`. An explicit value is always used as is, and `0` disables
|
|
146
|
+
// the synchronous path entirely.
|
|
147
|
+
params.syncThreshold = typeof opts.syncThreshold === 'number' ? opts.syncThreshold : defaultSyncThreshold
|
|
138
148
|
params.compressibleTypes = opts.customTypes instanceof RegExp
|
|
139
149
|
? opts.customTypes.test.bind(opts.customTypes)
|
|
140
150
|
: typeof opts.customTypes === 'function'
|
|
@@ -148,6 +158,22 @@ function processCompressParams (opts) {
|
|
|
148
158
|
if (typeof ((opts.zlib || zlib).createZstdCompress || zlib.createZstdCompress) === 'function') {
|
|
149
159
|
params.compressStream.zstd = () => ((opts.zlib || zlib).createZstdCompress || zlib.createZstdCompress)(params.zlibOptions)
|
|
150
160
|
}
|
|
161
|
+
// Synchronous counterparts of `params.compressStream`, used for small buffered payloads.
|
|
162
|
+
// Encodings without a usable synchronous method are left out of the map so that they
|
|
163
|
+
// keep flowing through the stream pipeline.
|
|
164
|
+
params.compressSync = {}
|
|
165
|
+
for (const [encoding, streamMethod, syncMethod, options] of [
|
|
166
|
+
['br', 'createBrotliCompress', 'brotliCompressSync', params.brotliOptions],
|
|
167
|
+
['gzip', 'createGzip', 'gzipSync', params.zlibOptions],
|
|
168
|
+
['deflate', 'createDeflate', 'deflateSync', params.zlibOptions],
|
|
169
|
+
['zstd', 'createZstdCompress', 'zstdCompressSync', params.zlibOptions]
|
|
170
|
+
]) {
|
|
171
|
+
const compressSync = resolveSyncCompressor(opts.zlib, streamMethod, syncMethod, options)
|
|
172
|
+
if (compressSync !== undefined) {
|
|
173
|
+
params.compressSync[encoding] = compressSync
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
151
177
|
params.uncompressStream = {
|
|
152
178
|
// Currently params.uncompressStream.br() is never called as we do not have any way to autodetect brotli compression in `fastify-compress`
|
|
153
179
|
// Brotli documentation reference: [RFC 7932](https://www.rfc-editor.org/rfc/rfc7932)
|
|
@@ -175,6 +201,54 @@ function processCompressParams (opts) {
|
|
|
175
201
|
return params
|
|
176
202
|
}
|
|
177
203
|
|
|
204
|
+
// Resolve the synchronous counterpart of a stream compressor.
|
|
205
|
+
// A synchronous method is only picked when it comes from the same `zlib` implementation the
|
|
206
|
+
// stream path would have used, so a custom `zlib` exposing just the stream constructor is
|
|
207
|
+
// never bypassed: its encoding simply keeps using the stream pipeline.
|
|
208
|
+
function resolveSyncCompressor (customZlib, streamMethod, syncMethod, options) {
|
|
209
|
+
let compressSync
|
|
210
|
+
|
|
211
|
+
if (customZlib != null && typeof customZlib[syncMethod] === 'function') {
|
|
212
|
+
compressSync = customZlib[syncMethod]
|
|
213
|
+
} else if ((customZlib == null || typeof customZlib[streamMethod] !== 'function') && typeof zlib[syncMethod] === 'function') {
|
|
214
|
+
// `zlib.zstdCompressSync` is only available on Node.js 22.15+/23.8+
|
|
215
|
+
compressSync = zlib[syncMethod]
|
|
216
|
+
} else {
|
|
217
|
+
return undefined
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return (payload) => compressSync(payload, options)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Compress an already buffered payload inline, bypassing the stream pipeline.
|
|
224
|
+
// Small payloads are much cheaper to compress this way: no zlib context and no stream
|
|
225
|
+
// pipeline are allocated per response, and the source payload is released immediately
|
|
226
|
+
// instead of being pinned for as long as the response is in flight.
|
|
227
|
+
// Returns `null` when the payload is not eligible and must be streamed instead.
|
|
228
|
+
function compressPayloadSync (params, reply, encoding, payload, payloadSize) {
|
|
229
|
+
if (params.syncThreshold <= 0 || payloadSize > params.syncThreshold) return null
|
|
230
|
+
|
|
231
|
+
const compressSync = params.compressSync[encoding]
|
|
232
|
+
if (compressSync === undefined) return null
|
|
233
|
+
|
|
234
|
+
// Only strings and Buffers are fully buffered and can be compressed in one go.
|
|
235
|
+
// Fastify already turns every other buffered payload type into one of those two
|
|
236
|
+
// before this point, so this is a safety net rather than a reachable path.
|
|
237
|
+
/* c8 ignore next */
|
|
238
|
+
if (typeof payload !== 'string' && !Buffer.isBuffer(payload)) return null
|
|
239
|
+
|
|
240
|
+
// Fastify recomputes `content-length` for buffered payloads, so a caller provided value
|
|
241
|
+
// that the plugin has been asked to keep can only be honoured by the stream path
|
|
242
|
+
if (params.removeContentLengthHeader === false && reply.getHeader('content-length') !== undefined) return null
|
|
243
|
+
|
|
244
|
+
const buffer = Buffer.isBuffer(payload) ? payload : Buffer.from(payload)
|
|
245
|
+
|
|
246
|
+
// Mirror `zipStream()` and pass already gzip or deflate compressed payloads through as is
|
|
247
|
+
if (isCompressed(buffer) !== 0) return buffer
|
|
248
|
+
|
|
249
|
+
return compressSync(buffer)
|
|
250
|
+
}
|
|
251
|
+
|
|
178
252
|
function processDecompressParams (opts) {
|
|
179
253
|
/* c8 ignore next 3 */
|
|
180
254
|
if (!opts) {
|
|
@@ -253,14 +327,18 @@ function buildRouteCompress (_fastify, params, routeOptions, decorateOnly) {
|
|
|
253
327
|
}
|
|
254
328
|
|
|
255
329
|
function onSend (req, reply, payload, next) {
|
|
330
|
+
if (isFetchResponse(payload)) {
|
|
331
|
+
payload = unwrapFetchResponse(reply, payload)
|
|
332
|
+
}
|
|
333
|
+
|
|
256
334
|
if (payload == null) {
|
|
257
|
-
return next()
|
|
335
|
+
return next(null, payload)
|
|
258
336
|
}
|
|
259
337
|
|
|
260
338
|
const responseEncoding = reply.getHeader('Content-Encoding')
|
|
261
339
|
if (responseEncoding && responseEncoding !== 'identity') {
|
|
262
340
|
// response is already compressed
|
|
263
|
-
return next()
|
|
341
|
+
return next(null, payload)
|
|
264
342
|
}
|
|
265
343
|
|
|
266
344
|
let stream, encoding
|
|
@@ -296,16 +374,30 @@ function buildRouteCompress (_fastify, params, routeOptions, decorateOnly) {
|
|
|
296
374
|
}
|
|
297
375
|
|
|
298
376
|
if (typeof payload.pipe !== 'function') {
|
|
299
|
-
if (isFetchResponse(payload)) {
|
|
300
|
-
payload = unwrapFetchResponse(reply, payload)
|
|
301
|
-
}
|
|
302
|
-
|
|
303
377
|
if (isWebReadableStream(payload)) {
|
|
304
378
|
payload = webStreamToNodeReadable(payload)
|
|
305
379
|
} else {
|
|
306
|
-
|
|
380
|
+
const payloadSize = Buffer.byteLength(payload)
|
|
381
|
+
if (payloadSize < params.threshold) {
|
|
307
382
|
return next()
|
|
308
383
|
}
|
|
384
|
+
|
|
385
|
+
let compressedPayload = null
|
|
386
|
+
try {
|
|
387
|
+
compressedPayload = compressPayloadSync(params, reply, encoding, payload, payloadSize)
|
|
388
|
+
} catch (err) {
|
|
389
|
+
return next(err)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (compressedPayload !== null) {
|
|
393
|
+
setVaryHeader(reply)
|
|
394
|
+
reply.header('Content-Encoding', encoding)
|
|
395
|
+
// The compressed size is known on this path, so an accurate `content-length` is
|
|
396
|
+
// set instead of dropping the header and falling back to a chunked response
|
|
397
|
+
reply.header('Content-Length', compressedPayload.length)
|
|
398
|
+
return next(null, compressedPayload)
|
|
399
|
+
}
|
|
400
|
+
|
|
309
401
|
payload = Readable.from(intoAsyncIterator(payload))
|
|
310
402
|
}
|
|
311
403
|
}
|
|
@@ -393,6 +485,14 @@ function compress (params) {
|
|
|
393
485
|
return
|
|
394
486
|
}
|
|
395
487
|
|
|
488
|
+
if (isFetchResponse(payload)) {
|
|
489
|
+
if (payload.body === null) {
|
|
490
|
+
return this.send(payload)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
payload = unwrapFetchResponse(this, payload)
|
|
494
|
+
}
|
|
495
|
+
|
|
396
496
|
let stream, encoding
|
|
397
497
|
const noCompress =
|
|
398
498
|
// don't compress on x-no-compression header
|
|
@@ -425,10 +525,6 @@ function compress (params) {
|
|
|
425
525
|
}
|
|
426
526
|
|
|
427
527
|
if (typeof payload.pipe !== 'function') {
|
|
428
|
-
if (isFetchResponse(payload)) {
|
|
429
|
-
payload = unwrapFetchResponse(this, payload)
|
|
430
|
-
}
|
|
431
|
-
|
|
432
528
|
if (isWebReadableStream(payload)) {
|
|
433
529
|
payload = webStreamToNodeReadable(payload)
|
|
434
530
|
} else if (!Buffer.isBuffer(payload) && typeof payload !== 'string') {
|
|
@@ -437,9 +533,27 @@ function compress (params) {
|
|
|
437
533
|
}
|
|
438
534
|
|
|
439
535
|
if (typeof payload.pipe !== 'function') {
|
|
440
|
-
|
|
536
|
+
const payloadSize = Buffer.byteLength(payload)
|
|
537
|
+
if (payloadSize < params.threshold) {
|
|
441
538
|
return this.send(payload)
|
|
442
539
|
}
|
|
540
|
+
|
|
541
|
+
let compressedPayload = null
|
|
542
|
+
try {
|
|
543
|
+
compressedPayload = compressPayloadSync(params, this, encoding, payload, payloadSize)
|
|
544
|
+
} catch (err) {
|
|
545
|
+
return this.send(err)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (compressedPayload !== null) {
|
|
549
|
+
setVaryHeader(this)
|
|
550
|
+
this.header('Content-Encoding', encoding)
|
|
551
|
+
// The compressed size is known on this path, so an accurate `content-length` is
|
|
552
|
+
// set instead of dropping the header and falling back to a chunked response
|
|
553
|
+
this.header('Content-Length', compressedPayload.length)
|
|
554
|
+
return this.send(compressedPayload)
|
|
555
|
+
}
|
|
556
|
+
|
|
443
557
|
payload = Readable.from(intoAsyncIterator(payload))
|
|
444
558
|
}
|
|
445
559
|
|
|
@@ -565,8 +679,8 @@ function maybeUnzip (payload, serialize) {
|
|
|
565
679
|
function zipStream (deflate, encoding) {
|
|
566
680
|
return createPeekTransform(function (data) {
|
|
567
681
|
switch (isCompressed(data)) {
|
|
568
|
-
case 1: return new
|
|
569
|
-
case 2: return new
|
|
682
|
+
case 1: return new PassThrough()
|
|
683
|
+
case 2: return new PassThrough()
|
|
570
684
|
}
|
|
571
685
|
return deflate[encoding]()
|
|
572
686
|
})
|
|
@@ -582,7 +696,7 @@ function unzipStream (inflate, maxRecursion) {
|
|
|
582
696
|
case 1: return compose(inflate.gzip(), unzipStream(inflate, maxRecursion - 1))
|
|
583
697
|
case 2: return compose(inflate.deflate(), unzipStream(inflate, maxRecursion - 1))
|
|
584
698
|
}
|
|
585
|
-
return new
|
|
699
|
+
return new PassThrough()
|
|
586
700
|
})
|
|
587
701
|
}
|
|
588
702
|
|
package/lib/utils.js
CHANGED
|
@@ -1,7 +1,46 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
const { Readable: NodeReadable } = require('node:stream')
|
|
4
|
-
const {
|
|
3
|
+
const { Readable: NodeReadable, Transform } = require('node:stream')
|
|
4
|
+
const { availableParallelism } = require('node:os')
|
|
5
|
+
|
|
6
|
+
// Bounds of the computed default synchronous compression threshold
|
|
7
|
+
const MIN_SYNC_THRESHOLD = 4096
|
|
8
|
+
const MAX_SYNC_THRESHOLD = 65536
|
|
9
|
+
// libuv's threadpool defaults to 4 threads, so at most 4 stream compressions can make
|
|
10
|
+
// progress at once however many cores the host reports. `UV_THREADPOOL_SIZE` can raise
|
|
11
|
+
// it, but it is not something this default can rely on.
|
|
12
|
+
const MAX_USEFUL_PARALLELISM = 4
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Compute the default upper size bound of the synchronous compression path.
|
|
16
|
+
*
|
|
17
|
+
* The stream path hands compression to the libuv threadpool, so it only pays off when
|
|
18
|
+
* there are spare cores to overlap that work with the event loop. The more cores are
|
|
19
|
+
* available for that overlap, the smaller the payload at which streaming starts winning:
|
|
20
|
+
* measured over real sockets, the crossover sits around 4 KiB with four or more cores,
|
|
21
|
+
* while on a single core the synchronous path stays ahead across the whole range.
|
|
22
|
+
*
|
|
23
|
+
* The threshold is therefore divided by 8 for every core beyond the first, and clamped.
|
|
24
|
+
* The divisor is deliberately steep because the two errors are not symmetric: settling
|
|
25
|
+
* too low only forgoes some of the throughput win, while settling too high costs far
|
|
26
|
+
* more than it ever gains.
|
|
27
|
+
*
|
|
28
|
+
* `availableParallelism()` accounts for a cgroup CPU quota since libuv 1.49 (Node 22.12),
|
|
29
|
+
* so a container limited to whole cores reports those rather than the host's. Two cases
|
|
30
|
+
* still read as a wide host and so land on the low end of the range:
|
|
31
|
+
*
|
|
32
|
+
* - Node.js before 22.12, where the quota is not consulted at all;
|
|
33
|
+
* - a quota below one full core, such as Kubernetes `cpu: 500m`, which floors to zero
|
|
34
|
+
* and falls back to the host's core count.
|
|
35
|
+
*
|
|
36
|
+
* Both err towards the small end, which is the safe direction: settling too low only
|
|
37
|
+
* forgoes some of the win. Such deployments can raise `syncThreshold` explicitly.
|
|
38
|
+
*/
|
|
39
|
+
function computeSyncThreshold (parallelism = availableParallelism()) {
|
|
40
|
+
const usableCores = Math.min(parallelism, MAX_USEFUL_PARALLELISM)
|
|
41
|
+
const threshold = MAX_SYNC_THRESHOLD >> (3 * (usableCores - 1))
|
|
42
|
+
return Math.min(Math.max(threshold, MIN_SYNC_THRESHOLD), MAX_SYNC_THRESHOLD)
|
|
43
|
+
}
|
|
5
44
|
|
|
6
45
|
// https://datatracker.ietf.org/doc/html/rfc8878#section-3.1.1
|
|
7
46
|
function isZstd (buffer) {
|
|
@@ -192,6 +231,7 @@ function createPeekTransform (swapStream, peekSize = 10) {
|
|
|
192
231
|
}
|
|
193
232
|
|
|
194
233
|
module.exports = {
|
|
234
|
+
computeSyncThreshold,
|
|
195
235
|
isZstd,
|
|
196
236
|
isGzip,
|
|
197
237
|
isDeflate,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fastify/compress",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.2.1",
|
|
4
4
|
"description": "Fastify compression utils",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -8,9 +8,7 @@
|
|
|
8
8
|
"dependencies": {
|
|
9
9
|
"@fastify/accept-negotiator": "^2.0.0",
|
|
10
10
|
"fastify-plugin": "^6.0.0",
|
|
11
|
-
"mime-db": "^1.52.0"
|
|
12
|
-
"minipass": "^7.0.4",
|
|
13
|
-
"readable-stream": "^4.5.2"
|
|
11
|
+
"mime-db": "^1.52.0"
|
|
14
12
|
},
|
|
15
13
|
"devDependencies": {
|
|
16
14
|
"@types/node": "^26.0.1",
|
|
@@ -26,6 +24,7 @@
|
|
|
26
24
|
"scripts": {
|
|
27
25
|
"lint": "eslint",
|
|
28
26
|
"lint:fix": "eslint --fix",
|
|
27
|
+
"benchmark": "node benchmark/sync-compression.mjs",
|
|
29
28
|
"test": "npm run test:unit && npm run test:typescript",
|
|
30
29
|
"test:typescript": "tstyche",
|
|
31
30
|
"test:unit": "node --test",
|
|
@@ -318,6 +318,98 @@ describe('When `global` is not set, it is `true` by default :', async () => {
|
|
|
318
318
|
t.assert.equal(zlib.gunzipSync(response.rawPayload).toString('utf-8'), body)
|
|
319
319
|
})
|
|
320
320
|
|
|
321
|
+
test('it should not compress a Fetch API Response holding a non-compressible `Content-Type`', async (t) => {
|
|
322
|
+
t.plan(2)
|
|
323
|
+
|
|
324
|
+
const fastify = Fastify()
|
|
325
|
+
await fastify.register(compressPlugin, { threshold: 0 })
|
|
326
|
+
|
|
327
|
+
const body = 'data: hello from fetch response\n\n'
|
|
328
|
+
fastify.get('/fetch-resp', (_request, reply) => {
|
|
329
|
+
reply.send(new Response(body, { headers: { 'content-type': 'text/event-stream' } }))
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
const response = await fastify.inject({
|
|
333
|
+
url: '/fetch-resp',
|
|
334
|
+
method: 'GET',
|
|
335
|
+
headers: { 'accept-encoding': 'gzip' }
|
|
336
|
+
})
|
|
337
|
+
t.assert.equal(response.headers['content-encoding'], undefined)
|
|
338
|
+
t.assert.equal(response.rawPayload.toString('utf-8'), body)
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
test('it should not compress a Fetch API Response that is already compressed', async (t) => {
|
|
342
|
+
t.plan(2)
|
|
343
|
+
|
|
344
|
+
const fastify = Fastify()
|
|
345
|
+
await fastify.register(compressPlugin, { threshold: 0 })
|
|
346
|
+
|
|
347
|
+
const body = 'hello from fetch response'
|
|
348
|
+
fastify.get('/fetch-resp', (_request, reply) => {
|
|
349
|
+
reply.send(new Response(zlib.brotliCompressSync(body), {
|
|
350
|
+
headers: {
|
|
351
|
+
'content-type': 'text/plain',
|
|
352
|
+
'content-encoding': 'br'
|
|
353
|
+
}
|
|
354
|
+
}))
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
const response = await fastify.inject({
|
|
358
|
+
url: '/fetch-resp',
|
|
359
|
+
method: 'GET',
|
|
360
|
+
headers: { 'accept-encoding': 'gzip' }
|
|
361
|
+
})
|
|
362
|
+
t.assert.equal(response.headers['content-encoding'], 'br')
|
|
363
|
+
t.assert.equal(zlib.brotliDecompressSync(response.rawPayload).toString('utf-8'), body)
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
test('it should not compress a partial Fetch API Response', async (t) => {
|
|
367
|
+
t.plan(3)
|
|
368
|
+
|
|
369
|
+
const fastify = Fastify()
|
|
370
|
+
await fastify.register(compressPlugin, { threshold: 0 })
|
|
371
|
+
|
|
372
|
+
const body = 'hello fr'
|
|
373
|
+
fastify.get('/fetch-resp', (_request, reply) => {
|
|
374
|
+
reply.send(new Response(body, {
|
|
375
|
+
status: 206,
|
|
376
|
+
headers: {
|
|
377
|
+
'content-type': 'text/plain',
|
|
378
|
+
'content-range': 'bytes 0-7/25'
|
|
379
|
+
}
|
|
380
|
+
}))
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
const response = await fastify.inject({
|
|
384
|
+
url: '/fetch-resp',
|
|
385
|
+
method: 'GET',
|
|
386
|
+
headers: { 'accept-encoding': 'gzip' }
|
|
387
|
+
})
|
|
388
|
+
t.assert.equal(response.statusCode, 206)
|
|
389
|
+
t.assert.equal(response.headers['content-encoding'], undefined)
|
|
390
|
+
t.assert.equal(response.rawPayload.toString('utf-8'), body)
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
test('it should send a bodyless Fetch API Response as is', async (t) => {
|
|
394
|
+
t.plan(3)
|
|
395
|
+
|
|
396
|
+
const fastify = Fastify()
|
|
397
|
+
await fastify.register(compressPlugin, { threshold: 0 })
|
|
398
|
+
|
|
399
|
+
fastify.get('/fetch-resp', (_request, reply) => {
|
|
400
|
+
reply.send(new Response(null, { status: 204, headers: { 'x-custom': 'kept' } }))
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
const response = await fastify.inject({
|
|
404
|
+
url: '/fetch-resp',
|
|
405
|
+
method: 'GET',
|
|
406
|
+
headers: { 'accept-encoding': 'gzip' }
|
|
407
|
+
})
|
|
408
|
+
t.assert.equal(response.statusCode, 204)
|
|
409
|
+
t.assert.equal(response.headers['x-custom'], 'kept')
|
|
410
|
+
t.assert.equal(response.rawPayload.length, 0)
|
|
411
|
+
})
|
|
412
|
+
|
|
321
413
|
test('it should compress a Web ReadableStream body', async (t) => {
|
|
322
414
|
t.plan(1)
|
|
323
415
|
|
|
@@ -2035,11 +2127,14 @@ describe('It should support stream1 :', async () => {
|
|
|
2035
2127
|
})
|
|
2036
2128
|
|
|
2037
2129
|
describe('It should remove `Content-Length` header :', async () => {
|
|
2130
|
+
// `./package.json` is small enough to be compressed synchronously, and the synchronous
|
|
2131
|
+
// path knows the compressed size, so it replaces the stale `Content-Length` with an
|
|
2132
|
+
// accurate one instead of removing it. `syncThreshold: 0` restores the streamed behaviour.
|
|
2038
2133
|
test('using `reply.compress()`', async (t) => {
|
|
2039
2134
|
t.plan(4)
|
|
2040
2135
|
|
|
2041
2136
|
const fastify = Fastify()
|
|
2042
|
-
await fastify.register(compressPlugin, { global: true })
|
|
2137
|
+
await fastify.register(compressPlugin, { global: true, syncThreshold: 0 })
|
|
2043
2138
|
|
|
2044
2139
|
fastify.get('/', (_request, reply) => {
|
|
2045
2140
|
readFile('./package.json', 'utf8', (err, data) => {
|
|
@@ -2099,7 +2194,7 @@ describe('It should remove `Content-Length` header :', async () => {
|
|
|
2099
2194
|
t.plan(4)
|
|
2100
2195
|
|
|
2101
2196
|
const fastify = Fastify()
|
|
2102
|
-
await fastify.register(compressPlugin, { global: true })
|
|
2197
|
+
await fastify.register(compressPlugin, { global: true, syncThreshold: 0 })
|
|
2103
2198
|
|
|
2104
2199
|
fastify.get('/', (_request, reply) => {
|
|
2105
2200
|
readFile('./package.json', 'utf8', (err, data) => {
|
|
@@ -2851,6 +2946,70 @@ describe('It should send data compressed according to `brotliOptions` :', async
|
|
|
2851
2946
|
const compressedPayload = zlib.brotliCompressSync(file, defaultBrotliOptions)
|
|
2852
2947
|
t.assert.deepEqual(response.rawPayload, compressedPayload)
|
|
2853
2948
|
})
|
|
2949
|
+
|
|
2950
|
+
test('default BROTLI_PARAM_QUALITY to be 4 when `global` is `false`', async (t) => {
|
|
2951
|
+
t.plan(2)
|
|
2952
|
+
|
|
2953
|
+
const fastify = Fastify()
|
|
2954
|
+
await fastify.register(compressPlugin, { global: false })
|
|
2955
|
+
|
|
2956
|
+
const file = readFileSync('./package.json', 'utf8')
|
|
2957
|
+
fastify.get('/', (_request, reply) => {
|
|
2958
|
+
reply
|
|
2959
|
+
.type('text/plain')
|
|
2960
|
+
.compress(file)
|
|
2961
|
+
})
|
|
2962
|
+
|
|
2963
|
+
const response = await fastify.inject({
|
|
2964
|
+
url: '/',
|
|
2965
|
+
method: 'GET',
|
|
2966
|
+
headers: {
|
|
2967
|
+
'accept-encoding': 'br'
|
|
2968
|
+
}
|
|
2969
|
+
})
|
|
2970
|
+
|
|
2971
|
+
const defaultBrotliOptions = {
|
|
2972
|
+
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 }
|
|
2973
|
+
}
|
|
2974
|
+
const compressedPayload = zlib.brotliCompressSync(file, defaultBrotliOptions)
|
|
2975
|
+
t.assert.deepEqual(response.rawPayload, compressedPayload)
|
|
2976
|
+
|
|
2977
|
+
// zlib's own default of quality 11 is far more expensive and must not be used
|
|
2978
|
+
const maxQualityPayload = zlib.brotliCompressSync(file)
|
|
2979
|
+
t.assert.notDeepEqual(response.rawPayload, maxQualityPayload)
|
|
2980
|
+
})
|
|
2981
|
+
|
|
2982
|
+
test('user supplied `brotliOptions` still take precedence when `global` is `false`', async (t) => {
|
|
2983
|
+
t.plan(1)
|
|
2984
|
+
|
|
2985
|
+
const brotliOptions = {
|
|
2986
|
+
params: {
|
|
2987
|
+
[zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT,
|
|
2988
|
+
[zlib.constants.BROTLI_PARAM_QUALITY]: 8
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
const fastify = Fastify()
|
|
2993
|
+
await fastify.register(compressPlugin, { global: false, brotliOptions })
|
|
2994
|
+
|
|
2995
|
+
const file = readFileSync('./package.json', 'utf8')
|
|
2996
|
+
fastify.get('/', (_request, reply) => {
|
|
2997
|
+
reply
|
|
2998
|
+
.type('text/plain')
|
|
2999
|
+
.compress(file)
|
|
3000
|
+
})
|
|
3001
|
+
|
|
3002
|
+
const response = await fastify.inject({
|
|
3003
|
+
url: '/',
|
|
3004
|
+
method: 'GET',
|
|
3005
|
+
headers: {
|
|
3006
|
+
'accept-encoding': 'br'
|
|
3007
|
+
}
|
|
3008
|
+
})
|
|
3009
|
+
|
|
3010
|
+
const compressedPayload = zlib.brotliCompressSync(file, brotliOptions)
|
|
3011
|
+
t.assert.deepEqual(response.rawPayload, compressedPayload)
|
|
3012
|
+
})
|
|
2854
3013
|
})
|
|
2855
3014
|
|
|
2856
3015
|
describe('It should send data compressed according to `zlibOptions` :', async () => {
|
|
@@ -22,7 +22,9 @@ async function routes (fastify) {
|
|
|
22
22
|
|
|
23
23
|
test('should compress large payload without premature close', async (t) => {
|
|
24
24
|
const fastify = Fastify()
|
|
25
|
-
|
|
25
|
+
// `syncThreshold: 0` keeps this payload on the stream pipeline this regression covers,
|
|
26
|
+
// whatever default the host parallelism would otherwise produce
|
|
27
|
+
await fastify.register(fastifyCompress, { encodings: ['gzip'], global: true, syncThreshold: 0 })
|
|
26
28
|
await fastify.register(routes)
|
|
27
29
|
|
|
28
30
|
const response = await fastify.inject({
|
|
@@ -381,6 +381,32 @@ test('reply.compress should preserve a Fetch Response status and headers', async
|
|
|
381
381
|
t.assert.equal(zlib.gunzipSync(res.rawPayload).toString('utf8'), 'from reply.compress')
|
|
382
382
|
})
|
|
383
383
|
|
|
384
|
+
test('reply.compress should not compress a Fetch Response holding a non-compressible `Content-Type`', async (t) => {
|
|
385
|
+
t.plan(2)
|
|
386
|
+
const fastify = Fastify()
|
|
387
|
+
await fastify.register(compressPlugin, { global: true, threshold: 0 })
|
|
388
|
+
fastify.get('/', (_req, reply) => {
|
|
389
|
+
reply.compress(new Response('data: from reply.compress\n\n', {
|
|
390
|
+
headers: { 'content-type': 'text/event-stream' }
|
|
391
|
+
}))
|
|
392
|
+
})
|
|
393
|
+
const res = await fastify.inject({ url: '/', method: 'GET', headers: { 'accept-encoding': 'gzip' } })
|
|
394
|
+
t.assert.equal(res.headers['content-encoding'], undefined)
|
|
395
|
+
t.assert.equal(res.rawPayload.toString('utf8'), 'data: from reply.compress\n\n')
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
test('reply.compress should send a bodyless Fetch Response as is', async (t) => {
|
|
399
|
+
t.plan(2)
|
|
400
|
+
const fastify = Fastify()
|
|
401
|
+
await fastify.register(compressPlugin, { global: true, threshold: 0 })
|
|
402
|
+
fastify.get('/', (_req, reply) => {
|
|
403
|
+
reply.compress(new Response(null, { status: 204 }))
|
|
404
|
+
})
|
|
405
|
+
const res = await fastify.inject({ url: '/', method: 'GET', headers: { 'accept-encoding': 'gzip' } })
|
|
406
|
+
t.assert.equal(res.statusCode, 204)
|
|
407
|
+
t.assert.equal(res.rawPayload.length, 0)
|
|
408
|
+
})
|
|
409
|
+
|
|
384
410
|
test('reply.compress should handle Web ReadableStream', async (t) => {
|
|
385
411
|
t.plan(1)
|
|
386
412
|
const fastify = Fastify()
|