Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 25 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
[![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-brightgreen?style=flat)](https://github.com/neostandard/neostandard)

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

> ℹ️ Note: In large-scale scenarios, use a proxy like Nginx to handle response compression.

Expand Down Expand Up @@ -37,11 +37,12 @@ This plugin adds two functionalities to Fastify: a compress utility and a global

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

1. `br`
2. `gzip`
3. `deflate`
4. `*` (no preference — `@fastify/compress` will use `gzip`)
5. `identity` (no compression)
1. `zstd` (Node.js 22.15+/23.8+)
2. `br`
3. `gzip`
4. `deflate`
5. `*` (no preference — `@fastify/compress` will use `gzip`)
6. `identity` (no compression)

If an unsupported encoding is received or the `'accept-encoding'` header is missing, the payload will not be compressed.
To return an error for unsupported encoding, use the `onUnsupportedEncoding` option.
Expand Down Expand Up @@ -175,6 +176,13 @@ await fastify.register(
// Only support gzip and deflate, and prefer deflate to gzip
{ encodings: ['deflate', 'gzip'] }
)

// Example with zstd support (Node.js 22.15+/23.8+)
await fastify.register(
import('@fastify/compress'),
// Prefer zstd, fallback to brotli, then gzip
{ encodings: ['zstd', 'br', 'gzip'] }
)
```

### brotliOptions and zlibOptions
Expand Down Expand Up @@ -214,9 +222,10 @@ This plugin adds a `preParsing` hook to decompress the request payload based on

Currently, the following encoding tokens are supported:

1. `br`
2. `gzip`
3. `deflate`
1. `zstd` (Node.js 22.15+/23.8+)
2. `br`
3. `gzip`
4. `deflate`

If an unsupported encoding or invalid payload is received, the plugin throws an error.

Expand Down Expand Up @@ -268,6 +277,13 @@ await fastify.register(
// Only support gzip
{ requestEncodings: ['gzip'] }
)

// Example with zstd support for request decompression (Node.js 22.15+/23.8+)
await fastify.register(
import('@fastify/compress'),
// Support zstd, brotli and gzip for request decompression
{ requestEncodings: ['zstd', 'br', 'gzip'] }
)
```

### forceRequestEncoding
Expand Down
17 changes: 17 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,15 +145,26 @@ function processCompressParams (opts) {
gzip: () => ((opts.zlib || zlib).createGzip || zlib.createGzip)(params.zlibOptions),
deflate: () => ((opts.zlib || zlib).createDeflate || zlib.createDeflate)(params.zlibOptions)
}
if (typeof ((opts.zlib || zlib).createZstdCompress || zlib.createZstdCompress) === 'function') {
params.compressStream.zstd = () => ((opts.zlib || zlib).createZstdCompress || zlib.createZstdCompress)(params.zlibOptions)
}
params.uncompressStream = {
// Currently params.uncompressStream.br() is never called as we do not have any way to autodetect brotli compression in `fastify-compress`
// Brotli documentation reference: [RFC 7932](https://www.rfc-editor.org/rfc/rfc7932)
br: /* c8 ignore next */ () => ((opts.zlib || zlib).createBrotliDecompress || zlib.createBrotliDecompress)(params.brotliOptions),
gzip: () => ((opts.zlib || zlib).createGunzip || zlib.createGunzip)(params.zlibOptions),
deflate: () => ((opts.zlib || zlib).createInflate || zlib.createInflate)(params.zlibOptions)
}
if (typeof ((opts.zlib || zlib).createZstdDecompress || zlib.createZstdDecompress) === 'function') {
// Currently params.uncompressStream.zstd() is never called as we do not have any way to autodetect zstd compression in `fastify-compress`
// Zstd documentation reference: [RFC 8878](https://www.rfc-editor.org/rfc/rfc8878)
params.uncompressStream.zstd = /* c8 ignore next */ () => ((opts.zlib || zlib).createZstdDecompress || zlib.createZstdDecompress)(params.zlibOptions)
}

const supportedEncodings = ['br', 'gzip', 'deflate', 'identity']
if (typeof zlib.createZstdCompress === 'function') {
supportedEncodings.unshift('zstd')
}

params.encodings = Array.isArray(opts.encodings)
? supportedEncodings
Expand Down Expand Up @@ -184,8 +195,14 @@ function processDecompressParams (opts) {
encodings: [],
forceEncoding: null
}
if (typeof (customZlib.createZstdDecompress || zlib.createZstdDecompress) === 'function') {
params.decompressStream.zstd = customZlib.createZstdDecompress || zlib.createZstdDecompress
}

const supportedEncodings = ['br', 'gzip', 'deflate', 'identity']
if (typeof zlib.createZstdCompress === 'function') {
supportedEncodings.unshift('zstd')
}

params.encodings = Array.isArray(opts.requestEncodings)
? supportedEncodings
Expand Down
16 changes: 15 additions & 1 deletion lib/utils.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
'use strict'

// https://datatracker.ietf.org/doc/html/rfc8878#section-3.1.1
function isZstd (buffer) {
return (
typeof buffer === 'object' &&
buffer !== null &&
buffer.length > 3 &&
// Zstd magic number: 0xFD2FB528 (little-endian)
buffer[0] === 0x28 &&
buffer[1] === 0xb5 &&
buffer[2] === 0x2f &&
buffer[3] === 0xfd
)
}

// https://datatracker.ietf.org/doc/html/rfc1950#section-2
function isDeflate (buffer) {
return (
Expand Down Expand Up @@ -76,4 +90,4 @@ async function * intoAsyncIterator (payload) {
yield payload
}

module.exports = { isGzip, isDeflate, isStream, intoAsyncIterator }
module.exports = { isZstd, isGzip, isDeflate, isStream, intoAsyncIterator }
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@
"compression",
"deflate",
"gzip",
"brotli"
"brotli",
"zstd"
],
"author": "Tomas Della Vedova - @delvedor (http://delved.org)",
"contributors": [
Expand Down
52 changes: 52 additions & 0 deletions test/global-compress.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,32 @@ describe('When `global` is not set, it is `true` by default :', async () => {
t.assert.equal(payload.toString('utf-8'), buf.toString())
})

test('it should compress Buffer data using zstd when `Accept-Encoding` request header is `zstd`', async (t) => {
if (typeof zlib.createZstdCompress !== 'function') {
t.skip('zstd not supported in this Node.js version')
return
}
t.plan(1)

const fastify = Fastify()
await fastify.register(compressPlugin, { threshold: 0 })

const buf = Buffer.from('hello world')
fastify.get('/', (_request, reply) => {
reply.send(buf)
})

const response = await fastify.inject({
url: '/',
method: 'GET',
headers: {
'accept-encoding': 'zstd'
}
})
const payload = zlib.zstdDecompressSync(response.rawPayload)
t.assert.equal(payload.toString('utf-8'), buf.toString())
})

test('it should compress Buffer data using deflate when `Accept-Encoding` request header is `deflate`', async (t) => {
t.plan(1)

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

test('it should compress JSON data using zstd when `Accept-Encoding` request header is `zstd`', async (t) => {
if (typeof zlib.createZstdCompress !== 'function') {
t.skip('zstd not supported in this Node.js version')
return
}
t.plan(1)

const fastify = Fastify()
await fastify.register(compressPlugin, { threshold: 0 })

const json = { hello: 'world' }
fastify.get('/', (_request, reply) => {
reply.send(json)
})

const response = await fastify.inject({
url: '/',
method: 'GET',
headers: {
'accept-encoding': 'zstd'
}
})
const payload = zlib.zstdDecompressSync(response.rawPayload)
t.assert.equal(payload.toString('utf-8'), JSON.stringify(json))
})

test('it should compress JSON data using deflate when `Accept-Encoding` request header is `deflate`', async (t) => {
t.plan(1)

Expand Down
27 changes: 27 additions & 0 deletions test/global-decompress.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,33 @@ describe('It should decompress the request payload :', async () => {
t.assert.equal(response.body, '@fastify/compress')
})

test('using zstd algorithm when `Content-Encoding` request header value is set to `zstd`', async (t) => {
if (typeof zlib.createZstdCompress !== 'function') {
t.skip('zstd not supported in this Node.js version')
return
}
t.plan(2)

const fastify = Fastify()
await fastify.register(compressPlugin)

fastify.post('/', (request, reply) => {
reply.send(request.body.name)
})

const response = await fastify.inject({
url: '/',
method: 'POST',
headers: {
'content-type': 'application/json',
'content-encoding': 'zstd'
},
payload: createPayload(zlib.createZstdCompress)
})
t.assert.equal(response.statusCode, 200)
t.assert.equal(response.body, '@fastify/compress')
})

test('using deflate algorithm when `Content-Encoding` request header value is set to `deflate`', async (t) => {
t.plan(2)

Expand Down
19 changes: 18 additions & 1 deletion test/utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const { createReadStream } = require('node:fs')
const { Socket } = require('node:net')
const { Duplex, PassThrough, Readable, Stream, Transform, Writable } = require('node:stream')
const { test } = require('node:test')
const { isStream, isDeflate, isGzip, intoAsyncIterator } = require('../lib/utils')
const { isStream, isZstd, isDeflate, isGzip, intoAsyncIterator } = require('../lib/utils')

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

test('isZstd() utility should be able to detect zstd compressed Buffer', async (t) => {
t.plan(10)
const equal = t.assert.equal

equal(isZstd(Buffer.alloc(0)), false)
equal(isZstd(Buffer.alloc(1)), false)
equal(isZstd(Buffer.alloc(2)), false)
equal(isZstd(Buffer.alloc(3)), false)
equal(isZstd(Buffer.from([0x28, 0xb5, 0x2f])), false)
equal(isZstd(Buffer.from([0x28, 0xb5, 0x2f, 0xfd])), true)

equal(isZstd({}), false)
equal(isZstd(null), false)
equal(isZstd(undefined), false)
equal(isZstd(''), false)
})

test('isGzip() utility should be able to detect gzip compressed Buffer', async (t) => {
t.plan(10)
const equal = t.assert.equal
Expand Down
2 changes: 1 addition & 1 deletion types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ type RouteDecompressOptions = Pick<fastifyCompress.FastifyCompressOptions,
| 'zlib'
>

type EncodingToken = 'br' | 'deflate' | 'gzip' | 'identity'
type EncodingToken = 'zstd' | 'br' | 'deflate' | 'gzip' | 'identity'

type CompressibleContentTypeFunction = (contentType: string) => boolean

Expand Down
15 changes: 15 additions & 0 deletions types/index.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,14 @@ const withGlobalOptions: FastifyCompressOptions = {
removeContentLengthHeader: true
}

const withZstdOptions: FastifyCompressOptions = {
encodings: ['zstd', 'br', 'gzip', 'deflate', 'identity'],
requestEncodings: ['zstd', 'br', 'gzip', 'deflate', 'identity']
}

const app: FastifyInstance = fastify()
app.register(fastifyCompress, withGlobalOptions)
app.register(fastifyCompress, withZstdOptions)

app.register(fastifyCompress, {
customTypes: value => value === 'application/json'
Expand Down Expand Up @@ -111,6 +117,15 @@ appWithoutGlobal.inject(
}
)

// Test that invalid encoding values trigger TypeScript errors
expectError(fastify().register(fastifyCompress, {
encodings: ['invalid-encoding']
}))

expectError(fastify().register(fastifyCompress, {
requestEncodings: ['another-invalid-encoding']
}))

// Instantiation of an app that should trigger a typescript error
const appThatTriggerAnError = fastify()
expectError(appThatTriggerAnError.register(fastifyCompress, {
Expand Down