Skip to content

Commit 0276b86

Browse files
mcollinaclaude
andauthored
feat: add support for zstd compression (#366)
* feat: add support for zstd compression - Add conditional zstd support for Node.js 22.15+/23.8+ - Implement zstd compression/decompression streams - Add isZstd() utility for magic byte detection (RFC 8878) - Update TypeScript definitions to include 'zstd' encoding - Add comprehensive tests for zstd compression and decompression - Update package.json keywords and documentation - Maintain backward compatibility with older Node.js versions Fixes #365 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Matteo Collina <hello@matteocollina.com> * docs: update README to include zstd compression support - Add zstd to supported encodings list with Node.js version requirements - Update encoding priority order to include zstd as highest priority - Add examples showing zstd usage for both response compression and request decompression - Document zstd availability for Node.js 22.15+/23.8+ 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Matteo Collina <hello@matteocollina.com> --------- Signed-off-by: Matteo Collina <hello@matteocollina.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 46730ef commit 0276b86

File tree

9 files changed

+172
-13
lines changed

9 files changed

+172
-13
lines changed

README.md

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
[![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-brightgreen?style=flat)](https://github.com/neostandard/neostandard)
66

77
Adds compression utils to [the Fastify `reply` object](https://fastify.dev/docs/latest/Reference/Reply/#reply) and a hook to decompress requests payloads.
8-
Supports `gzip`, `deflate`, and `brotli`.
8+
Supports `gzip`, `deflate`, `brotli`, and `zstd` (Node.js 22.15+/23.8+).
99

1010
> ℹ️ Note: In large-scale scenarios, use a proxy like Nginx to handle response compression.
1111
@@ -37,11 +37,12 @@ This plugin adds two functionalities to Fastify: a compress utility and a global
3737

3838
Currently, the following encoding tokens are supported, using the first acceptable token in this order:
3939

40-
1. `br`
41-
2. `gzip`
42-
3. `deflate`
43-
4. `*` (no preference — `@fastify/compress` will use `gzip`)
44-
5. `identity` (no compression)
40+
1. `zstd` (Node.js 22.15+/23.8+)
41+
2. `br`
42+
3. `gzip`
43+
4. `deflate`
44+
5. `*` (no preference — `@fastify/compress` will use `gzip`)
45+
6. `identity` (no compression)
4546

4647
If an unsupported encoding is received or the `'accept-encoding'` header is missing, the payload will not be compressed.
4748
To return an error for unsupported encoding, use the `onUnsupportedEncoding` option.
@@ -175,6 +176,13 @@ await fastify.register(
175176
// Only support gzip and deflate, and prefer deflate to gzip
176177
{ encodings: ['deflate', 'gzip'] }
177178
)
179+
180+
// Example with zstd support (Node.js 22.15+/23.8+)
181+
await fastify.register(
182+
import('@fastify/compress'),
183+
// Prefer zstd, fallback to brotli, then gzip
184+
{ encodings: ['zstd', 'br', 'gzip'] }
185+
)
178186
```
179187
180188
### brotliOptions and zlibOptions
@@ -214,9 +222,10 @@ This plugin adds a `preParsing` hook to decompress the request payload based on
214222
215223
Currently, the following encoding tokens are supported:
216224
217-
1. `br`
218-
2. `gzip`
219-
3. `deflate`
225+
1. `zstd` (Node.js 22.15+/23.8+)
226+
2. `br`
227+
3. `gzip`
228+
4. `deflate`
220229
221230
If an unsupported encoding or invalid payload is received, the plugin throws an error.
222231
@@ -268,6 +277,13 @@ await fastify.register(
268277
// Only support gzip
269278
{ requestEncodings: ['gzip'] }
270279
)
280+
281+
// Example with zstd support for request decompression (Node.js 22.15+/23.8+)
282+
await fastify.register(
283+
import('@fastify/compress'),
284+
// Support zstd, brotli and gzip for request decompression
285+
{ requestEncodings: ['zstd', 'br', 'gzip'] }
286+
)
271287
```
272288
273289
### forceRequestEncoding

index.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,15 +145,26 @@ function processCompressParams (opts) {
145145
gzip: () => ((opts.zlib || zlib).createGzip || zlib.createGzip)(params.zlibOptions),
146146
deflate: () => ((opts.zlib || zlib).createDeflate || zlib.createDeflate)(params.zlibOptions)
147147
}
148+
if (typeof ((opts.zlib || zlib).createZstdCompress || zlib.createZstdCompress) === 'function') {
149+
params.compressStream.zstd = () => ((opts.zlib || zlib).createZstdCompress || zlib.createZstdCompress)(params.zlibOptions)
150+
}
148151
params.uncompressStream = {
149152
// Currently params.uncompressStream.br() is never called as we do not have any way to autodetect brotli compression in `fastify-compress`
150153
// Brotli documentation reference: [RFC 7932](https://www.rfc-editor.org/rfc/rfc7932)
151154
br: /* c8 ignore next */ () => ((opts.zlib || zlib).createBrotliDecompress || zlib.createBrotliDecompress)(params.brotliOptions),
152155
gzip: () => ((opts.zlib || zlib).createGunzip || zlib.createGunzip)(params.zlibOptions),
153156
deflate: () => ((opts.zlib || zlib).createInflate || zlib.createInflate)(params.zlibOptions)
154157
}
158+
if (typeof ((opts.zlib || zlib).createZstdDecompress || zlib.createZstdDecompress) === 'function') {
159+
// Currently params.uncompressStream.zstd() is never called as we do not have any way to autodetect zstd compression in `fastify-compress`
160+
// Zstd documentation reference: [RFC 8878](https://www.rfc-editor.org/rfc/rfc8878)
161+
params.uncompressStream.zstd = /* c8 ignore next */ () => ((opts.zlib || zlib).createZstdDecompress || zlib.createZstdDecompress)(params.zlibOptions)
162+
}
155163

156164
const supportedEncodings = ['br', 'gzip', 'deflate', 'identity']
165+
if (typeof zlib.createZstdCompress === 'function') {
166+
supportedEncodings.unshift('zstd')
167+
}
157168

158169
params.encodings = Array.isArray(opts.encodings)
159170
? supportedEncodings
@@ -184,8 +195,14 @@ function processDecompressParams (opts) {
184195
encodings: [],
185196
forceEncoding: null
186197
}
198+
if (typeof (customZlib.createZstdDecompress || zlib.createZstdDecompress) === 'function') {
199+
params.decompressStream.zstd = customZlib.createZstdDecompress || zlib.createZstdDecompress
200+
}
187201

188202
const supportedEncodings = ['br', 'gzip', 'deflate', 'identity']
203+
if (typeof zlib.createZstdCompress === 'function') {
204+
supportedEncodings.unshift('zstd')
205+
}
189206

190207
params.encodings = Array.isArray(opts.requestEncodings)
191208
? supportedEncodings

lib/utils.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
'use strict'
22

3+
// https://datatracker.ietf.org/doc/html/rfc8878#section-3.1.1
4+
function isZstd (buffer) {
5+
return (
6+
typeof buffer === 'object' &&
7+
buffer !== null &&
8+
buffer.length > 3 &&
9+
// Zstd magic number: 0xFD2FB528 (little-endian)
10+
buffer[0] === 0x28 &&
11+
buffer[1] === 0xb5 &&
12+
buffer[2] === 0x2f &&
13+
buffer[3] === 0xfd
14+
)
15+
}
16+
317
// https://datatracker.ietf.org/doc/html/rfc1950#section-2
418
function isDeflate (buffer) {
519
return (
@@ -76,4 +90,4 @@ async function * intoAsyncIterator (payload) {
7690
yield payload
7791
}
7892

79-
module.exports = { isGzip, isDeflate, isStream, intoAsyncIterator }
93+
module.exports = { isZstd, isGzip, isDeflate, isStream, intoAsyncIterator }

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@
4141
"compression",
4242
"deflate",
4343
"gzip",
44-
"brotli"
44+
"brotli",
45+
"zstd"
4546
],
4647
"author": "Tomas Della Vedova - @delvedor (http://delved.org)",
4748
"contributors": [

test/global-compress.test.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,32 @@ describe('When `global` is not set, it is `true` by default :', async () => {
3333
t.assert.equal(payload.toString('utf-8'), buf.toString())
3434
})
3535

36+
test('it should compress Buffer data using zstd when `Accept-Encoding` request header is `zstd`', async (t) => {
37+
if (typeof zlib.createZstdCompress !== 'function') {
38+
t.skip('zstd not supported in this Node.js version')
39+
return
40+
}
41+
t.plan(1)
42+
43+
const fastify = Fastify()
44+
await fastify.register(compressPlugin, { threshold: 0 })
45+
46+
const buf = Buffer.from('hello world')
47+
fastify.get('/', (_request, reply) => {
48+
reply.send(buf)
49+
})
50+
51+
const response = await fastify.inject({
52+
url: '/',
53+
method: 'GET',
54+
headers: {
55+
'accept-encoding': 'zstd'
56+
}
57+
})
58+
const payload = zlib.zstdDecompressSync(response.rawPayload)
59+
t.assert.equal(payload.toString('utf-8'), buf.toString())
60+
})
61+
3662
test('it should compress Buffer data using deflate when `Accept-Encoding` request header is `deflate`', async (t) => {
3763
t.plan(1)
3864

@@ -100,6 +126,32 @@ describe('When `global` is not set, it is `true` by default :', async () => {
100126
t.assert.equal(payload.toString('utf-8'), JSON.stringify(json))
101127
})
102128

129+
test('it should compress JSON data using zstd when `Accept-Encoding` request header is `zstd`', async (t) => {
130+
if (typeof zlib.createZstdCompress !== 'function') {
131+
t.skip('zstd not supported in this Node.js version')
132+
return
133+
}
134+
t.plan(1)
135+
136+
const fastify = Fastify()
137+
await fastify.register(compressPlugin, { threshold: 0 })
138+
139+
const json = { hello: 'world' }
140+
fastify.get('/', (_request, reply) => {
141+
reply.send(json)
142+
})
143+
144+
const response = await fastify.inject({
145+
url: '/',
146+
method: 'GET',
147+
headers: {
148+
'accept-encoding': 'zstd'
149+
}
150+
})
151+
const payload = zlib.zstdDecompressSync(response.rawPayload)
152+
t.assert.equal(payload.toString('utf-8'), JSON.stringify(json))
153+
})
154+
103155
test('it should compress JSON data using deflate when `Accept-Encoding` request header is `deflate`', async (t) => {
104156
t.plan(1)
105157

test/global-decompress.test.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,33 @@ describe('It should decompress the request payload :', async () => {
4242
t.assert.equal(response.body, '@fastify/compress')
4343
})
4444

45+
test('using zstd algorithm when `Content-Encoding` request header value is set to `zstd`', async (t) => {
46+
if (typeof zlib.createZstdCompress !== 'function') {
47+
t.skip('zstd not supported in this Node.js version')
48+
return
49+
}
50+
t.plan(2)
51+
52+
const fastify = Fastify()
53+
await fastify.register(compressPlugin)
54+
55+
fastify.post('/', (request, reply) => {
56+
reply.send(request.body.name)
57+
})
58+
59+
const response = await fastify.inject({
60+
url: '/',
61+
method: 'POST',
62+
headers: {
63+
'content-type': 'application/json',
64+
'content-encoding': 'zstd'
65+
},
66+
payload: createPayload(zlib.createZstdCompress)
67+
})
68+
t.assert.equal(response.statusCode, 200)
69+
t.assert.equal(response.body, '@fastify/compress')
70+
})
71+
4572
test('using deflate algorithm when `Content-Encoding` request header value is set to `deflate`', async (t) => {
4673
t.plan(2)
4774

test/utils.test.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const { createReadStream } = require('node:fs')
44
const { Socket } = require('node:net')
55
const { Duplex, PassThrough, Readable, Stream, Transform, Writable } = require('node:stream')
66
const { test } = require('node:test')
7-
const { isStream, isDeflate, isGzip, intoAsyncIterator } = require('../lib/utils')
7+
const { isStream, isZstd, isDeflate, isGzip, intoAsyncIterator } = require('../lib/utils')
88

99
test('isStream() utility should be able to detect Streams', async (t) => {
1010
t.plan(12)
@@ -48,6 +48,23 @@ test('isDeflate() utility should be able to detect deflate compressed Buffer', a
4848
equal(isDeflate(''), false)
4949
})
5050

51+
test('isZstd() utility should be able to detect zstd compressed Buffer', async (t) => {
52+
t.plan(10)
53+
const equal = t.assert.equal
54+
55+
equal(isZstd(Buffer.alloc(0)), false)
56+
equal(isZstd(Buffer.alloc(1)), false)
57+
equal(isZstd(Buffer.alloc(2)), false)
58+
equal(isZstd(Buffer.alloc(3)), false)
59+
equal(isZstd(Buffer.from([0x28, 0xb5, 0x2f])), false)
60+
equal(isZstd(Buffer.from([0x28, 0xb5, 0x2f, 0xfd])), true)
61+
62+
equal(isZstd({}), false)
63+
equal(isZstd(null), false)
64+
equal(isZstd(undefined), false)
65+
equal(isZstd(''), false)
66+
})
67+
5168
test('isGzip() utility should be able to detect gzip compressed Buffer', async (t) => {
5269
t.plan(10)
5370
const equal = t.assert.equal

types/index.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ type RouteDecompressOptions = Pick<fastifyCompress.FastifyCompressOptions,
5757
| 'zlib'
5858
>
5959

60-
type EncodingToken = 'br' | 'deflate' | 'gzip' | 'identity'
60+
type EncodingToken = 'zstd' | 'br' | 'deflate' | 'gzip' | 'identity'
6161

6262
type CompressibleContentTypeFunction = (contentType: string) => boolean
6363

types/index.test-d.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,14 @@ const withGlobalOptions: FastifyCompressOptions = {
2525
removeContentLengthHeader: true
2626
}
2727

28+
const withZstdOptions: FastifyCompressOptions = {
29+
encodings: ['zstd', 'br', 'gzip', 'deflate', 'identity'],
30+
requestEncodings: ['zstd', 'br', 'gzip', 'deflate', 'identity']
31+
}
32+
2833
const app: FastifyInstance = fastify()
2934
app.register(fastifyCompress, withGlobalOptions)
35+
app.register(fastifyCompress, withZstdOptions)
3036

3137
app.register(fastifyCompress, {
3238
customTypes: value => value === 'application/json'
@@ -111,6 +117,15 @@ appWithoutGlobal.inject(
111117
}
112118
)
113119

120+
// Test that invalid encoding values trigger TypeScript errors
121+
expectError(fastify().register(fastifyCompress, {
122+
encodings: ['invalid-encoding']
123+
}))
124+
125+
expectError(fastify().register(fastifyCompress, {
126+
requestEncodings: ['another-invalid-encoding']
127+
}))
128+
114129
// Instantiation of an app that should trigger a typescript error
115130
const appThatTriggerAnError = fastify()
116131
expectError(appThatTriggerAnError.register(fastifyCompress, {

0 commit comments

Comments
 (0)