@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/.github/dependabot.yml +1 -0
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lock-threads.yml +1 -1
- package/README.md +84 -0
- package/benchmark/sync-compression.mjs +282 -0
- package/index.js +132 -15
- package/lib/utils.js +50 -0
- package/package.json +4 -3
- package/test/global-compress.test.js +191 -2
- package/test/regression/issue-350.test.js +3 -1
- package/test/routes-compress.test.js +43 -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/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.
|
|
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.
|
|
18
|
-
"c8": "^
|
|
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
|
-
|
|
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()
|