@fastify/compress 9.1.0 → 9.2.0

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/lib/utils.js CHANGED
@@ -1,8 +1,48 @@
1
1
  'use strict'
2
2
 
3
3
  const { Readable: NodeReadable } = require('node:stream')
4
+ const { availableParallelism } = require('node:os')
4
5
  const { Transform } = require('readable-stream')
5
6
 
7
+ // Bounds of the computed default synchronous compression threshold
8
+ const MIN_SYNC_THRESHOLD = 4096
9
+ const MAX_SYNC_THRESHOLD = 65536
10
+ // libuv's threadpool defaults to 4 threads, so at most 4 stream compressions can make
11
+ // progress at once however many cores the host reports. `UV_THREADPOOL_SIZE` can raise
12
+ // it, but it is not something this default can rely on.
13
+ const MAX_USEFUL_PARALLELISM = 4
14
+
15
+ /**
16
+ * Compute the default upper size bound of the synchronous compression path.
17
+ *
18
+ * The stream path hands compression to the libuv threadpool, so it only pays off when
19
+ * there are spare cores to overlap that work with the event loop. The more cores are
20
+ * available for that overlap, the smaller the payload at which streaming starts winning:
21
+ * measured over real sockets, the crossover sits around 4 KiB with four or more cores,
22
+ * while on a single core the synchronous path stays ahead across the whole range.
23
+ *
24
+ * The threshold is therefore divided by 8 for every core beyond the first, and clamped.
25
+ * The divisor is deliberately steep because the two errors are not symmetric: settling
26
+ * too low only forgoes some of the throughput win, while settling too high costs far
27
+ * more than it ever gains.
28
+ *
29
+ * `availableParallelism()` accounts for a cgroup CPU quota since libuv 1.49 (Node 22.12),
30
+ * so a container limited to whole cores reports those rather than the host's. Two cases
31
+ * still read as a wide host and so land on the low end of the range:
32
+ *
33
+ * - Node.js before 22.12, where the quota is not consulted at all;
34
+ * - a quota below one full core, such as Kubernetes `cpu: 500m`, which floors to zero
35
+ * and falls back to the host's core count.
36
+ *
37
+ * Both err towards the small end, which is the safe direction: settling too low only
38
+ * forgoes some of the win. Such deployments can raise `syncThreshold` explicitly.
39
+ */
40
+ function computeSyncThreshold (parallelism = availableParallelism()) {
41
+ const usableCores = Math.min(parallelism, MAX_USEFUL_PARALLELISM)
42
+ const threshold = MAX_SYNC_THRESHOLD >> (3 * (usableCores - 1))
43
+ return Math.min(Math.max(threshold, MIN_SYNC_THRESHOLD), MAX_SYNC_THRESHOLD)
44
+ }
45
+
6
46
  // https://datatracker.ietf.org/doc/html/rfc8878#section-3.1.1
