From d6d6584190d36d243e7d4e6e5c774a2a98f7247b Mon Sep 17 00:00:00 2001 From: Polys Georgiou Date: Tue, 12 Aug 2025 12:18:43 +0100 Subject: [PATCH] Add support for zstd --- README.md | 15 +++- index.js | 33 +++++++- package.json | 2 +- test/compression.js | 193 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 234 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index df1bd789..af417b8e 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,9 @@ The following compression codings are supported: - deflate - gzip - br (brotli) + - zstd (Zstandard) -**Note** Brotli is supported only since Node.js versions v11.7.0 and v10.16.0. +**Note** Brotli is supported only since Node.js versions v11.7.0 and v10.16.0. Zstd is supported only since Node.js v23.8.0 and v22.15.0. ## Install @@ -46,9 +47,10 @@ as compressing will transform the body. #### Options `compression()` accepts these properties in the options object. In addition to -those listed below, [zlib](https://nodejs.org/api/zlib.html) options may be -passed in to the options object or -[brotli](https://nodejs.org/api/zlib.html#zlib_class_brotlioptions) options. +those listed below, [zlib](https://nodejs.org/api/zlib.html), +[brotli](https://nodejs.org/api/zlib.html#zlib_class_brotlioptions), or +[zstd](https://nodejs.org/api/zlib.html#class-zstdoptions) options may be +passed in to the options object. ##### chunkSize @@ -116,6 +118,11 @@ Type: `Object` This specifies the options for configuring Brotli. See [Node.js documentation](https://nodejs.org/api/zlib.html#class-brotlioptions) for a complete list of available options. +##### zstd + +Type: `Object` + +This specifies the options for configuring Zstd. See [Node.js documentation](https://nodejs.org/api/zlib.html#class-zstdoptions) for a complete list of available options. ##### strategy diff --git a/index.js b/index.js index c0638e08..3be7cafc 100644 --- a/index.js +++ b/index.js @@ -36,15 +36,33 @@ module.exports.filter = shouldCompress */ var hasBrotliSupport = 'createBrotliCompress' in zlib +/** + * @const + * whether current node version has zstd support + */ +var hasZstdSupport = 'createZstdCompress' in zlib + /** * Module variables. * @private */ var cacheControlNoTransformRegExp = /(?:^|,)\s*?no-transform\s*?(?:,|$)/ -var SUPPORTED_ENCODING = hasBrotliSupport ? ['br', 'gzip', 'deflate', 'identity'] : ['gzip', 'deflate', 'identity'] -var PREFERRED_ENCODING = hasBrotliSupport ? ['br', 'gzip'] : ['gzip'] -var encodingSupported = ['gzip', 'deflate', 'identity', 'br'] +var SUPPORTED_ENCODING = (function () { + var supported = ['gzip', 'deflate', 'identity'] + if (hasZstdSupport) supported.unshift('zstd') + if (hasBrotliSupport) supported.unshift('br') + return supported +})() + +var PREFERRED_ENCODING = (function () { + var preferred = ['gzip'] + if (hasZstdSupport) preferred.unshift('zstd') // prefer zstd over gzip + if (hasBrotliSupport) preferred.unshift('br') // prefer br over zstd or gzip + return preferred +})() + +var encodingSupported = ['gzip', 'deflate', 'identity', 'br', 'zstd'] /** * Compress response data with gzip / deflate. @@ -57,6 +75,7 @@ var encodingSupported = ['gzip', 'deflate', 'identity', 'br'] function compression (options) { var opts = options || {} var optsBrotli = {} + var optsZstd = {} if (hasBrotliSupport) { Object.assign(optsBrotli, opts.brotli) @@ -68,6 +87,10 @@ function compression (options) { optsBrotli.params = Object.assign(brotliParams, optsBrotli.params) } + if (hasZstdSupport) { + Object.assign(optsZstd, opts.zstd) + } + // options var filter = opts.filter || shouldCompress var threshold = bytes.parse(opts.threshold) @@ -215,7 +238,9 @@ function compression (options) { ? zlib.createGzip(opts) : method === 'br' ? zlib.createBrotliCompress(optsBrotli) - : zlib.createDeflate(opts) + : method === 'zstd' + ? zlib.createZstdCompress(optsZstd) + : zlib.createDeflate(opts) // add buffered listeners to stream addListeners(stream, stream.on, listeners) diff --git a/package.json b/package.json index 65e19f81..284f447f 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "type": "opencollective", "url": "https://opencollective.com/express" }, - "keywords": ["compression", "gzip", "deflate", "middleware", "express", "brotli", "http", "stream"], + "keywords": ["compression", "gzip", "deflate", "middleware", "express", "brotli", "zstd", "http", "stream"], "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", diff --git a/test/compression.js b/test/compression.js index 8107def0..466cb79e 100644 --- a/test/compression.js +++ b/test/compression.js @@ -26,6 +26,13 @@ var compression = require('..') var hasBrotliSupport = 'createBrotliCompress' in zlib var brotli = hasBrotliSupport ? it : it.skip +/** + * @const + * whether current node version has zstd support + */ +var hasZstdSupport = 'createZstdCompress' in zlib +var zstd = hasZstdSupport ? it : it.skip + describe('compression()', function () { it('should skip HEAD', function (done) { var server = createServer({ threshold: 0 }, function (req, res) { @@ -563,6 +570,52 @@ describe('compression()', function () { }) }) + describe('when "Accept-Encoding: zstd"', function () { + zstd('should respond with zstd', function (done) { + var server = createServer({ threshold: 0 }, function (req, res) { + res.setHeader('Content-Type', 'text/plain') + res.end('hello, world') + }) + + request(server) + .get('/') + .set('Accept-Encoding', 'zstd') + .expect('Content-Encoding', 'zstd', done) + }) + }) + + describe('when "Accept-Encoding: zstd" and passing compression level', function () { + zstd('should respond with zstd', function (done) { + var params = {} + params[zlib.constants.ZSTD_c_compressionLevel] = 10 + + var server = createServer({ threshold: 0, zstd: { params: params } }, function (req, res) { + res.setHeader('Content-Type', 'text/plain') + res.end('hello, world') + }) + + request(server) + .get('/') + .set('Accept-Encoding', 'zstd') + .expect('Content-Encoding', 'zstd', done) + }) + + zstd('shouldn\'t break compression when gzip is requested', function (done) { + var params = {} + params[zlib.constants.ZSTD_c_compressionLevel] = 9 + + var server = createServer({ threshold: 0, zstd: { params: params } }, function (req, res) { + res.setHeader('Content-Type', 'text/plain') + res.end('hello, world') + }) + + request(server) + .get('/') + .set('Accept-Encoding', 'gzip') + .expect('Content-Encoding', 'gzip', done) + }) + }) + describe('when "Accept-Encoding: gzip, deflate"', function () { it('should respond with gzip', function (done) { var server = createServer({ threshold: 0 }, function (req, res) { @@ -634,6 +687,20 @@ describe('compression()', function () { }) }) + describe('when "Accept-Encoding: deflate, gzip, br, zstd"', function () { + brotli('should respond with br', function (done) { + var server = createServer({ threshold: 0 }, function (req, res) { + res.setHeader('Content-Type', 'text/plain') + res.end('hello, world') + }) + + request(server) + .get('/') + .set('Accept-Encoding', 'deflate, gzip, br, zstd') + .expect('Content-Encoding', 'br', done) + }) + }) + describe('when "Accept-Encoding: gzip;q=1, br;q=0.3"', function () { brotli('should respond with gzip', function (done) { var server = createServer({ threshold: 0 }, function (req, res) { @@ -648,6 +715,20 @@ describe('compression()', function () { }) }) + describe('when "Accept-Encoding: gzip;q=1, br;q=0.3, zstd;q=0.5"', function () { + it('should respond with gzip', function (done) { + var server = createServer({ threshold: 0 }, function (req, res) { + res.setHeader('Content-Type', 'text/plain') + res.end('hello, world') + }) + + request(server) + .get('/') + .set('Accept-Encoding', 'gzip;q=1, br;q=0.3, zstd;q=0.5') + .expect('Content-Encoding', 'gzip', done) + }) + }) + describe('when "Accept-Encoding: gzip, br;q=0.8"', function () { brotli('should respond with gzip', function (done) { var server = createServer({ threshold: 0 }, function (req, res) { @@ -662,6 +743,62 @@ describe('compression()', function () { }) }) + describe('when "Accept-Encoding: gzip, br;q=0.8, zstd;q=0.9"', function () { + it('should respond with gzip', function (done) { + var server = createServer({ threshold: 0 }, function (req, res) { + res.setHeader('Content-Type', 'text/plain') + res.end('hello, world') + }) + + request(server) + .get('/') + .set('Accept-Encoding', 'gzip, br;q=0.8, zstd;q=0.9') + .expect('Content-Encoding', 'gzip', done) + }) + }) + + describe('when "Accept-Encoding: gzip;q=0.2, br;q=0.8, zstd;q=0.6"', function () { + brotli('should respond with brotli', function (done) { + var server = createServer({ threshold: 0 }, function (req, res) { + res.setHeader('Content-Type', 'text/plain') + res.end('hello, world') + }) + + request(server) + .get('/') + .set('Accept-Encoding', 'gzip;q=0.2, br;q=0.8, zstd;q=0.6') + .expect('Content-Encoding', 'br', done) + }) + }) + + describe('when "Accept-Encoding: gzip;q=0.2, br;q=0.4, zstd;q=0.6"', function () { + zstd('should respond with zstd', function (done) { + var server = createServer({ threshold: 0 }, function (req, res) { + res.setHeader('Content-Type', 'text/plain') + res.end('hello, world') + }) + + request(server) + .get('/') + .set('Accept-Encoding', 'gzip;q=0.2, br;q=0.4, zstd;q=0.6') + .expect('Content-Encoding', 'zstd', done) + }) + }) + + describe('when "Accept-Encoding: gzip;q=0.1, br;q=0.2, zstd"', function () { + zstd('should respond with zstd', function (done) { + var server = createServer({ threshold: 0 }, function (req, res) { + res.setHeader('Content-Type', 'text/plain') + res.end('hello, world') + }) + + request(server) + .get('/') + .set('Accept-Encoding', 'gzip;q=0.1, br;q=0.2, zstd') + .expect('Content-Encoding', 'zstd', done) + }) + }) + describe('when "Accept-Encoding: gzip;q=0.001"', function () { brotli('should respond with gzip', function (done) { var server = createServer({ threshold: 0 }, function (req, res) { @@ -690,6 +827,20 @@ describe('compression()', function () { }) }) + describe('when "Accept-Encoding: deflate, zstd"', function () { + zstd('should respond with zstd', function (done) { + var server = createServer({ threshold: 0 }, function (req, res) { + res.setHeader('Content-Type', 'text/plain') + res.end('hello, world') + }) + + request(server) + .get('/') + .set('Accept-Encoding', 'deflate, zstd') + .expect('Content-Encoding', 'zstd', done) + }) + }) + describe('when "Cache-Control: no-transform" response header', function () { it('should not compress response', function (done) { var server = createServer({ threshold: 0 }, function (req, res) { @@ -854,6 +1005,32 @@ describe('compression()', function () { .end() }) + zstd('should flush small chunks for zstd', function (done) { + var chunks = 0 + var next + var server = createServer({ threshold: 0 }, function (req, res) { + next = writeAndFlush(res, 2, Buffer.from('..')) + res.setHeader('Content-Type', 'text/plain') + next() + }) + + function onchunk (chunk) { + assert.ok(chunks++ < 20) + assert.strictEqual(chunk.toString(), '..') + next() + } + + request(server) + .get('/') + .set('Accept-Encoding', 'zstd') + .request() + .on('response', unchunk('zstd', onchunk, function (err) { + if (err) return done(err) + server.close(done) + })) + .end() + }) + it('should flush small chunks for deflate', function (done) { var chunks = 0 var next @@ -947,6 +1124,19 @@ describe('compression()', function () { .expect(200, done) }) + zstd('should compress when enforceEncoding is zstd', function (done) { + var server = createServer({ threshold: 0, enforceEncoding: 'zstd' }, function (req, res) { + res.setHeader('Content-Type', 'text/plain') + res.end('hello, world') + }) + + request(server) + .get('/') + .set('Accept-Encoding', '') + .expect('Content-Encoding', 'zstd') + .expect(200, done) + }) + it('should not compress when enforceEncoding is unknown', function (done) { var server = createServer({ threshold: 0, enforceEncoding: 'bogus' }, function (req, res) { res.setHeader('Content-Type', 'text/plain') @@ -1070,6 +1260,9 @@ function unchunk (encoding, onchunk, onend) { case 'br': stream = res.pipe(zlib.createBrotliDecompress()) break + case 'zstd': + stream = res.pipe(zlib.createZstdDecompress()) + break } stream.on('data', onchunk)