@fastify/compress 8.1.0 → 8.3.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/workflows/ci.yml +5 -0
- package/README.md +64 -4
- package/index.js +26 -7
- package/lib/utils.js +15 -1
- package/package.json +5 -6
- package/test/global-compress.test.js +198 -0
- package/test/routes-compress.test.js +30 -0
- package/types/index.d.ts +3 -1
- package/types/index.test-d.ts +15 -0
- package/.claude/settings.local.json +0 -8
- package/CLAUDE.md +0 -64
package/.github/workflows/ci.yml
CHANGED
|
@@ -14,6 +14,11 @@ on:
|
|
|
14
14
|
- 'docs/**'
|
|
15
15
|
- '*.md'
|
|
16
16
|
|
|
17
|
+
# This allows a subsequently queued workflow run to interrupt previous runs
|
|
18
|
+
concurrency:
|
|
19
|
+
group: "${{ github.workflow }}-${{ github.event.pull_request.head.label || github.head_ref || github.ref }}"
|
|
20
|
+
cancel-in-progress: true
|
|
21
|
+
|
|
17
22
|
permissions:
|
|
18
23
|
contents: read
|
|
19
24
|
|
package/README.md
CHANGED
|
@@ -57,6 +57,23 @@ await fastify.register(
|
|
|
57
57
|
{ global: false }
|
|
58
58
|
)
|
|
59
59
|
```
|
|
60
|
+
|
|
61
|
+
If only compression or decompression is required, set the `globalCompression` or `globalDecompression` config flags to `false` respectively (both are `true` by default).
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
await fastify.register(
|
|
65
|
+
import('@fastify/compress'),
|
|
66
|
+
// Disable compression but keep decompression enabled (default behavior for globalDecompression is true)
|
|
67
|
+
{ globalCompression: false }
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
// Disable decompression but keep compression enabled
|
|
71
|
+
await fastify.register(
|
|
72
|
+
import('@fastify/compress'),
|
|
73
|
+
{ globalDecompression: false }
|
|
74
|
+
)
|
|
75
|
+
```
|
|
76
|
+
|
|
60
77
|
Fastify encapsulation can be used to set global compression but run it only in a subset of routes by wrapping them inside a plugin.
|
|
61
78
|
|
|
62
79
|
> ℹ️ Note: If using `@fastify/compress` plugin together with `@fastify/static` plugin, `@fastify/compress` must be registered (with *global hook*) **before** registering `@fastify/static`.
|
|
@@ -106,6 +123,40 @@ app.get('/', (req, reply) => {
|
|
|
106
123
|
await app.listen({ port: 3000 })
|
|
107
124
|
```
|
|
108
125
|
|
|
126
|
+
It's also possible to pass a Fetch API `Response` object or a Web `ReadableStream`. The plugin will automatically extract the body stream from the `Response` or convert the Web stream to a Node.js `Readable` behind the scenes.
|
|
127
|
+
|
|
128
|
+
```js
|
|
129
|
+
import fastify from 'fastify'
|
|
130
|
+
|
|
131
|
+
const app = fastify()
|
|
132
|
+
await app.register(import('@fastify/compress'), { global: true })
|
|
133
|
+
|
|
134
|
+
app.get('/', async (req, reply) => {
|
|
135
|
+
const resp = new Response('Hello from Fetch Response')
|
|
136
|
+
reply.compress(resp)
|
|
137
|
+
})
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
```js
|
|
141
|
+
app.get('/', async (req, reply) => {
|
|
142
|
+
return new Response('Hello from Fetch Response')
|
|
143
|
+
})
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
```js
|
|
147
|
+
app.get('/', (req, reply) => {
|
|
148
|
+
const stream = new ReadableStream({
|
|
149
|
+
start (controller) {
|
|
150
|
+
controller.enqueue(new TextEncoder().encode('Hello from Web ReadableStream'))
|
|
151
|
+
controller.close()
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
reply.header('content-type', 'text/plain')
|
|
156
|
+
reply.compress(stream)
|
|
157
|
+
})
|
|
158
|
+
```
|
|
159
|
+
|
|
109
160
|
## Compress Options
|
|
110
161
|
|
|
111
162
|
### threshold
|
|
@@ -222,15 +273,24 @@ This plugin adds a `preParsing` hook to decompress the request payload based on
|
|
|
222
273
|
|
|
223
274
|
Currently, the following encoding tokens are supported:
|
|
224
275
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
276
|
+
- `zstd` (Node.js 22.15+/23.8+)
|
|
277
|
+
- `br`
|
|
278
|
+
- `gzip`
|
|
279
|
+
- `deflate`
|
|
229
280
|
|
|
230
281
|
If an unsupported encoding or invalid payload is received, the plugin throws an error.
|
|
231
282
|
|
|
232
283
|
If the request header is missing, the plugin yields to the next hook.
|
|
233
284
|
|
|
285
|
+
### Supported payload types
|
|
286
|
+
|
|
287
|
+
The plugin supports compressing the following payload types:
|
|
288
|
+
|
|
289
|
+
- Strings and Buffers
|
|
290
|
+
- Node.js streams
|
|
291
|
+
- Response objects (from the Fetch API)
|
|
292
|
+
- ReadableStream objects (from the Web Streams API)
|
|
293
|
+
|
|
234
294
|
### Global hook
|
|
235
295
|
|
|
236
296
|
The global request decompression hook is enabled by default. To disable it, pass `{ global: false }`:
|
package/index.js
CHANGED
|
@@ -12,7 +12,7 @@ const { Minipass } = require('minipass')
|
|
|
12
12
|
const pumpify = require('pumpify')
|
|
13
13
|
const { Readable } = require('readable-stream')
|
|
14
14
|
|
|
15
|
-
const { isStream, isGzip, isDeflate, intoAsyncIterator } = require('./lib/utils')
|
|
15
|
+
const { isStream, isGzip, isDeflate, intoAsyncIterator, isWebReadableStream, isFetchResponse, webStreamToNodeReadable } = require('./lib/utils')
|
|
16
16
|
|
|
17
17
|
const InvalidRequestEncodingError = createError('FST_CP_ERR_INVALID_CONTENT_ENCODING', 'Unsupported Content-Encoding: %s', 415)
|
|
18
18
|
const InvalidRequestCompressedPayloadError = createError('FST_CP_ERR_INVALID_CONTENT', 'Could not decompress the request payload using the provided encoding', 400)
|
|
@@ -124,7 +124,9 @@ function processCompressParams (opts) {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
const params = {
|
|
127
|
-
global: (typeof opts.
|
|
127
|
+
global: (typeof opts.globalCompression === 'boolean')
|
|
128
|
+
? opts.globalCompression
|
|
129
|
+
: (typeof opts.global === 'boolean') ? opts.global : true
|
|
128
130
|
}
|
|
129
131
|
|
|
130
132
|
params.removeContentLengthHeader = typeof opts.removeContentLengthHeader === 'boolean' ? opts.removeContentLengthHeader : true
|
|
@@ -184,7 +186,9 @@ function processDecompressParams (opts) {
|
|
|
184
186
|
const customZlib = opts.zlib || zlib
|
|
185
187
|
|
|
186
188
|
const params = {
|
|
187
|
-
global: (typeof opts.
|
|
189
|
+
global: (typeof opts.globalDecompression === 'boolean')
|
|
190
|
+
? opts.globalDecompression
|
|
191
|
+
: (typeof opts.global === 'boolean') ? opts.global : true,
|
|
188
192
|
onUnsupportedRequestEncoding: opts.onUnsupportedRequestEncoding,
|
|
189
193
|
onInvalidRequestPayload: opts.onInvalidRequestPayload,
|
|
190
194
|
decompressStream: {
|
|
@@ -254,6 +258,7 @@ function buildRouteCompress (_fastify, params, routeOptions, decorateOnly) {
|
|
|
254
258
|
if (payload == null) {
|
|
255
259
|
return next()
|
|
256
260
|
}
|
|
261
|
+
|
|
257
262
|
const responseEncoding = reply.getHeader('Content-Encoding')
|
|
258
263
|
if (responseEncoding && responseEncoding !== 'identity') {
|
|
259
264
|
// response is already compressed
|
|
@@ -290,10 +295,18 @@ function buildRouteCompress (_fastify, params, routeOptions, decorateOnly) {
|
|
|
290
295
|
}
|
|
291
296
|
|
|
292
297
|
if (typeof payload.pipe !== 'function') {
|
|
293
|
-
if (
|
|
294
|
-
|
|
298
|
+
if (isFetchResponse(payload)) {
|
|
299
|
+
payload = payload.body
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (isWebReadableStream(payload)) {
|
|
303
|
+
payload = webStreamToNodeReadable(payload)
|
|
304
|
+
} else {
|
|
305
|
+
if (Buffer.byteLength(payload) < params.threshold) {
|
|
306
|
+
return next()
|
|
307
|
+
}
|
|
308
|
+
payload = Readable.from(intoAsyncIterator(payload))
|
|
295
309
|
}
|
|
296
|
-
payload = Readable.from(intoAsyncIterator(payload))
|
|
297
310
|
}
|
|
298
311
|
|
|
299
312
|
setVaryHeader(reply)
|
|
@@ -408,7 +421,13 @@ function compress (params) {
|
|
|
408
421
|
}
|
|
409
422
|
|
|
410
423
|
if (typeof payload.pipe !== 'function') {
|
|
411
|
-
if (
|
|
424
|
+
if (isFetchResponse(payload)) {
|
|
425
|
+
payload = payload.body
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (isWebReadableStream(payload)) {
|
|
429
|
+
payload = webStreamToNodeReadable(payload)
|
|
430
|
+
} else if (!Buffer.isBuffer(payload) && typeof payload !== 'string') {
|
|
412
431
|
payload = this.serialize(payload)
|
|
413
432
|
}
|
|
414
433
|
}
|
package/lib/utils.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
+
const { Readable: NodeReadable } = require('node:stream')
|
|
4
|
+
|
|
3
5
|
// https://datatracker.ietf.org/doc/html/rfc8878#section-3.1.1
|
|
4
6
|
function isZstd (buffer) {
|
|
5
7
|
return (
|
|
@@ -49,6 +51,18 @@ function isStream (stream) {
|
|
|
49
51
|
return stream !== null && typeof stream === 'object' && typeof stream.pipe === 'function'
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
function isWebReadableStream (obj) {
|
|
55
|
+
return obj instanceof ReadableStream
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isFetchResponse (obj) {
|
|
59
|
+
return obj instanceof Response
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function webStreamToNodeReadable (webStream) {
|
|
63
|
+
return NodeReadable.fromWeb(webStream)
|
|
64
|
+
}
|
|
65
|
+
|
|
52
66
|
/**
|
|
53
67
|
* Provide a async iteratable for Readable.from
|
|
54
68
|
*/
|
|
@@ -90,4 +104,4 @@ async function * intoAsyncIterator (payload) {
|
|
|
90
104
|
yield payload
|
|
91
105
|
}
|
|
92
106
|
|
|
93
|
-
module.exports = { isZstd, isGzip, isDeflate, isStream, intoAsyncIterator }
|
|
107
|
+
module.exports = { isZstd, isGzip, isDeflate, isStream, intoAsyncIterator, isWebReadableStream, isFetchResponse, webStreamToNodeReadable }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fastify/compress",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.3.0",
|
|
4
4
|
"description": "Fastify compression utils",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -16,16 +16,15 @@
|
|
|
16
16
|
"readable-stream": "^4.5.2"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
|
-
"@
|
|
20
|
-
"@types/node": "^22.0.0",
|
|
19
|
+
"@types/node": "^24.0.8",
|
|
21
20
|
"adm-zip": "^0.5.12",
|
|
22
21
|
"c8": "^10.1.2",
|
|
23
22
|
"eslint": "^9.17.0",
|
|
24
23
|
"fastify": "^5.0.0",
|
|
25
24
|
"jsonstream": "^1.0.3",
|
|
26
25
|
"neostandard": "^0.12.0",
|
|
27
|
-
"tsd": "^0.
|
|
28
|
-
"typescript": "~5.
|
|
26
|
+
"tsd": "^0.33.0",
|
|
27
|
+
"typescript": "~5.9.2"
|
|
29
28
|
},
|
|
30
29
|
"scripts": {
|
|
31
30
|
"lint": "eslint",
|
|
@@ -84,7 +83,7 @@
|
|
|
84
83
|
"url": "git+https://github.com/fastify/fastify-compress.git"
|
|
85
84
|
},
|
|
86
85
|
"tsd": {
|
|
87
|
-
"directory": "
|
|
86
|
+
"directory": "types"
|
|
88
87
|
},
|
|
89
88
|
"publishConfig": {
|
|
90
89
|
"access": "public"
|
|
@@ -9,6 +9,7 @@ const JSONStream = require('jsonstream')
|
|
|
9
9
|
const Fastify = require('fastify')
|
|
10
10
|
const compressPlugin = require('../index')
|
|
11
11
|
const { once } = require('node:events')
|
|
12
|
+
const { ReadableStream: WebReadableStream, Response } = globalThis
|
|
12
13
|
|
|
13
14
|
describe('When `global` is not set, it is `true` by default :', async () => {
|
|
14
15
|
test('it should compress Buffer data using brotli when `Accept-Encoding` request header is `br`', async (t) => {
|
|
@@ -265,6 +266,54 @@ describe('When `global` is not set, it is `true` by default :', async () => {
|
|
|
265
266
|
const payload = zlib.gunzipSync(response.rawPayload)
|
|
266
267
|
t.assert.equal(payload.toString('utf-8'), 'hello')
|
|
267
268
|
})
|
|
269
|
+
|
|
270
|
+
test('it should compress a Fetch API Response body', async (t) => {
|
|
271
|
+
t.plan(1)
|
|
272
|
+
|
|
273
|
+
const fastify = Fastify()
|
|
274
|
+
await fastify.register(compressPlugin, { threshold: 0 })
|
|
275
|
+
|
|
276
|
+
const body = 'hello from fetch response'
|
|
277
|
+
fastify.get('/fetch-resp', (_request, reply) => {
|
|
278
|
+
const resp = new Response(body, { headers: { 'content-type': 'text/plain' } })
|
|
279
|
+
reply.send(resp)
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
const response = await fastify.inject({
|
|
283
|
+
url: '/fetch-resp',
|
|
284
|
+
method: 'GET',
|
|
285
|
+
headers: { 'accept-encoding': 'gzip' }
|
|
286
|
+
})
|
|
287
|
+
const payload = zlib.gunzipSync(response.rawPayload)
|
|
288
|
+
t.assert.equal(payload.toString('utf-8'), body)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
test('it should compress a Web ReadableStream body', async (t) => {
|
|
292
|
+
t.plan(1)
|
|
293
|
+
|
|
294
|
+
const fastify = Fastify()
|
|
295
|
+
await fastify.register(compressPlugin, { threshold: 0 })
|
|
296
|
+
|
|
297
|
+
const body = 'hello from web stream'
|
|
298
|
+
fastify.get('/web-stream', (_request, reply) => {
|
|
299
|
+
const stream = new WebReadableStream({
|
|
300
|
+
start (controller) {
|
|
301
|
+
controller.enqueue(Buffer.from(body))
|
|
302
|
+
controller.close()
|
|
303
|
+
}
|
|
304
|
+
})
|
|
305
|
+
reply.header('content-type', 'text/plain')
|
|
306
|
+
reply.send(stream)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
const response = await fastify.inject({
|
|
310
|
+
url: '/web-stream',
|
|
311
|
+
method: 'GET',
|
|
312
|
+
headers: { 'accept-encoding': 'gzip' }
|
|
313
|
+
})
|
|
314
|
+
const payload = zlib.gunzipSync(response.rawPayload)
|
|
315
|
+
t.assert.equal(payload.toString('utf-8'), body)
|
|
316
|
+
})
|
|
268
317
|
})
|
|
269
318
|
|
|
270
319
|
describe('It should send compressed Stream data when `global` is `true` :', async () => {
|
|
@@ -3350,3 +3399,152 @@ for (const contentType of notByDefaultSupportedContentTypes) {
|
|
|
3350
3399
|
t.assert.equal(response.rawPayload.toString('utf-8'), file)
|
|
3351
3400
|
})
|
|
3352
3401
|
}
|
|
3402
|
+
|
|
3403
|
+
test('It should support separate globalCompression and globalDecompression settings', async (t) => {
|
|
3404
|
+
t.plan(3)
|
|
3405
|
+
|
|
3406
|
+
// Test case: disable compression but enable decompression globally
|
|
3407
|
+
const fastify = Fastify()
|
|
3408
|
+
await fastify.register(compressPlugin, {
|
|
3409
|
+
globalCompression: false,
|
|
3410
|
+
globalDecompression: true
|
|
3411
|
+
})
|
|
3412
|
+
|
|
3413
|
+
const testData = { message: 'test data for decompression only' }
|
|
3414
|
+
|
|
3415
|
+
fastify.get('/', (request, reply) => {
|
|
3416
|
+
reply.send(testData)
|
|
3417
|
+
})
|
|
3418
|
+
|
|
3419
|
+
// Test that compression is disabled
|
|
3420
|
+
const getResponse = await fastify.inject({
|
|
3421
|
+
url: '/',
|
|
3422
|
+
method: 'GET',
|
|
3423
|
+
headers: {
|
|
3424
|
+
'accept-encoding': 'gzip, deflate, br'
|
|
3425
|
+
}
|
|
3426
|
+
})
|
|
3427
|
+
|
|
3428
|
+
t.assert.equal(getResponse.statusCode, 200)
|
|
3429
|
+
// No compression should happen since globalCompression is false
|
|
3430
|
+
t.assert.equal(getResponse.headers['content-encoding'], undefined)
|
|
3431
|
+
t.assert.deepEqual(getResponse.json(), testData)
|
|
3432
|
+
})
|
|
3433
|
+
|
|
3434
|
+
test('It should enable decompression when globalDecompression is true', async (t) => {
|
|
3435
|
+
t.plan(2)
|
|
3436
|
+
|
|
3437
|
+
// Test case: enable decompression globally
|
|
3438
|
+
const fastify = Fastify()
|
|
3439
|
+
await fastify.register(compressPlugin, {
|
|
3440
|
+
globalCompression: false,
|
|
3441
|
+
globalDecompression: true
|
|
3442
|
+
})
|
|
3443
|
+
|
|
3444
|
+
// Create a compressed request payload to test decompression
|
|
3445
|
+
const compressedPayload = zlib.gzipSync(JSON.stringify({ input: 'compressed data' }))
|
|
3446
|
+
|
|
3447
|
+
fastify.post('/', (request, reply) => {
|
|
3448
|
+
// This should be decompressed automatically
|
|
3449
|
+
reply.send({ received: request.body })
|
|
3450
|
+
})
|
|
3451
|
+
|
|
3452
|
+
// Test that decompression is enabled
|
|
3453
|
+
const postResponse = await fastify.inject({
|
|
3454
|
+
url: '/',
|
|
3455
|
+
method: 'POST',
|
|
3456
|
+
headers: {
|
|
3457
|
+
'content-encoding': 'gzip',
|
|
3458
|
+
'content-type': 'application/json'
|
|
3459
|
+
},
|
|
3460
|
+
payload: compressedPayload
|
|
3461
|
+
})
|
|
3462
|
+
|
|
3463
|
+
t.assert.equal(postResponse.statusCode, 200)
|
|
3464
|
+
// Decompression should work since globalDecompression is true
|
|
3465
|
+
t.assert.deepEqual(postResponse.json(), { received: { input: 'compressed data' } })
|
|
3466
|
+
})
|
|
3467
|
+
|
|
3468
|
+
test('It should fall back to global option when specific options are not provided', async (t) => {
|
|
3469
|
+
t.plan(2)
|
|
3470
|
+
|
|
3471
|
+
// Test that global: false affects both compression and decompression
|
|
3472
|
+
const fastify = Fastify()
|
|
3473
|
+
await fastify.register(compressPlugin, {
|
|
3474
|
+
global: false
|
|
3475
|
+
})
|
|
3476
|
+
|
|
3477
|
+
const testData = { message: 'test data' }
|
|
3478
|
+
|
|
3479
|
+
fastify.get('/', (request, reply) => {
|
|
3480
|
+
reply.send(testData)
|
|
3481
|
+
})
|
|
3482
|
+
|
|
3483
|
+
const response = await fastify.inject({
|
|
3484
|
+
url: '/',
|
|
3485
|
+
method: 'GET',
|
|
3486
|
+
headers: {
|
|
3487
|
+
'accept-encoding': 'gzip, deflate, br'
|
|
3488
|
+
}
|
|
3489
|
+
})
|
|
3490
|
+
|
|
3491
|
+
t.assert.equal(response.statusCode, 200)
|
|
3492
|
+
// No compression should happen since global is false
|
|
3493
|
+
t.assert.equal(response.headers['content-encoding'], undefined)
|
|
3494
|
+
})
|
|
3495
|
+
|
|
3496
|
+
test('It should demonstrate globalCompression overrides global setting', async (t) => {
|
|
3497
|
+
t.plan(2)
|
|
3498
|
+
|
|
3499
|
+
// Test that globalCompression: true overrides global: false
|
|
3500
|
+
const fastify = Fastify()
|
|
3501
|
+
await fastify.register(compressPlugin, {
|
|
3502
|
+
global: false,
|
|
3503
|
+
globalCompression: true,
|
|
3504
|
+
threshold: 0
|
|
3505
|
+
})
|
|
3506
|
+
|
|
3507
|
+
fastify.get('/', (request, reply) => {
|
|
3508
|
+
reply.send({ message: 'globalCompression overrides global' })
|
|
3509
|
+
})
|
|
3510
|
+
|
|
3511
|
+
const response = await fastify.inject({
|
|
3512
|
+
url: '/',
|
|
3513
|
+
method: 'GET',
|
|
3514
|
+
headers: { 'accept-encoding': 'gzip' }
|
|
3515
|
+
})
|
|
3516
|
+
|
|
3517
|
+
t.assert.equal(response.statusCode, 200)
|
|
3518
|
+
t.assert.equal(response.headers['content-encoding'], 'gzip') // Compression enabled despite global: false
|
|
3519
|
+
})
|
|
3520
|
+
|
|
3521
|
+
test('It should demonstrate globalDecompression controls decompression independently', async (t) => {
|
|
3522
|
+
t.plan(2)
|
|
3523
|
+
|
|
3524
|
+
// Test globalDecompression: false disables decompression even with global: true
|
|
3525
|
+
const fastify = Fastify()
|
|
3526
|
+
await fastify.register(compressPlugin, {
|
|
3527
|
+
global: true,
|
|
3528
|
+
globalDecompression: false
|
|
3529
|
+
})
|
|
3530
|
+
|
|
3531
|
+
const compressedPayload = zlib.gzipSync(JSON.stringify({ test: 'decompression' }))
|
|
3532
|
+
|
|
3533
|
+
fastify.post('/', (request, reply) => {
|
|
3534
|
+
reply.send({ received: request.body })
|
|
3535
|
+
})
|
|
3536
|
+
|
|
3537
|
+
const response = await fastify.inject({
|
|
3538
|
+
url: '/',
|
|
3539
|
+
method: 'POST',
|
|
3540
|
+
headers: {
|
|
3541
|
+
'content-encoding': 'gzip',
|
|
3542
|
+
'content-type': 'application/json'
|
|
3543
|
+
},
|
|
3544
|
+
payload: compressedPayload
|
|
3545
|
+
})
|
|
3546
|
+
|
|
3547
|
+
// Should get 400 error since decompression is disabled and compressed data can't be parsed
|
|
3548
|
+
t.assert.equal(response.statusCode, 400)
|
|
3549
|
+
t.assert.ok(response.body.includes('Content-Length') || response.body.includes('Bad Request'))
|
|
3550
|
+
})
|
|
@@ -440,3 +440,33 @@ test('It should avoid to trigger `onSend` hook twice', async (t) => {
|
|
|
440
440
|
})
|
|
441
441
|
t.assert.deepEqual(JSON.parse(zlib.brotliDecompressSync(response.rawPayload)), { hi: true })
|
|
442
442
|
})
|
|
443
|
+
|
|
444
|
+
test('reply.compress should handle Fetch Response', async (t) => {
|
|
445
|
+
t.plan(1)
|
|
446
|
+
const fastify = Fastify()
|
|
447
|
+
await fastify.register(compressPlugin, { global: true, threshold: 0 })
|
|
448
|
+
fastify.get('/', (_req, reply) => {
|
|
449
|
+
const r = new Response('from reply.compress', { headers: { 'content-type': 'text/plain' } })
|
|
450
|
+
reply.compress(r)
|
|
451
|
+
})
|
|
452
|
+
const res = await fastify.inject({ url: '/', method: 'GET', headers: { 'accept-encoding': 'gzip' } })
|
|
453
|
+
t.assert.equal(zlib.gunzipSync(res.rawPayload).toString('utf8'), 'from reply.compress')
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
test('reply.compress should handle Web ReadableStream', async (t) => {
|
|
457
|
+
t.plan(1)
|
|
458
|
+
const fastify = Fastify()
|
|
459
|
+
await fastify.register(compressPlugin, { global: true, threshold: 0 })
|
|
460
|
+
fastify.get('/', (_req, reply) => {
|
|
461
|
+
const stream = new ReadableStream({
|
|
462
|
+
start (controller) {
|
|
463
|
+
controller.enqueue(Buffer.from('from webstream'))
|
|
464
|
+
controller.close()
|
|
465
|
+
}
|
|
466
|
+
})
|
|
467
|
+
reply.header('content-type', 'text/plain')
|
|
468
|
+
reply.compress(stream)
|
|
469
|
+
})
|
|
470
|
+
const res = await fastify.inject({ url: '/', method: 'GET', headers: { 'accept-encoding': 'gzip' } })
|
|
471
|
+
t.assert.equal(zlib.gunzipSync(res.rawPayload).toString('utf8'), 'from webstream')
|
|
472
|
+
})
|
package/types/index.d.ts
CHANGED
|
@@ -26,7 +26,7 @@ declare module 'fastify' {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
interface FastifyReply {
|
|
29
|
-
compress(input: Stream | Input): void;
|
|
29
|
+
compress(input: Stream | Input | Response | ReadableStream): void;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export interface RouteOptions {
|
|
@@ -77,6 +77,8 @@ declare namespace fastifyCompress {
|
|
|
77
77
|
encodings?: EncodingToken[];
|
|
78
78
|
forceRequestEncoding?: EncodingToken;
|
|
79
79
|
global?: boolean;
|
|
80
|
+
globalDecompression?: boolean;
|
|
81
|
+
globalCompression?: boolean;
|
|
80
82
|
inflateIfDeflated?: boolean;
|
|
81
83
|
onInvalidRequestPayload?: (encoding: string, request: FastifyRequest, error: Error) => Error | undefined | null;
|
|
82
84
|
onUnsupportedEncoding?: (encoding: string, request: FastifyRequest, reply: FastifyReply) => string | Buffer | Stream;
|
package/types/index.test-d.ts
CHANGED
|
@@ -132,3 +132,18 @@ expectError(appThatTriggerAnError.register(fastifyCompress, {
|
|
|
132
132
|
global: true,
|
|
133
133
|
thisOptionDoesNotExist: 'trigger a typescript error'
|
|
134
134
|
}))
|
|
135
|
+
|
|
136
|
+
app.get('/ts-fetch-response', async (_request, reply) => {
|
|
137
|
+
const resp = new Response('ok', { headers: { 'content-type': 'text/plain' } })
|
|
138
|
+
expectType<void>(reply.compress(resp))
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
app.get('/ts-web-readable-stream', async (_request, reply) => {
|
|
142
|
+
const stream = new ReadableStream({
|
|
143
|
+
start (controller) {
|
|
144
|
+
controller.enqueue(new Uint8Array([1, 2, 3]))
|
|
145
|
+
controller.close()
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
expectType<void>(reply.compress(stream))
|
|
149
|
+
})
|
package/CLAUDE.md
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
# CLAUDE.md
|
|
2
|
-
|
|
3
|
-
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
-
|
|
5
|
-
## Development Commands
|
|
6
|
-
|
|
7
|
-
### Testing
|
|
8
|
-
- `npm run test` - Run both unit and TypeScript tests
|
|
9
|
-
- `npm run test:unit` - Run unit tests with Node.js test runner
|
|
10
|
-
- `npm run test:coverage` - Run tests with coverage reporting
|
|
11
|
-
- `npm run lint` - Run ESLint for code style checking
|
|
12
|
-
- `npm run lint:fix` - Auto-fix linting issues
|
|
13
|
-
|
|
14
|
-
### Running Single Tests
|
|
15
|
-
Use Node.js test runner with file path:
|
|
16
|
-
```bash
|
|
17
|
-
node --test test/global-compress.test.js
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
## Architecture Overview
|
|
21
|
-
|
|
22
|
-
This is a Fastify plugin (`@fastify/compress`) that provides compression and decompression capabilities for HTTP requests and responses.
|
|
23
|
-
|
|
24
|
-
### Core Components
|
|
25
|
-
|
|
26
|
-
**Main Plugin (`index.js`)**: 574-line plugin implementation using Fastify's hook system:
|
|
27
|
-
- `onSend` hook for response compression
|
|
28
|
-
- `preParsing` hook for request decompression
|
|
29
|
-
- `onRoute` hook for route-level configuration
|
|
30
|
-
|
|
31
|
-
**Utilities (`lib/utils.js`)**: Core compression detection and stream handling:
|
|
32
|
-
- `isZstd()` / `isGzip()` / `isDeflate()` - Magic byte detection (RFC 8878/1952/1950)
|
|
33
|
-
- `isStream()` - Stream type checking
|
|
34
|
-
- `intoAsyncIterator()` - Payload conversion utilities
|
|
35
|
-
|
|
36
|
-
**TypeScript Support (`types/index.d.ts`)**: Full type definitions with Fastify module augmentation
|
|
37
|
-
|
|
38
|
-
### Compression System
|
|
39
|
-
|
|
40
|
-
**Supported Encodings**: zstd (Node.js 22.15+/23.8+), brotli (`br`), gzip, deflate, identity
|
|
41
|
-
**Priority Order**: zstd > br > gzip > deflate > * (defaults to gzip) > identity
|
|
42
|
-
**Threshold-based**: Default 1024 bytes minimum for compression
|
|
43
|
-
**Content-Type Aware**: Uses `mime-db` for compressible content detection
|
|
44
|
-
|
|
45
|
-
### Configuration Levels
|
|
46
|
-
1. **Global**: Applied to all routes via plugin options
|
|
47
|
-
2. **Route-level**: Override global settings in route config
|
|
48
|
-
3. **Runtime**: Manual compression via `reply.compress()`
|
|
49
|
-
|
|
50
|
-
### Key Dependencies
|
|
51
|
-
- `@fastify/accept-negotiator` - Content encoding negotiation
|
|
52
|
-
- `fastify-plugin` - Plugin registration
|
|
53
|
-
- `mime-db` - MIME type database
|
|
54
|
-
- `minipass`, `pump`, `pumpify` - Stream processing
|
|
55
|
-
|
|
56
|
-
### Testing Structure
|
|
57
|
-
Uses Node.js built-in test runner with describe/test pattern. Test files are organized by functionality:
|
|
58
|
-
- `global-compress.test.js` / `global-decompress.test.js` - Global functionality
|
|
59
|
-
- `routes-compress.test.js` / `routes-decompress.test.js` - Route-level functionality
|
|
60
|
-
- `utils.test.js` - Utility functions
|
|
61
|
-
- `regression/` - Regression tests
|
|
62
|
-
|
|
63
|
-
### Code Style
|
|
64
|
-
Uses `neostandard` (ESLint-based) with TypeScript support. Configuration in `eslint.config.js`.
|