7
47
  function isZstd (buffer) {
8
48
  return (
@@ -64,6 +104,14 @@ function webStreamToNodeReadable (webStream) {
64
104
  return NodeReadable.fromWeb(webStream)
65
105
  }
66
106
 
107
+ function unwrapFetchResponse (reply, response) {
108
+ reply.code(response.status)
109
+ for (const [name, value] of response.headers) {
110
+ reply.header(name, value)
111
+ }
112
+ return response.body
113
+ }
114
+
67
115
  /**
68
116
  * Provide a async iteratable for Readable.from
69
117
  */
@@ -184,6 +232,7 @@ function createPeekTransform (swapStream, peekSize = 10) {
184
232
  }
185
233
 
186
234
  module.exports = {
235
+ computeSyncThreshold,
187
236
  isZstd,
188
237
  isGzip,
189
238
  isDeflate,
@@ -192,5 +241,6 @@ module.exports = {
192
241
  isWebReadableStream,
193
242
  isFetchResponse,
194
243
  webStreamToNodeReadable,
244
+ unwrapFetchResponse,
195
245
  createPeekTransform
196
246
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fastify/compress",
3
- "version": "9.1.0",
3
+ "version": "9.2.0",
4
4
  "description": "Fastify compression utils",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
@@ -14,8 +14,8 @@
14
14
  },
15
15
  "devDependencies": {
16
16
  "@types/node": "^26.0.1",
17
- "adm-zip": "^0.5.12",
18
- "c8": "^11.0.0",
17
+ "adm-zip": "^0.6.0",
18
+ "c8": "^12.0.0",
19
19
  "eslint": "^9.17.0",
20
20
  "fastify": "^5.0.0",
21
21
  "jsonstream": "^1.0.3",
@@ -26,6 +26,7 @@
26
26
  "scripts": {
27
27
  "lint": "eslint",
28
28
  "lint:fix": "eslint --fix",
29
+ "benchmark": "node benchmark/sync-compression.mjs",
29
30
  "test": "npm run test:unit && npm run test:typescript",
30
31
  "test:typescript": "tstyche",
31
32
  "test:unit": "node --test",
@@ -288,6 +288,128 @@ describe('When `global` is not set, it is `true` by default :', async () => {
288
288
  t.assert.equal(payload.toString('utf-8'), body)
289
289
  })
290
290
 
291
+ test('it should preserve a Fetch API Response status and headers', async (t) => {
292
+ t.plan(5)
293
+
294
+ const fastify = Fastify()
295
+ await fastify.register(compressPlugin, { threshold: 0 })
296
+
297
+ const body = 'hello from fetch response'
298
+ fastify.get('/fetch-resp', (_request, reply) => {
299
+ reply.send(new Response(body, {
300
+ status: 201,
301
+ headers: {
302
+ 'content-type': 'text/plain',
303
+ 'cache-control': 'public, max-age=120',
304
+ 'set-cookie': 'a=b; HttpOnly'
305
+ }
306
+ }))
307
+ })
308
+
309
+ const response = await fastify.inject({
310
+ url: '/fetch-resp',
311
+ method: 'GET',
312
+ headers: { 'accept-encoding': 'gzip' }
313
+ })
314
+ t.assert.equal(response.statusCode, 201)
315
+ t.assert.equal(response.headers['content-type'], 'text/plain')
316
+ t.assert.equal(response.headers['cache-control'], 'public, max-age=120')
317
+ t.assert.equal(response.headers['set-cookie'], 'a=b; HttpOnly')
318
+ t.assert.equal(zlib.gunzipSync(response.rawPayload).toString('utf-8'), body)
319
+ })
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
+
291
413
  test('it should compress a Web ReadableStream body', async (t) => {
292
414
  t.plan(1)
293
415
 
@@ -2005,11 +2127,14 @@ describe('It should support stream1 :', async () => {
2005
2127
  })
2006
2128
 
2007
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.
2008
2133
  test('using `reply.compress()`', async (t) => {
2009
2134
  t.plan(4)
2010
2135
 
2011
2136
  const fastify = Fastify()
2012
- await fastify.register(compressPlugin, { global: true })
2137
+ await fastify.register(compressPlugin, { global: true, syncThreshold: 0 })
2013
2138
 
2014
2139
  fastify.get('/', (_request, reply) => {
2015
2140
  readFile('./package.json', 'utf8', (err, data) => {
@@ -2069,7 +2194,7 @@ describe('It should remove `Content-Length` header :', async () => {
2069
2194
  t.plan(4)
2070
2195
 
2071
2196
  const fastify = Fastify()
2072
- await fastify.register(compressPlugin, { global: true })
2197
+ await fastify.register(compressPlugin, { global: true, syncThreshold: 0 })
2073
2198
 
2074
2199
  fastify.get('/', (_request, reply) => {
2075
2200
  readFile('./package.json', 'utf8', (err, data) => {
@@ -2821,6 +2946,70 @@ describe('It should send data compressed according to `brotliOptions` :', async
2821
2946
  const compressedPayload = zlib.brotliCompressSync(file, defaultBrotliOptions)
2822
2947
  t.assert.deepEqual(response.rawPayload, compressedPayload)
2823
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
+ })
2824
3013
  })
2825
3014
 
2826
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({
@@ -364,6 +364,49 @@ test('reply.compress should handle Fetch Response', async (t) => {
364
364
  t.assert.equal(zlib.gunzipSync(res.rawPayload).toString('utf8'), 'from reply.compress')
365
365
  })
366
366
 
367
+ test('reply.compress should preserve a Fetch Response status and headers', async (t) => {
368
+ t.plan(4)
369
+ const fastify = Fastify()
370
+ await fastify.register(compressPlugin, { global: true, threshold: 0 })
371
+ fastify.get('/', (_req, reply) => {
372
+ reply.compress(new Response('from reply.compress', {
373
+ status: 418,
374
+ headers: { 'content-type': 'text/plain', 'x-custom': 'kept' }
375
+ }))
376
+ })
377
+ const res = await fastify.inject({ url: '/', method: 'GET', headers: { 'accept-encoding': 'gzip' } })
378
+ t.assert.equal(res.statusCode, 418)
379
+ t.assert.equal(res.headers['content-type'], 'text/plain')
380
+ t.assert.equal(res.headers['x-custom'], 'kept')
381
+ t.assert.equal(zlib.gunzipSync(res.rawPayload).toString('utf8'), 'from reply.compress')
382
+ })
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
+
367
410
  test('reply.compress should handle Web ReadableStream', async (t) => {
368
411
  t.plan(1)
369
412
  const fastify = Fastify()