@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/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
- params.brotliOptions = params.global
132
- ? { ...recommendedDefaultBrotliOptions, ...opts.brotliOptions }
133
- : opts.brotliOptions
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
- if (Buffer.byteLength(payload) < params.threshold) {
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
- if (Buffer.byteLength(payload) < params.threshold) {
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 Minipass()
569
- case 2: return new Minipass()
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 Minipass()
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 { Transform } = require('readable-stream')
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.1.1",
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
- await fastify.register(fastifyCompress, { encodings: ['gzip'], global: true })
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()