From a59812b735f770fc476c9208f29a32e47dc77158 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Tue, 1 Jul 2025 11:10:43 +0200 Subject: [PATCH 1/2] feat: add support for zstd compression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Signed-off-by: Matteo Collina --- index.js | 17 +++++++++++ lib/utils.js | 16 ++++++++++- package.json | 3 +- test/global-compress.test.js | 52 ++++++++++++++++++++++++++++++++++ test/global-decompress.test.js | 27 ++++++++++++++++++ test/utils.test.js | 19 ++++++++++++- types/index.d.ts | 2 +- types/index.test-d.ts | 15 ++++++++++ 8 files changed, 147 insertions(+), 4 deletions(-) diff --git a/index.js b/index.js index 0aabf9b..6e5148f 100644 --- a/index.js +++ b/index.js @@ -145,6 +145,9 @@ 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) @@ -152,8 +155,16 @@ function processCompressParams (opts) { 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 @@ -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 diff --git a/lib/utils.js b/lib/utils.js index 2f265fe..02eb02c 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -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 ( @@ -76,4 +90,4 @@ async function * intoAsyncIterator (payload) { yield payload } -module.exports = { isGzip, isDeflate, isStream, intoAsyncIterator } +module.exports = { isZstd, isGzip, isDeflate, isStream, intoAsyncIterator } diff --git a/package.json b/package.json index dff4705..c1f092c 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ "compression", "deflate", "gzip", - "brotli" + "brotli", + "zstd" ], "author": "Tomas Della Vedova - @delvedor (http://delved.org)", "contributors": [ diff --git a/test/global-compress.test.js b/test/global-compress.test.js index 4c142bc..b986b8f 100644 --- a/test/global-compress.test.js +++ b/test/global-compress.test.js @@ -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) @@ -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) diff --git a/test/global-decompress.test.js b/test/global-decompress.test.js index 68e7e9b..1a6b6ac 100644 --- a/test/global-decompress.test.js +++ b/test/global-decompress.test.js @@ -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) diff --git a/test/utils.test.js b/test/utils.test.js index f62e9d9..cc71569 100644 --- a/test/utils.test.js +++ b/test/utils.test.js @@ -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) @@ -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 diff --git a/types/index.d.ts b/types/index.d.ts index 63dd1d0..71f7a44 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -57,7 +57,7 @@ type RouteDecompressOptions = Pick -type EncodingToken = 'br' | 'deflate' | 'gzip' | 'identity' +type EncodingToken = 'zstd' | 'br' | 'deflate' | 'gzip' | 'identity' type CompressibleContentTypeFunction = (contentType: string) => boolean diff --git a/types/index.test-d.ts b/types/index.test-d.ts index ca5704e..32a32b9 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -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' @@ -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, { From 66dd43528748a6c05472f9b58c62dae4feb2cd85 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Tue, 1 Jul 2025 11:16:14 +0200 Subject: [PATCH 2/2] docs: update README to include zstd compression support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Signed-off-by: Matteo Collina --- README.md | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 7953282..9a6fb26 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. @@ -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 @@ -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. @@ -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