From 77e8ffd47be30c5a36cd5f1b0c41cccc95f904bb Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Sun, 19 May 2024 11:33:55 +0200 Subject: [PATCH 01/25] =?UTF-8?q?feat:=20add=20brotli=20to=20supported=20c?= =?UTF-8?q?ompression=20=F0=9F=97=9C=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 7 +++ lib/compression.js | 12 +++- lib/config.js | 3 +- lib/core.js | 4 ++ lib/types/server/encoders.d.ts | 4 +- lib/types/server/options.d.ts | 1 + test/payload.js | 24 +++++++ test/transmit.js | 112 +++++++++++++++++++++++++++++++++ 8 files changed, 163 insertions(+), 4 deletions(-) diff --git a/API.md b/API.md index 7b9316f06..d282043b4 100755 --- a/API.md +++ b/API.md @@ -110,6 +110,13 @@ Default value: '1024'. Sets the minimum response payload size in bytes that is required for content encoding compression. If the payload size is under the limit, no compression is performed. +##### `server.options.compression.priority` + +Default value: `null`. + +Sets the priority for content encoding compression algorithms in descending order, +e.g.: `['br', 'gzip', 'deflate']`. + #### `server.options.debug` Default value: `{ request: ['implementation'] }`. diff --git a/lib/compression.js b/lib/compression.js index 3e4c692e4..28ad8fe27 100755 --- a/lib/compression.js +++ b/lib/compression.js @@ -8,21 +8,23 @@ const Hoek = require('@hapi/hoek'); const internals = { - common: ['gzip, deflate', 'deflate, gzip', 'gzip', 'deflate', 'gzip, deflate, br'] + common: ['gzip, deflate', 'deflate, gzip', 'gzip', 'deflate', 'br', 'gzip, deflate, br'] }; exports = module.exports = internals.Compression = class { decoders = { + br: (options) => Zlib.createBrotliDecompress(options), gzip: (options) => Zlib.createGunzip(options), deflate: (options) => Zlib.createInflate(options) }; - encodings = ['identity', 'gzip', 'deflate']; + encodings = ['identity', 'gzip', 'deflate', 'br']; encoders = { identity: null, + br: (options) => Zlib.createBrotliCompress(options), gzip: (options) => Zlib.createGzip(options), deflate: (options) => Zlib.createDeflate(options) }; @@ -116,4 +118,10 @@ exports = module.exports = internals.Compression = class { Hoek.assert(encoder !== undefined, `Unknown encoding ${encoding}`); return encoder(request.route.settings.compression[encoding]); } + + setPriority(priority) { + + this.encodings = [...new Set([...priority, ...this.encodings])]; + this._updateCommons(); + } }; diff --git a/lib/config.js b/lib/config.js index 2b668f97d..daca2b911 100755 --- a/lib/config.js +++ b/lib/config.js @@ -241,7 +241,8 @@ internals.server = Validate.object({ autoListen: Validate.boolean(), cache: Validate.allow(null), // Validated elsewhere compression: Validate.object({ - minBytes: Validate.number().min(1).integer().default(1024) + minBytes: Validate.number().min(1).integer().default(1024), + priority: Validate.array().items(Validate.string().valid('gzip', 'deflate', 'br')).default(null) }) .allow(false) .default(), diff --git a/lib/core.js b/lib/core.js index 202f6dbe0..6192a420d 100755 --- a/lib/core.js +++ b/lib/core.js @@ -127,6 +127,10 @@ exports = module.exports = internals.Core = class { this._debug(); this._initializeCache(); + if (this.settings.compression.priority) { + this.compression.setPriority(this.settings.compression.priority); + } + if (this.settings.routes.validate.validator) { this.validator = Validation.validator(this.settings.routes.validate.validator); } diff --git a/lib/types/server/encoders.d.ts b/lib/types/server/encoders.d.ts index c91fd7df3..68bb7ff3b 100644 --- a/lib/types/server/encoders.d.ts +++ b/lib/types/server/encoders.d.ts @@ -1,4 +1,4 @@ -import { createDeflate, createGunzip, createGzip, createInflate } from 'zlib'; +import { createBrotliCompress, createBrotliDecompress, createDeflate, createGunzip, createGzip, createInflate } from 'zlib'; /** * Available [content encoders](https://github.com/hapijs/hapi/blob/master/API.md#-serverencoderencoding-encoder). @@ -7,6 +7,7 @@ export interface ContentEncoders { deflate: typeof createDeflate; gzip: typeof createGzip; + br: typeof createBrotliCompress; } /** @@ -16,4 +17,5 @@ export interface ContentDecoders { deflate: typeof createInflate; gzip: typeof createGunzip; + br: typeof createBrotliDecompress; } diff --git a/lib/types/server/options.d.ts b/lib/types/server/options.d.ts index 5b7c9a7e3..9ace20f05 100644 --- a/lib/types/server/options.d.ts +++ b/lib/types/server/options.d.ts @@ -10,6 +10,7 @@ import { SameSitePolicy, ServerStateCookieOptions } from './state'; export interface ServerOptionsCompression { minBytes: number; + priority: string[]; } /** diff --git a/test/payload.js b/test/payload.js index 93b045cdf..4ba576665 100755 --- a/test/payload.js +++ b/test/payload.js @@ -525,6 +525,30 @@ describe('Payload', () => { expect(res.result).to.equal(message); }); + it('handles br payload', async () => { + + const message = { 'msg': 'This message is going to be brotlied.' }; + const server = Hapi.server(); + server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); + + const compressed = await new Promise((resolve) => Zlib.brotliCompress(JSON.stringify(message), (ignore, result) => resolve(result))); + + const request = { + method: 'POST', + url: '/', + headers: { + 'content-type': 'application/json', + 'content-encoding': 'br', + 'content-length': compressed.length + }, + payload: compressed + }; + + const res = await server.inject(request); + expect(res.result).to.exist(); + expect(res.result).to.equal(message); + }); + it('handles custom compression', async () => { const message = { 'msg': 'This message is going to be gzipped.' }; diff --git a/test/transmit.js b/test/transmit.js index b6f1fd7cd..e00e2cf2b 100755 --- a/test/transmit.js +++ b/test/transmit.js @@ -677,6 +677,19 @@ describe('transmission', () => { expect(res3.headers['last-modified']).to.equal(res2.headers['last-modified']); }); + it('returns a brotlied file in the response when the request accepts br', async () => { + + const server = Hapi.server({ compression: { minBytes: 1 }, routes: { files: { relativeTo: __dirname } } }); + await server.register(Inert); + server.route({ method: 'GET', path: '/file', handler: (request, h) => h.file(__dirname + '/../package.json') }); + + const res = await server.inject({ url: '/file', headers: { 'accept-encoding': 'br' } }); + expect(res.headers['content-type']).to.equal('application/json; charset=utf-8'); + expect(res.headers['content-encoding']).to.equal('br'); + expect(res.headers['content-length']).to.not.exist(); + expect(res.payload).to.exist(); + }); + it('returns a gzipped file in the response when the request accepts gzip', async () => { const server = Hapi.server({ compression: { minBytes: 1 }, routes: { files: { relativeTo: __dirname } } }); @@ -729,6 +742,16 @@ describe('transmission', () => { expect(res.payload).to.exist(); }); + it('returns a brotlied stream response without a content-length header when accept-encoding is br', async () => { + + const server = Hapi.server({ compression: { minBytes: 1 } }); + server.route({ method: 'GET', path: '/stream', handler: () => new internals.TimerStream() }); + + const res = await server.inject({ url: '/stream', headers: { 'Content-Type': 'application/json', 'accept-encoding': 'br' } }); + expect(res.statusCode).to.equal(200); + expect(res.headers['content-length']).to.not.exist(); + }); + it('returns a gzipped stream response without a content-length header when accept-encoding is gzip', async () => { const server = Hapi.server({ compression: { minBytes: 1 } }); @@ -749,6 +772,37 @@ describe('transmission', () => { expect(res.headers['content-length']).to.not.exist(); }); + it('returns a br response on a post request when accept-encoding: br is requested', async () => { + + const data = '{"test":"true"}'; + + const server = Hapi.server({ compression: { minBytes: 1 } }); + server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); + await server.start(); + + const uri = 'http://localhost:' + server.info.port; + const brotlied = await internals.compress('brotliCompress', Buffer.from(data)); + + const { payload } = await Wreck.post(uri, { headers: { 'accept-encoding': 'br' }, payload: data }); + expect(payload.toString()).to.equal(brotlied.toString()); + await server.stop(); + }); + + it('returns a br response on a get request when accept-encoding: br is requested', async () => { + + const data = '{"test":"true"}'; + + const server = Hapi.server({ compression: { minBytes: 1 } }); + server.route({ method: 'GET', path: '/', handler: () => data }); + await server.start(); + + const uri = 'http://localhost:' + server.info.port; + const brotlied = await internals.compress('brotliCompress', Buffer.from(data)); + const { payload } = await Wreck.get(uri, { headers: { 'accept-encoding': 'br' } }); + expect(payload.toString()).to.equal(brotlied.toString()); + await server.stop(); + }); + it('returns a gzip response on a post request when accept-encoding: gzip is requested', async () => { const data = '{"test":"true"}'; @@ -891,6 +945,35 @@ describe('transmission', () => { await server.stop(); }); + + it('returns a br response on a post request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=1 is requested', async () => { + + const data = '{"test":"true"}'; + const server = Hapi.server({ compression: { minBytes: 1, priority: ['br'] } }); + server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); + await server.start(); + + const uri = 'http://localhost:' + server.info.port; + const brotlied = await internals.compress('brotliCompress', Buffer.from(data)); + const { payload } = await Wreck.post(uri, { headers: { 'accept-encoding': 'gzip;q=1, deflate;q=0.5, br;q=1' }, payload: data }); + expect(payload.toString()).to.equal(brotlied.toString()); + await server.stop(); + }); + + it('returns a br response on a get request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=1 is requested', async () => { + + const data = '{"test":"true"}'; + const server = Hapi.server({ compression: { minBytes: 1, priority: ['br'] } }); + server.route({ method: 'GET', path: '/', handler: () => data }); + await server.start(); + + const uri = 'http://localhost:' + server.info.port; + const brotlied = await internals.compress('brotliCompress', Buffer.from(data)); + const { payload } = await Wreck.get(uri, { headers: { 'accept-encoding': 'gzip;q=1, deflate;q=0.5, br;q=1' } }); + expect(payload.toString()).to.equal(brotlied.toString()); + await server.stop(); + }); + it('returns a gzip response on a post request when accept-encoding: deflate, gzip is requested', async () => { const data = '{"test":"true"}'; @@ -919,6 +1002,35 @@ describe('transmission', () => { await server.stop(); }); + + it('returns a br response on a post request when accept-encoding: gzip, deflate, br is requested', async () => { + + const data = '{"test":"true"}'; + const server = Hapi.server({ compression: { minBytes: 1, priority: ['br'] } }); + server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); + await server.start(); + + const uri = 'http://localhost:' + server.info.port; + const brotlied = await internals.compress('brotliCompress', Buffer.from(data)); + const { payload } = await Wreck.post(uri, { headers: { 'accept-encoding': 'gzip, deflate, br' }, payload: data }); + expect(payload.toString()).to.equal(brotlied.toString()); + await server.stop(); + }); + + it('returns a br response on a get request when accept-encoding: gzip, deflate, br is requested', async () => { + + const data = '{"test":"true"}'; + const server = Hapi.server({ compression: { minBytes: 1, priority: ['br'] } }); + server.route({ method: 'GET', path: '/', handler: () => data }); + await server.start(); + + const uri = 'http://localhost:' + server.info.port; + const brotlied = await internals.compress('brotliCompress', Buffer.from(data)); + const { payload } = await Wreck.get(uri, { headers: { 'accept-encoding': 'gzip, deflate, br' } }); + expect(payload.toString()).to.equal(brotlied.toString()); + await server.stop(); + }); + it('boom object reused does not affect encoding header.', async () => { const error = Boom.badRequest(); From 3b168b5fb1d9cdd41d42f9f1cf532f77a79e164b Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Sun, 19 May 2024 14:14:29 +0200 Subject: [PATCH 02/25] =?UTF-8?q?chore:=20address=20comments=20?= =?UTF-8?q?=F0=9F=93=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 7 ---- lib/compression.js | 6 --- lib/config.js | 3 +- lib/core.js | 4 -- lib/types/server/options.d.ts | 1 - test/transmit.js | 74 ++++------------------------------- 6 files changed, 9 insertions(+), 86 deletions(-) diff --git a/API.md b/API.md index d282043b4..7b9316f06 100755 --- a/API.md +++ b/API.md @@ -110,13 +110,6 @@ Default value: '1024'. Sets the minimum response payload size in bytes that is required for content encoding compression. If the payload size is under the limit, no compression is performed. -##### `server.options.compression.priority` - -Default value: `null`. - -Sets the priority for content encoding compression algorithms in descending order, -e.g.: `['br', 'gzip', 'deflate']`. - #### `server.options.debug` Default value: `{ request: ['implementation'] }`. diff --git a/lib/compression.js b/lib/compression.js index 28ad8fe27..adbca045e 100755 --- a/lib/compression.js +++ b/lib/compression.js @@ -118,10 +118,4 @@ exports = module.exports = internals.Compression = class { Hoek.assert(encoder !== undefined, `Unknown encoding ${encoding}`); return encoder(request.route.settings.compression[encoding]); } - - setPriority(priority) { - - this.encodings = [...new Set([...priority, ...this.encodings])]; - this._updateCommons(); - } }; diff --git a/lib/config.js b/lib/config.js index daca2b911..2b668f97d 100755 --- a/lib/config.js +++ b/lib/config.js @@ -241,8 +241,7 @@ internals.server = Validate.object({ autoListen: Validate.boolean(), cache: Validate.allow(null), // Validated elsewhere compression: Validate.object({ - minBytes: Validate.number().min(1).integer().default(1024), - priority: Validate.array().items(Validate.string().valid('gzip', 'deflate', 'br')).default(null) + minBytes: Validate.number().min(1).integer().default(1024) }) .allow(false) .default(), diff --git a/lib/core.js b/lib/core.js index 6192a420d..202f6dbe0 100755 --- a/lib/core.js +++ b/lib/core.js @@ -127,10 +127,6 @@ exports = module.exports = internals.Core = class { this._debug(); this._initializeCache(); - if (this.settings.compression.priority) { - this.compression.setPriority(this.settings.compression.priority); - } - if (this.settings.routes.validate.validator) { this.validator = Validation.validator(this.settings.routes.validate.validator); } diff --git a/lib/types/server/options.d.ts b/lib/types/server/options.d.ts index 9ace20f05..5b7c9a7e3 100644 --- a/lib/types/server/options.d.ts +++ b/lib/types/server/options.d.ts @@ -10,7 +10,6 @@ import { SameSitePolicy, ServerStateCookieOptions } from './state'; export interface ServerOptionsCompression { minBytes: number; - priority: string[]; } /** diff --git a/test/transmit.js b/test/transmit.js index e00e2cf2b..45a152424 100755 --- a/test/transmit.js +++ b/test/transmit.js @@ -889,7 +889,7 @@ describe('transmission', () => { await server.stop(); }); - it('returns a gzip response on a post request when accept-encoding: gzip;q=1, deflate;q=0.5 is requested', async () => { + it('returns a gzip response on a post request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=0.7 is requested', async () => { const data = '{"test":"true"}'; const server = Hapi.server({ compression: { minBytes: 1 } }); @@ -898,12 +898,12 @@ describe('transmission', () => { const uri = 'http://localhost:' + server.info.port; const zipped = await internals.compress('gzip', Buffer.from(data)); - const { payload } = await Wreck.post(uri, { headers: { 'accept-encoding': 'gzip;q=1, deflate;q=0.5' }, payload: data }); + const { payload } = await Wreck.post(uri, { headers: { 'accept-encoding': 'gzip;q=1, deflate;q=0.5, br;q=0.7' }, payload: data }); expect(payload.toString()).to.equal(zipped.toString()); await server.stop(); }); - it('returns a gzip response on a get request when accept-encoding: gzip;q=1, deflate;q=0.5 is requested', async () => { + it('returns a gzip response on a get request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=0.7 is requested', async () => { const data = '{"test":"true"}'; const server = Hapi.server({ compression: { minBytes: 1 } }); @@ -912,12 +912,12 @@ describe('transmission', () => { const uri = 'http://localhost:' + server.info.port; const zipped = await internals.compress('gzip', Buffer.from(data)); - const { payload } = await Wreck.get(uri, { headers: { 'accept-encoding': 'gzip;q=1, deflate;q=0.5' } }); + const { payload } = await Wreck.get(uri, { headers: { 'accept-encoding': 'gzip;q=1, deflate;q=0.5, br;q=0.7' } }); expect(payload.toString()).to.equal(zipped.toString()); await server.stop(); }); - it('returns a deflate response on a post request when accept-encoding: deflate;q=1, gzip;q=0.5 is requested', async () => { + it('returns a deflate response on a post request when accept-encoding: deflate;q=1, gzip;q=0.5, br;q=0.7 is requested', async () => { const data = '{"test":"true"}'; const server = Hapi.server({ compression: { minBytes: 1 } }); @@ -926,12 +926,12 @@ describe('transmission', () => { const uri = 'http://localhost:' + server.info.port; const deflated = await internals.compress('deflate', Buffer.from(data)); - const { payload } = await Wreck.post(uri, { headers: { 'accept-encoding': 'deflate;q=1, gzip;q=0.5' }, payload: data }); + const { payload } = await Wreck.post(uri, { headers: { 'accept-encoding': 'deflate;q=1, gzip;q=0.5, br;q=0.7' }, payload: data }); expect(payload.toString()).to.equal(deflated.toString()); await server.stop(); }); - it('returns a deflate response on a get request when accept-encoding: deflate;q=1, gzip;q=0.5 is requested', async () => { + it('returns a deflate response on a get request when accept-encoding: deflate;q=1, gzip;q=0.5, br;q=0.7 is requested', async () => { const data = '{"test":"true"}'; const server = Hapi.server({ compression: { minBytes: 1 } }); @@ -940,40 +940,11 @@ describe('transmission', () => { const uri = 'http://localhost:' + server.info.port; const deflated = await internals.compress('deflate', Buffer.from(data)); - const { payload } = await Wreck.get(uri, { headers: { 'accept-encoding': 'deflate;q=1, gzip;q=0.5' } }); + const { payload } = await Wreck.get(uri, { headers: { 'accept-encoding': 'deflate;q=1, gzip;q=0.5, br;q=0.7' } }); expect(payload.toString()).to.equal(deflated.toString()); await server.stop(); }); - - it('returns a br response on a post request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=1 is requested', async () => { - - const data = '{"test":"true"}'; - const server = Hapi.server({ compression: { minBytes: 1, priority: ['br'] } }); - server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); - await server.start(); - - const uri = 'http://localhost:' + server.info.port; - const brotlied = await internals.compress('brotliCompress', Buffer.from(data)); - const { payload } = await Wreck.post(uri, { headers: { 'accept-encoding': 'gzip;q=1, deflate;q=0.5, br;q=1' }, payload: data }); - expect(payload.toString()).to.equal(brotlied.toString()); - await server.stop(); - }); - - it('returns a br response on a get request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=1 is requested', async () => { - - const data = '{"test":"true"}'; - const server = Hapi.server({ compression: { minBytes: 1, priority: ['br'] } }); - server.route({ method: 'GET', path: '/', handler: () => data }); - await server.start(); - - const uri = 'http://localhost:' + server.info.port; - const brotlied = await internals.compress('brotliCompress', Buffer.from(data)); - const { payload } = await Wreck.get(uri, { headers: { 'accept-encoding': 'gzip;q=1, deflate;q=0.5, br;q=1' } }); - expect(payload.toString()).to.equal(brotlied.toString()); - await server.stop(); - }); - it('returns a gzip response on a post request when accept-encoding: deflate, gzip is requested', async () => { const data = '{"test":"true"}'; @@ -1002,35 +973,6 @@ describe('transmission', () => { await server.stop(); }); - - it('returns a br response on a post request when accept-encoding: gzip, deflate, br is requested', async () => { - - const data = '{"test":"true"}'; - const server = Hapi.server({ compression: { minBytes: 1, priority: ['br'] } }); - server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); - await server.start(); - - const uri = 'http://localhost:' + server.info.port; - const brotlied = await internals.compress('brotliCompress', Buffer.from(data)); - const { payload } = await Wreck.post(uri, { headers: { 'accept-encoding': 'gzip, deflate, br' }, payload: data }); - expect(payload.toString()).to.equal(brotlied.toString()); - await server.stop(); - }); - - it('returns a br response on a get request when accept-encoding: gzip, deflate, br is requested', async () => { - - const data = '{"test":"true"}'; - const server = Hapi.server({ compression: { minBytes: 1, priority: ['br'] } }); - server.route({ method: 'GET', path: '/', handler: () => data }); - await server.start(); - - const uri = 'http://localhost:' + server.info.port; - const brotlied = await internals.compress('brotliCompress', Buffer.from(data)); - const { payload } = await Wreck.get(uri, { headers: { 'accept-encoding': 'gzip, deflate, br' } }); - expect(payload.toString()).to.equal(brotlied.toString()); - await server.stop(); - }); - it('boom object reused does not affect encoding header.', async () => { const error = Boom.badRequest(); From 0d8cdd45386b1ada7e53f257c09d3a5de284338a Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Sun, 26 May 2024 11:34:52 +0200 Subject: [PATCH 03/25] =?UTF-8?q?chore:=20address=20comments=20?= =?UTF-8?q?=F0=9F=93=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/compression.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/compression.js b/lib/compression.js index adbca045e..6e28e6262 100755 --- a/lib/compression.js +++ b/lib/compression.js @@ -47,7 +47,6 @@ exports = module.exports = internals.Compression = class { addEncoder(encoding, encoder) { - Hoek.assert(this.encoders[encoding] === undefined, `Cannot override existing encoder for ${encoding}`); Hoek.assert(typeof encoder === 'function', `Invalid encoder function for ${encoding}`); this.encoders[encoding] = encoder; this.encodings.unshift(encoding); @@ -56,7 +55,6 @@ exports = module.exports = internals.Compression = class { addDecoder(encoding, decoder) { - Hoek.assert(this.decoders[encoding] === undefined, `Cannot override existing decoder for ${encoding}`); Hoek.assert(typeof decoder === 'function', `Invalid decoder function for ${encoding}`); this.decoders[encoding] = decoder; } From a30274313ef13ee2d8295432915d88f31912268d Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Sat, 26 Jul 2025 16:30:34 +0200 Subject: [PATCH 04/25] =?UTF-8?q?feat:=20add=20br=20&=20zstd=20to=20suppor?= =?UTF-8?q?ted=20compression=20=F0=9F=97=9C=EF=B8=8F=20algos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 7 ++ lib/compression.js | 28 +++++- lib/config.js | 3 +- lib/core.js | 4 + lib/types/server/encoders.d.ts | 4 +- lib/types/server/options.d.ts | 1 + package.json | 4 +- test/payload.js | 24 +++++ test/transmit.js | 175 +++++++++++++++++++++++++++++++-- 9 files changed, 235 insertions(+), 15 deletions(-) diff --git a/API.md b/API.md index 7b9316f06..2362890c4 100755 --- a/API.md +++ b/API.md @@ -110,6 +110,13 @@ Default value: '1024'. Sets the minimum response payload size in bytes that is required for content encoding compression. If the payload size is under the limit, no compression is performed. +##### `server.options.compression.priority` + +Default value: `null`. + +Sets the priority for content encoding compression algorithms in descending order, +e.g.: `['zstd', 'br', 'gzip', 'deflate']`. + #### `server.options.debug` Default value: `{ request: ['implementation'] }`. diff --git a/lib/compression.js b/lib/compression.js index 6e28e6262..2e030bc5c 100755 --- a/lib/compression.js +++ b/lib/compression.js @@ -6,9 +6,17 @@ const Accept = require('@hapi/accept'); const Bounce = require('@hapi/bounce'); const Hoek = require('@hapi/hoek'); - const internals = { - common: ['gzip, deflate', 'deflate, gzip', 'gzip', 'deflate', 'br', 'gzip, deflate, br'] + common: [ + 'gzip, deflate', + 'deflate, gzip', + 'gzip', + 'deflate', + 'br', + 'gzip, deflate, br', + 'zstd', + 'gzip, deflate, br, zstd' + ] }; @@ -17,16 +25,18 @@ exports = module.exports = internals.Compression = class { decoders = { br: (options) => Zlib.createBrotliDecompress(options), gzip: (options) => Zlib.createGunzip(options), - deflate: (options) => Zlib.createInflate(options) + deflate: (options) => Zlib.createInflate(options), + zstd: (options) => Zlib.createZstdDecompress(options) }; - encodings = ['identity', 'gzip', 'deflate', 'br']; + encodings = ['identity', 'gzip', 'deflate', 'br', 'zstd']; encoders = { identity: null, br: (options) => Zlib.createBrotliCompress(options), gzip: (options) => Zlib.createGzip(options), - deflate: (options) => Zlib.createDeflate(options) + deflate: (options) => Zlib.createDeflate(options), + zstd: (options) => Zlib.createZstdCompress(options) }; #common = null; @@ -47,6 +57,7 @@ exports = module.exports = internals.Compression = class { addEncoder(encoding, encoder) { + Hoek.assert(this.encoders[encoding] === undefined, `Cannot override existing encoder for ${encoding}`); Hoek.assert(typeof encoder === 'function', `Invalid encoder function for ${encoding}`); this.encoders[encoding] = encoder; this.encodings.unshift(encoding); @@ -55,6 +66,7 @@ exports = module.exports = internals.Compression = class { addDecoder(encoding, decoder) { + Hoek.assert(this.decoders[encoding] === undefined, `Cannot override existing decoder for ${encoding}`); Hoek.assert(typeof decoder === 'function', `Invalid decoder function for ${encoding}`); this.decoders[encoding] = decoder; } @@ -116,4 +128,10 @@ exports = module.exports = internals.Compression = class { Hoek.assert(encoder !== undefined, `Unknown encoding ${encoding}`); return encoder(request.route.settings.compression[encoding]); } + + setPriority(priority) { + + this.encodings = [...new Set([...priority, ...this.encodings])]; + this._updateCommons(); + } }; diff --git a/lib/config.js b/lib/config.js index 2b668f97d..9563bfb3d 100755 --- a/lib/config.js +++ b/lib/config.js @@ -241,7 +241,8 @@ internals.server = Validate.object({ autoListen: Validate.boolean(), cache: Validate.allow(null), // Validated elsewhere compression: Validate.object({ - minBytes: Validate.number().min(1).integer().default(1024) + minBytes: Validate.number().min(1).integer().default(1024), + priority: Validate.array().items(Validate.string().valid('gzip', 'deflate', 'br', 'zstd')).default(null) }) .allow(false) .default(), diff --git a/lib/core.js b/lib/core.js index 202f6dbe0..6192a420d 100755 --- a/lib/core.js +++ b/lib/core.js @@ -127,6 +127,10 @@ exports = module.exports = internals.Core = class { this._debug(); this._initializeCache(); + if (this.settings.compression.priority) { + this.compression.setPriority(this.settings.compression.priority); + } + if (this.settings.routes.validate.validator) { this.validator = Validation.validator(this.settings.routes.validate.validator); } diff --git a/lib/types/server/encoders.d.ts b/lib/types/server/encoders.d.ts index 68bb7ff3b..96d89a259 100644 --- a/lib/types/server/encoders.d.ts +++ b/lib/types/server/encoders.d.ts @@ -1,4 +1,4 @@ -import { createBrotliCompress, createBrotliDecompress, createDeflate, createGunzip, createGzip, createInflate } from 'zlib'; +import { createBrotliCompress, createBrotliDecompress, createDeflate, createGunzip, createGzip, createInflate, createZstdCompress, createZstdDecompress } from 'zlib'; /** * Available [content encoders](https://github.com/hapijs/hapi/blob/master/API.md#-serverencoderencoding-encoder). @@ -8,6 +8,7 @@ export interface ContentEncoders { deflate: typeof createDeflate; gzip: typeof createGzip; br: typeof createBrotliCompress; + zstd: typeof createZstdCompress; } /** @@ -18,4 +19,5 @@ export interface ContentDecoders { deflate: typeof createInflate; gzip: typeof createGunzip; br: typeof createBrotliDecompress; + zstd: typeof createZstdDecompress; } diff --git a/lib/types/server/options.d.ts b/lib/types/server/options.d.ts index 5b7c9a7e3..9ace20f05 100644 --- a/lib/types/server/options.d.ts +++ b/lib/types/server/options.d.ts @@ -10,6 +10,7 @@ import { SameSitePolicy, ServerStateCookieOptions } from './state'; export interface ServerOptionsCompression { minBytes: number; + priority: string[]; } /** diff --git a/package.json b/package.json index ec7ba6c99..02c4a934c 100755 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "main": "lib/index.js", "types": "lib/index.d.ts", "engines": { - "node": ">=14.15.0" + "node": ">=22.15.0" }, "files": [ "lib" @@ -51,7 +51,7 @@ "@hapi/lab": "^25.3.2", "@hapi/vision": "^7.0.3", "@hapi/wreck": "^18.1.0", - "@types/node": "^18.19.122", + "@types/node": "^22.17.2", "handlebars": "^4.7.8", "joi": "^17.13.3", "legacy-readable-stream": "npm:readable-stream@^1.0.34", diff --git a/test/payload.js b/test/payload.js index 4ba576665..1820fc794 100755 --- a/test/payload.js +++ b/test/payload.js @@ -549,6 +549,30 @@ describe('Payload', () => { expect(res.result).to.equal(message); }); + it('handles zstd payload', async () => { + + const message = { 'msg': 'This message is going to be zstded.' }; + const server = Hapi.server(); + server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); + + const compressed = await new Promise((resolve) => Zlib.zstdCompress(JSON.stringify(message), (ignore, result) => resolve(result))); + + const request = { + method: 'POST', + url: '/', + headers: { + 'content-type': 'application/json', + 'content-encoding': 'zstd', + 'content-length': compressed.length + }, + payload: compressed + }; + + const res = await server.inject(request); + expect(res.result).to.exist(); + expect(res.result).to.equal(message); + }); + it('handles custom compression', async () => { const message = { 'msg': 'This message is going to be gzipped.' }; diff --git a/test/transmit.js b/test/transmit.js index 45a152424..d01ba0501 100755 --- a/test/transmit.js +++ b/test/transmit.js @@ -27,7 +27,6 @@ const internals = {}; const { describe, it } = exports.lab = Lab.script(); const expect = Code.expect; - describe('transmission', () => { describe('send()', () => { @@ -677,6 +676,19 @@ describe('transmission', () => { expect(res3.headers['last-modified']).to.equal(res2.headers['last-modified']); }); + it('returns a zstded file in the response when the request accepts zstd', async () => { + + const server = Hapi.server({ compression: { minBytes: 1 }, routes: { files: { relativeTo: __dirname } } }); + await server.register(Inert); + server.route({ method: 'GET', path: '/file', handler: (request, h) => h.file(__dirname + '/../package.json') }); + + const res = await server.inject({ url: '/file', headers: { 'accept-encoding': 'zstd' } }); + expect(res.headers['content-type']).to.equal('application/json; charset=utf-8'); + expect(res.headers['content-encoding']).to.equal('zstd'); + expect(res.headers['content-length']).to.not.exist(); + expect(res.payload).to.exist(); + }); + it('returns a brotlied file in the response when the request accepts br', async () => { const server = Hapi.server({ compression: { minBytes: 1 }, routes: { files: { relativeTo: __dirname } } }); @@ -742,6 +754,16 @@ describe('transmission', () => { expect(res.payload).to.exist(); }); + it('returns a zstded stream response without a content-length header when accept-encoding is zstd', async () => { + + const server = Hapi.server({ compression: { minBytes: 1 } }); + server.route({ method: 'GET', path: '/stream', handler: () => new internals.TimerStream() }); + + const res = await server.inject({ url: '/stream', headers: { 'Content-Type': 'application/json', 'accept-encoding': 'zstd' } }); + expect(res.statusCode).to.equal(200); + expect(res.headers['content-length']).to.not.exist(); + }); + it('returns a brotlied stream response without a content-length header when accept-encoding is br', async () => { const server = Hapi.server({ compression: { minBytes: 1 } }); @@ -772,6 +794,36 @@ describe('transmission', () => { expect(res.headers['content-length']).to.not.exist(); }); + it('returns a zstd response on a post request when accept-encoding: zstd is requested', async () => { + + const data = '{"test":"true"}'; + + const server = Hapi.server({ compression: { minBytes: 1 } }); + server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); + await server.start(); + + const uri = 'http://localhost:' + server.info.port; + const zstded = await internals.compress('zstdCompress', Buffer.from(data)); + const { payload } = await Wreck.post(uri, { headers: { 'accept-encoding': 'zstd' }, payload: data }); + expect(payload.toString()).to.equal(zstded.toString()); + await server.stop(); + }); + + it('returns a zstd response on a get request when accept-encoding: zstd is requested', async () => { + + const data = '{"test":"true"}'; + + const server = Hapi.server({ compression: { minBytes: 1 } }); + server.route({ method: 'GET', path: '/', handler: () => data }); + await server.start(); + + const uri = 'http://localhost:' + server.info.port; + const zstded = await internals.compress('zstdCompress', Buffer.from(data)); + const { payload } = await Wreck.get(uri, { headers: { 'accept-encoding': 'zstd' } }); + expect(payload.toString()).to.equal(zstded.toString()); + await server.stop(); + }); + it('returns a br response on a post request when accept-encoding: br is requested', async () => { const data = '{"test":"true"}'; @@ -782,7 +834,6 @@ describe('transmission', () => { const uri = 'http://localhost:' + server.info.port; const brotlied = await internals.compress('brotliCompress', Buffer.from(data)); - const { payload } = await Wreck.post(uri, { headers: { 'accept-encoding': 'br' }, payload: data }); expect(payload.toString()).to.equal(brotlied.toString()); await server.stop(); @@ -917,7 +968,7 @@ describe('transmission', () => { await server.stop(); }); - it('returns a deflate response on a post request when accept-encoding: deflate;q=1, gzip;q=0.5, br;q=0.7 is requested', async () => { + it('returns a deflate response on a post request when accept-encoding: deflate;q=1, gzip;q=0.5, br;q=0.7, zstd;q=1 is requested', async () => { const data = '{"test":"true"}'; const server = Hapi.server({ compression: { minBytes: 1 } }); @@ -926,12 +977,12 @@ describe('transmission', () => { const uri = 'http://localhost:' + server.info.port; const deflated = await internals.compress('deflate', Buffer.from(data)); - const { payload } = await Wreck.post(uri, { headers: { 'accept-encoding': 'deflate;q=1, gzip;q=0.5, br;q=0.7' }, payload: data }); + const { payload } = await Wreck.post(uri, { headers: { 'accept-encoding': 'deflate;q=1, gzip;q=0.5, br;q=0.7, zstd;q=1' }, payload: data }); expect(payload.toString()).to.equal(deflated.toString()); await server.stop(); }); - it('returns a deflate response on a get request when accept-encoding: deflate;q=1, gzip;q=0.5, br;q=0.7 is requested', async () => { + it('returns a deflate response on a get request when accept-encoding: deflate;q=1, gzip;q=0.5, br;q=0.7, zstd;q=1 is requested', async () => { const data = '{"test":"true"}'; const server = Hapi.server({ compression: { minBytes: 1 } }); @@ -940,11 +991,67 @@ describe('transmission', () => { const uri = 'http://localhost:' + server.info.port; const deflated = await internals.compress('deflate', Buffer.from(data)); - const { payload } = await Wreck.get(uri, { headers: { 'accept-encoding': 'deflate;q=1, gzip;q=0.5, br;q=0.7' } }); + const { payload } = await Wreck.get(uri, { headers: { 'accept-encoding': 'deflate;q=1, gzip;q=0.5, br;q=0.7, zstd;q=1' } }); expect(payload.toString()).to.equal(deflated.toString()); await server.stop(); }); + it('returns a br response on a post request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=1 is requested', async () => { + + const data = '{"test":"true"}'; + const server = Hapi.server({ compression: { minBytes: 1, priority: ['br'] } }); + server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); + await server.start(); + + const uri = 'http://localhost:' + server.info.port; + const brotlied = await internals.compress('brotliCompress', Buffer.from(data)); + const { payload } = await Wreck.post(uri, { headers: { 'accept-encoding': 'gzip;q=1, deflate;q=0.5, br;q=1' }, payload: data }); + expect(payload.toString()).to.equal(brotlied.toString()); + await server.stop(); + }); + + it('returns a br response on a get request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=1 is requested', async () => { + + const data = '{"test":"true"}'; + const server = Hapi.server({ compression: { minBytes: 1, priority: ['br'] } }); + server.route({ method: 'GET', path: '/', handler: () => data }); + await server.start(); + + const uri = 'http://localhost:' + server.info.port; + const brotlied = await internals.compress('brotliCompress', Buffer.from(data)); + const { payload } = await Wreck.get(uri, { headers: { 'accept-encoding': 'gzip;q=1, deflate;q=0.5, br;q=1' } }); + expect(payload.toString()).to.equal(brotlied.toString()); + await server.stop(); + }); + + it('returns a zstd response on a post request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=0.7; zstd;q=1 is requested', async () => { + + const data = '{"test":"true"}'; + const server = Hapi.server({ compression: { minBytes: 1, priority: ['zstd'] } }); + server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); + await server.start(); + + const uri = 'http://localhost:' + server.info.port; + const zstded = await internals.compress('zstdCompress', Buffer.from(data)); + const { payload } = await Wreck.post(uri, { headers: { 'accept-encoding': 'gzip;q=1, deflate;q=0.5, br;q=0.7, zstd;q=1' }, payload: data }); + expect(payload.toString()).to.equal(zstded.toString()); + await server.stop(); + }); + + it('returns a zstd response on a get request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=0.7, zstd;q=1 is requested', async () => { + + const data = '{"test":"true"}'; + const server = Hapi.server({ compression: { minBytes: 1, priority: ['zstd'] } }); + server.route({ method: 'GET', path: '/', handler: () => data }); + await server.start(); + + const uri = 'http://localhost:' + server.info.port; + const zstded = await internals.compress('zstdCompress', Buffer.from(data)); + const { payload } = await Wreck.get(uri, { headers: { 'accept-encoding': 'gzip;q=1, deflate;q=0.5, br;q=0.7, zstd;q=1' } }); + expect(payload.toString()).to.equal(zstded.toString()); + await server.stop(); + }); + it('returns a gzip response on a post request when accept-encoding: deflate, gzip is requested', async () => { const data = '{"test":"true"}'; @@ -973,6 +1080,62 @@ describe('transmission', () => { await server.stop(); }); + it('returns a br response on a post request when accept-encoding: gzip, deflate, br is requested', async () => { + + const data = '{"test":"true"}'; + const server = Hapi.server({ compression: { minBytes: 1, priority: ['br'] } }); + server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); + await server.start(); + + const uri = 'http://localhost:' + server.info.port; + const brotlied = await internals.compress('brotliCompress', Buffer.from(data)); + const { payload } = await Wreck.post(uri, { headers: { 'accept-encoding': 'gzip, deflate, br' }, payload: data }); + expect(payload.toString()).to.equal(brotlied.toString()); + await server.stop(); + }); + + it('returns a br response on a get request when accept-encoding: gzip, deflate, br is requested', async () => { + + const data = '{"test":"true"}'; + const server = Hapi.server({ compression: { minBytes: 1, priority: ['br'] } }); + server.route({ method: 'GET', path: '/', handler: () => data }); + await server.start(); + + const uri = 'http://localhost:' + server.info.port; + const brotlied = await internals.compress('brotliCompress', Buffer.from(data)); + const { payload } = await Wreck.get(uri, { headers: { 'accept-encoding': 'gzip, deflate, br' } }); + expect(payload.toString()).to.equal(brotlied.toString()); + await server.stop(); + }); + + it('returns a zstd response on a post request when accept-encoding: gzip, deflate, br, zstd is requested', async () => { + + const data = '{"test":"true"}'; + const server = Hapi.server({ compression: { minBytes: 1, priority: ['zstd'] } }); + server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); + await server.start(); + + const uri = 'http://localhost:' + server.info.port; + const zstded = await internals.compress('zstdCompress', Buffer.from(data)); + const { payload } = await Wreck.post(uri, { headers: { 'accept-encoding': 'gzip, deflate, br, zstd' }, payload: data }); + expect(payload.toString()).to.equal(zstded.toString()); + await server.stop(); + }); + + it('returns a zstd response on a get request when accept-encoding: gzip, deflate, br, zstd is requested', async () => { + + const data = '{"test":"true"}'; + const server = Hapi.server({ compression: { minBytes: 1, priority: ['zstd'] } }); + server.route({ method: 'GET', path: '/', handler: () => data }); + await server.start(); + + const uri = 'http://localhost:' + server.info.port; + const zstded = await internals.compress('zstdCompress', Buffer.from(data)); + const { payload } = await Wreck.get(uri, { headers: { 'accept-encoding': 'gzip, deflate, br, zstd' } }); + expect(payload.toString()).to.equal(zstded.toString()); + await server.stop(); + }); + it('boom object reused does not affect encoding header.', async () => { const error = Boom.badRequest(); From f3cb4517ffaf2eeb5f89be2b19c940647ed2fb02 Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Sat, 26 Jul 2025 16:31:37 +0200 Subject: [PATCH 05/25] =?UTF-8?q?fix:=20cure=20=E2=9A=95=EF=B8=8F=20ci?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci-module.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-module.yml b/.github/workflows/ci-module.yml index 7229300a8..7b9ef3270 100644 --- a/.github/workflows/ci-module.yml +++ b/.github/workflows/ci-module.yml @@ -12,4 +12,4 @@ jobs: test: uses: hapijs/.github/.github/workflows/ci-module.yml@master with: - min-node-version: 14 + min-node-version: 22 From 16d3e9879a805ec5fae2077d5667fd6b01ef009f Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Sun, 27 Jul 2025 10:46:00 +0200 Subject: [PATCH 06/25] =?UTF-8?q?feat:=20adjust=20default=20priority=20?= =?UTF-8?q?=F0=9F=8E=9B=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/compression.js | 12 ++++++------ test/transmit.js | 1 + 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/compression.js b/lib/compression.js index 2e030bc5c..4102ad59f 100755 --- a/lib/compression.js +++ b/lib/compression.js @@ -8,14 +8,14 @@ const Hoek = require('@hapi/hoek'); const internals = { common: [ + 'gzip, deflate, br, zstd', + 'gzip, deflate, br', + 'zstd', + 'br', 'gzip, deflate', 'deflate, gzip', 'gzip', - 'deflate', - 'br', - 'gzip, deflate, br', - 'zstd', - 'gzip, deflate, br, zstd' + 'deflate' ] }; @@ -29,7 +29,7 @@ exports = module.exports = internals.Compression = class { zstd: (options) => Zlib.createZstdDecompress(options) }; - encodings = ['identity', 'gzip', 'deflate', 'br', 'zstd']; + encodings = ['identity', 'zstd', 'br', 'gzip', 'deflate']; encoders = { identity: null, diff --git a/test/transmit.js b/test/transmit.js index d01ba0501..5a600ddac 100755 --- a/test/transmit.js +++ b/test/transmit.js @@ -27,6 +27,7 @@ const internals = {}; const { describe, it } = exports.lab = Lab.script(); const expect = Code.expect; + describe('transmission', () => { describe('send()', () => { From 36db0b6161a5c8192df1829b0446a99e0163c70a Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Tue, 29 Jul 2025 12:30:07 +0200 Subject: [PATCH 07/25] feat: make built-in `br` & `zstd` algos opt-in and disabled by default --- API.md | 12 +++++++ lib/compression.js | 25 ++++++++++---- lib/config.js | 2 ++ lib/core.js | 8 +++++ lib/types/server/options.d.ts | 2 ++ test/payload.js | 4 +-- test/transmit.js | 65 +++++++++++++++++++++-------------- 7 files changed, 83 insertions(+), 35 deletions(-) diff --git a/API.md b/API.md index 2362890c4..b86cb2eca 100755 --- a/API.md +++ b/API.md @@ -103,6 +103,18 @@ Default value: `{ minBytes: 1024 }`. Defines server handling of content encoding requests. If `false`, response content encoding is disabled and no compression is performed by the server. +##### `server.options.compression.enableBrotli` + +Default value: `false`. + +Enables built-in support of `brotli` compression algorithm. + +##### `server.options.compression.enableZstd` + +Default value: `false`. + +Enables built-in support of `zstd` compression algorithm. + ##### `server.options.compression.minBytes` Default value: '1024'. diff --git a/lib/compression.js b/lib/compression.js index 4102ad59f..862431d98 100755 --- a/lib/compression.js +++ b/lib/compression.js @@ -23,20 +23,16 @@ const internals = { exports = module.exports = internals.Compression = class { decoders = { - br: (options) => Zlib.createBrotliDecompress(options), gzip: (options) => Zlib.createGunzip(options), - deflate: (options) => Zlib.createInflate(options), - zstd: (options) => Zlib.createZstdDecompress(options) + deflate: (options) => Zlib.createInflate(options) }; - encodings = ['identity', 'zstd', 'br', 'gzip', 'deflate']; + encodings = ['identity', 'gzip', 'deflate']; encoders = { identity: null, - br: (options) => Zlib.createBrotliCompress(options), gzip: (options) => Zlib.createGzip(options), - deflate: (options) => Zlib.createDeflate(options), - zstd: (options) => Zlib.createZstdCompress(options) + deflate: (options) => Zlib.createDeflate(options) }; #common = null; @@ -129,9 +125,24 @@ exports = module.exports = internals.Compression = class { return encoder(request.route.settings.compression[encoding]); } + enableBrotliCompression() { + + this.decoders.br = (options) => Zlib.createBrotliDecompress(options); + this.encoders.br = (options) => Zlib.createBrotliCompress(options); + this.setPriority(['br']); + } + + enableZstdCompression() { + + this.decoders.zstd = (options) => Zlib.createZstdDecompress(options); + this.encoders.zstd = (options) => Zlib.createZstdCompress(options); + this.setPriority(['zstd']); + } + setPriority(priority) { this.encodings = [...new Set([...priority, ...this.encodings])]; + this._updateCommons(); } }; diff --git a/lib/config.js b/lib/config.js index 9563bfb3d..0047c9d5d 100755 --- a/lib/config.js +++ b/lib/config.js @@ -241,6 +241,8 @@ internals.server = Validate.object({ autoListen: Validate.boolean(), cache: Validate.allow(null), // Validated elsewhere compression: Validate.object({ + enableBrotli: Validate.boolean().default(false), + enableZstd: Validate.boolean().default(false), minBytes: Validate.number().min(1).integer().default(1024), priority: Validate.array().items(Validate.string().valid('gzip', 'deflate', 'br', 'zstd')).default(null) }) diff --git a/lib/core.js b/lib/core.js index 6192a420d..752bc857b 100755 --- a/lib/core.js +++ b/lib/core.js @@ -127,6 +127,14 @@ exports = module.exports = internals.Core = class { this._debug(); this._initializeCache(); + if (this.settings.compression.enableBrotli) { + this.compression.enableBrotliCompression(); + } + + if (this.settings.compression.enableZstd) { + this.compression.enableZstdCompression(); + } + if (this.settings.compression.priority) { this.compression.setPriority(this.settings.compression.priority); } diff --git a/lib/types/server/options.d.ts b/lib/types/server/options.d.ts index 9ace20f05..df7f8063b 100644 --- a/lib/types/server/options.d.ts +++ b/lib/types/server/options.d.ts @@ -9,6 +9,8 @@ import { CacheProvider, ServerOptionsCache } from './cache'; import { SameSitePolicy, ServerStateCookieOptions } from './state'; export interface ServerOptionsCompression { + enableBrotli: boolean; + enableZstd: boolean; minBytes: number; priority: string[]; } diff --git a/test/payload.js b/test/payload.js index 1820fc794..336c96c0d 100755 --- a/test/payload.js +++ b/test/payload.js @@ -528,7 +528,7 @@ describe('Payload', () => { it('handles br payload', async () => { const message = { 'msg': 'This message is going to be brotlied.' }; - const server = Hapi.server(); + const server = Hapi.server({ compression: { enableBrotli: true } }); server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); const compressed = await new Promise((resolve) => Zlib.brotliCompress(JSON.stringify(message), (ignore, result) => resolve(result))); @@ -552,7 +552,7 @@ describe('Payload', () => { it('handles zstd payload', async () => { const message = { 'msg': 'This message is going to be zstded.' }; - const server = Hapi.server(); + const server = Hapi.server({ compression: { enableZstd: true } }); server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); const compressed = await new Promise((resolve) => Zlib.zstdCompress(JSON.stringify(message), (ignore, result) => resolve(result))); diff --git a/test/transmit.js b/test/transmit.js index 5a600ddac..7332d0844 100755 --- a/test/transmit.js +++ b/test/transmit.js @@ -679,7 +679,7 @@ describe('transmission', () => { it('returns a zstded file in the response when the request accepts zstd', async () => { - const server = Hapi.server({ compression: { minBytes: 1 }, routes: { files: { relativeTo: __dirname } } }); + const server = Hapi.server({ compression: { enableZstd: true, minBytes: 1 }, routes: { files: { relativeTo: __dirname } } }); await server.register(Inert); server.route({ method: 'GET', path: '/file', handler: (request, h) => h.file(__dirname + '/../package.json') }); @@ -692,7 +692,7 @@ describe('transmission', () => { it('returns a brotlied file in the response when the request accepts br', async () => { - const server = Hapi.server({ compression: { minBytes: 1 }, routes: { files: { relativeTo: __dirname } } }); + const server = Hapi.server({ compression: { enableBrotli: true, minBytes: 1 }, routes: { files: { relativeTo: __dirname } } }); await server.register(Inert); server.route({ method: 'GET', path: '/file', handler: (request, h) => h.file(__dirname + '/../package.json') }); @@ -755,6 +755,19 @@ describe('transmission', () => { expect(res.payload).to.exist(); }); + it('returns a deflated file in the response when the request accepts gzip, deflate but priority set to deflate', async () => { + + const server = Hapi.server({ compression: { minBytes: 1, priority: ['deflate'] }, routes: { files: { relativeTo: __dirname } } }); + await server.register(Inert); + server.route({ method: 'GET', path: '/file', handler: (request, h) => h.file(__dirname + '/../package.json') }); + + const res = await server.inject({ url: '/file', headers: { 'accept-encoding': 'gzip, deflate' } }); + expect(res.headers['content-type']).to.equal('application/json; charset=utf-8'); + expect(res.headers['content-encoding']).to.equal('deflate'); + expect(res.headers['content-length']).to.not.exist(); + expect(res.payload).to.exist(); + }); + it('returns a zstded stream response without a content-length header when accept-encoding is zstd', async () => { const server = Hapi.server({ compression: { minBytes: 1 } }); @@ -799,7 +812,7 @@ describe('transmission', () => { const data = '{"test":"true"}'; - const server = Hapi.server({ compression: { minBytes: 1 } }); + const server = Hapi.server({ compression: { enableZstd: true, minBytes: 1 } }); server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); await server.start(); @@ -814,7 +827,7 @@ describe('transmission', () => { const data = '{"test":"true"}'; - const server = Hapi.server({ compression: { minBytes: 1 } }); + const server = Hapi.server({ compression: { enableZstd: true, minBytes: 1 } }); server.route({ method: 'GET', path: '/', handler: () => data }); await server.start(); @@ -829,7 +842,7 @@ describe('transmission', () => { const data = '{"test":"true"}'; - const server = Hapi.server({ compression: { minBytes: 1 } }); + const server = Hapi.server({ compression: { enableBrotli: true, minBytes: 1 } }); server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); await server.start(); @@ -844,7 +857,7 @@ describe('transmission', () => { const data = '{"test":"true"}'; - const server = Hapi.server({ compression: { minBytes: 1 } }); + const server = Hapi.server({ compression: { enableBrotli: true, minBytes: 1 } }); server.route({ method: 'GET', path: '/', handler: () => data }); await server.start(); @@ -941,7 +954,7 @@ describe('transmission', () => { await server.stop(); }); - it('returns a gzip response on a post request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=0.7 is requested', async () => { + it('returns a gzip response on a post request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=0.7; zstd;q=0.8 is requested', async () => { const data = '{"test":"true"}'; const server = Hapi.server({ compression: { minBytes: 1 } }); @@ -950,12 +963,12 @@ describe('transmission', () => { const uri = 'http://localhost:' + server.info.port; const zipped = await internals.compress('gzip', Buffer.from(data)); - const { payload } = await Wreck.post(uri, { headers: { 'accept-encoding': 'gzip;q=1, deflate;q=0.5, br;q=0.7' }, payload: data }); + const { payload } = await Wreck.post(uri, { headers: { 'accept-encoding': 'gzip;q=1, deflate;q=0.5, br;q=0.7, zstd;q=0.8' }, payload: data }); expect(payload.toString()).to.equal(zipped.toString()); await server.stop(); }); - it('returns a gzip response on a get request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=0.7 is requested', async () => { + it('returns a gzip response on a get request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=0.7, zstd;q=0.8 is requested', async () => { const data = '{"test":"true"}'; const server = Hapi.server({ compression: { minBytes: 1 } }); @@ -964,12 +977,12 @@ describe('transmission', () => { const uri = 'http://localhost:' + server.info.port; const zipped = await internals.compress('gzip', Buffer.from(data)); - const { payload } = await Wreck.get(uri, { headers: { 'accept-encoding': 'gzip;q=1, deflate;q=0.5, br;q=0.7' } }); + const { payload } = await Wreck.get(uri, { headers: { 'accept-encoding': 'gzip;q=1, deflate;q=0.5, br;q=0.7, zstd;q=0.8' } }); expect(payload.toString()).to.equal(zipped.toString()); await server.stop(); }); - it('returns a deflate response on a post request when accept-encoding: deflate;q=1, gzip;q=0.5, br;q=0.7, zstd;q=1 is requested', async () => { + it('returns a deflate response on a post request when accept-encoding: deflate;q=1, gzip;q=0.5, br;q=0.7, zstd;q=0.8 is requested', async () => { const data = '{"test":"true"}'; const server = Hapi.server({ compression: { minBytes: 1 } }); @@ -978,12 +991,12 @@ describe('transmission', () => { const uri = 'http://localhost:' + server.info.port; const deflated = await internals.compress('deflate', Buffer.from(data)); - const { payload } = await Wreck.post(uri, { headers: { 'accept-encoding': 'deflate;q=1, gzip;q=0.5, br;q=0.7, zstd;q=1' }, payload: data }); + const { payload } = await Wreck.post(uri, { headers: { 'accept-encoding': 'deflate;q=1, gzip;q=0.5, br;q=0.7, zstd;q=0.8' }, payload: data }); expect(payload.toString()).to.equal(deflated.toString()); await server.stop(); }); - it('returns a deflate response on a get request when accept-encoding: deflate;q=1, gzip;q=0.5, br;q=0.7, zstd;q=1 is requested', async () => { + it('returns a deflate response on a get request when accept-encoding: deflate;q=1, gzip;q=0.5, br;q=0.7, zstd;q=0.8 is requested', async () => { const data = '{"test":"true"}'; const server = Hapi.server({ compression: { minBytes: 1 } }); @@ -992,35 +1005,35 @@ describe('transmission', () => { const uri = 'http://localhost:' + server.info.port; const deflated = await internals.compress('deflate', Buffer.from(data)); - const { payload } = await Wreck.get(uri, { headers: { 'accept-encoding': 'deflate;q=1, gzip;q=0.5, br;q=0.7, zstd;q=1' } }); + const { payload } = await Wreck.get(uri, { headers: { 'accept-encoding': 'deflate;q=1, gzip;q=0.5, br;q=0.7, zstd;q=0.8' } }); expect(payload.toString()).to.equal(deflated.toString()); await server.stop(); }); - it('returns a br response on a post request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=1 is requested', async () => { + it('returns a br response on a post request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=1, zstd;q=0.8 is requested', async () => { const data = '{"test":"true"}'; - const server = Hapi.server({ compression: { minBytes: 1, priority: ['br'] } }); + const server = Hapi.server({ compression: { enableBrotli: true, minBytes: 1 } }); server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); await server.start(); const uri = 'http://localhost:' + server.info.port; const brotlied = await internals.compress('brotliCompress', Buffer.from(data)); - const { payload } = await Wreck.post(uri, { headers: { 'accept-encoding': 'gzip;q=1, deflate;q=0.5, br;q=1' }, payload: data }); + const { payload } = await Wreck.post(uri, { headers: { 'accept-encoding': 'gzip;q=1, deflate;q=0.5, br;q=1, zstd;q=0.8' }, payload: data }); expect(payload.toString()).to.equal(brotlied.toString()); await server.stop(); }); - it('returns a br response on a get request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=1 is requested', async () => { + it('returns a br response on a get request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=1, zstd;q=0.8 is requested', async () => { const data = '{"test":"true"}'; - const server = Hapi.server({ compression: { minBytes: 1, priority: ['br'] } }); + const server = Hapi.server({ compression: { enableBrotli: true, minBytes: 1 } }); server.route({ method: 'GET', path: '/', handler: () => data }); await server.start(); const uri = 'http://localhost:' + server.info.port; const brotlied = await internals.compress('brotliCompress', Buffer.from(data)); - const { payload } = await Wreck.get(uri, { headers: { 'accept-encoding': 'gzip;q=1, deflate;q=0.5, br;q=1' } }); + const { payload } = await Wreck.get(uri, { headers: { 'accept-encoding': 'gzip;q=1, deflate;q=0.5, br;q=1, zstd;q=0.8' } }); expect(payload.toString()).to.equal(brotlied.toString()); await server.stop(); }); @@ -1028,7 +1041,7 @@ describe('transmission', () => { it('returns a zstd response on a post request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=0.7; zstd;q=1 is requested', async () => { const data = '{"test":"true"}'; - const server = Hapi.server({ compression: { minBytes: 1, priority: ['zstd'] } }); + const server = Hapi.server({ compression: { enableZstd: true, minBytes: 1 } }); server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); await server.start(); @@ -1042,7 +1055,7 @@ describe('transmission', () => { it('returns a zstd response on a get request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=0.7, zstd;q=1 is requested', async () => { const data = '{"test":"true"}'; - const server = Hapi.server({ compression: { minBytes: 1, priority: ['zstd'] } }); + const server = Hapi.server({ compression: { enableZstd: true, minBytes: 1 } }); server.route({ method: 'GET', path: '/', handler: () => data }); await server.start(); @@ -1084,7 +1097,7 @@ describe('transmission', () => { it('returns a br response on a post request when accept-encoding: gzip, deflate, br is requested', async () => { const data = '{"test":"true"}'; - const server = Hapi.server({ compression: { minBytes: 1, priority: ['br'] } }); + const server = Hapi.server({ compression: { enableBrotli: true, minBytes: 1 } }); server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); await server.start(); @@ -1098,7 +1111,7 @@ describe('transmission', () => { it('returns a br response on a get request when accept-encoding: gzip, deflate, br is requested', async () => { const data = '{"test":"true"}'; - const server = Hapi.server({ compression: { minBytes: 1, priority: ['br'] } }); + const server = Hapi.server({ compression: { enableBrotli: true, minBytes: 1 } }); server.route({ method: 'GET', path: '/', handler: () => data }); await server.start(); @@ -1112,7 +1125,7 @@ describe('transmission', () => { it('returns a zstd response on a post request when accept-encoding: gzip, deflate, br, zstd is requested', async () => { const data = '{"test":"true"}'; - const server = Hapi.server({ compression: { minBytes: 1, priority: ['zstd'] } }); + const server = Hapi.server({ compression: { enableZstd: true, minBytes: 1 } }); server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); await server.start(); @@ -1126,7 +1139,7 @@ describe('transmission', () => { it('returns a zstd response on a get request when accept-encoding: gzip, deflate, br, zstd is requested', async () => { const data = '{"test":"true"}'; - const server = Hapi.server({ compression: { minBytes: 1, priority: ['zstd'] } }); + const server = Hapi.server({ compression: { enableZstd: true, minBytes: 1 } }); server.route({ method: 'GET', path: '/', handler: () => data }); await server.start(); From a5ba404f070aa2b0ff14fa95ed9ba986aac97429 Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Tue, 29 Jul 2025 12:36:26 +0200 Subject: [PATCH 08/25] =?UTF-8?q?chore:=20brush=20up=20=F0=9F=92=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/compression.js | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/compression.js b/lib/compression.js index 862431d98..f95534fc2 100755 --- a/lib/compression.js +++ b/lib/compression.js @@ -142,7 +142,6 @@ exports = module.exports = internals.Compression = class { setPriority(priority) { this.encodings = [...new Set([...priority, ...this.encodings])]; - this._updateCommons(); } }; From b26b41aa0237410c41a694a9295bd466fdd78910 Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Sat, 2 Aug 2025 11:26:51 +0200 Subject: [PATCH 09/25] =?UTF-8?q?feat:=20add=20support=20for=20compression?= =?UTF-8?q?=20=F0=9F=97=9C=20options=20=F0=9F=8E=9B=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 15 ++++++++++++++- lib/compression.js | 12 ++++++------ lib/config.js | 10 ++++++++-- lib/core.js | 4 ++-- 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/API.md b/API.md index b86cb2eca..05f216a0a 100755 --- a/API.md +++ b/API.md @@ -109,11 +109,24 @@ Default value: `false`. Enables built-in support of `brotli` compression algorithm. +Available values: + +- `false` - no compression. +- `true` - compression with system defaults. +- [`BrotliOptions`](https://nodejs.org/api/zlib.html#class-brotlioptions) - compression with specified options. + ##### `server.options.compression.enableZstd` Default value: `false`. -Enables built-in support of `zstd` compression algorithm. +Enables built-in support of `zstd` compression algorithm. +Zstd compression is experimental (see [node Zstd documentation](https://nodejs.org/api/zlib.html#zlibcreatezstdcompressoptions)). + +Available values: + +- `false` - no compression. +- `true` - compression with system defaults. +- [`ZstdOptions`](https://nodejs.org/api/zlib.html#class-zstdoptions) - compression with specified options. ##### `server.options.compression.minBytes` diff --git a/lib/compression.js b/lib/compression.js index f95534fc2..e8052b3b6 100755 --- a/lib/compression.js +++ b/lib/compression.js @@ -125,17 +125,17 @@ exports = module.exports = internals.Compression = class { return encoder(request.route.settings.compression[encoding]); } - enableBrotliCompression() { + enableBrotliCompression(compressionOptions) { - this.decoders.br = (options) => Zlib.createBrotliDecompress(options); - this.encoders.br = (options) => Zlib.createBrotliCompress(options); + this.decoders.br = (options) => Zlib.createBrotliDecompress({ ...options, ...compressionOptions }); + this.encoders.br = (options) => Zlib.createBrotliCompress({ ...options, ...compressionOptions }); this.setPriority(['br']); } - enableZstdCompression() { + enableZstdCompression(compressionOptions) { - this.decoders.zstd = (options) => Zlib.createZstdDecompress(options); - this.encoders.zstd = (options) => Zlib.createZstdCompress(options); + this.decoders.zstd = (options) => Zlib.createZstdDecompress({ ...options, ...compressionOptions }); + this.encoders.zstd = (options) => Zlib.createZstdCompress({ ...options, ...compressionOptions }); this.setPriority(['zstd']); } diff --git a/lib/config.js b/lib/config.js index 0047c9d5d..19e85012b 100755 --- a/lib/config.js +++ b/lib/config.js @@ -241,8 +241,14 @@ internals.server = Validate.object({ autoListen: Validate.boolean(), cache: Validate.allow(null), // Validated elsewhere compression: Validate.object({ - enableBrotli: Validate.boolean().default(false), - enableZstd: Validate.boolean().default(false), + enableBrotli: Validate.alternatives([ + Validate.boolean(), + Validate.object() + ]).default(false), + enableZstd: Validate.alternatives([ + Validate.boolean(), + Validate.object() + ]).default(false), minBytes: Validate.number().min(1).integer().default(1024), priority: Validate.array().items(Validate.string().valid('gzip', 'deflate', 'br', 'zstd')).default(null) }) diff --git a/lib/core.js b/lib/core.js index 752bc857b..7f733da3c 100755 --- a/lib/core.js +++ b/lib/core.js @@ -128,11 +128,11 @@ exports = module.exports = internals.Core = class { this._initializeCache(); if (this.settings.compression.enableBrotli) { - this.compression.enableBrotliCompression(); + this.compression.enableBrotliCompression(this.settings.compression.enableBrotli); } if (this.settings.compression.enableZstd) { - this.compression.enableZstdCompression(); + this.compression.enableZstdCompression(this.settings.compression.enableZstd); } if (this.settings.compression.priority) { From ec58da7273272cac05d72214e257366a00ed794c Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Sat, 2 Aug 2025 11:33:26 +0200 Subject: [PATCH 10/25] =?UTF-8?q?fix:=20fixate=20=F0=9F=AA=9B=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/types/server/options.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/types/server/options.d.ts b/lib/types/server/options.d.ts index df7f8063b..8bd6d1e85 100644 --- a/lib/types/server/options.d.ts +++ b/lib/types/server/options.d.ts @@ -9,8 +9,8 @@ import { CacheProvider, ServerOptionsCache } from './cache'; import { SameSitePolicy, ServerStateCookieOptions } from './state'; export interface ServerOptionsCompression { - enableBrotli: boolean; - enableZstd: boolean; + enableBrotli: boolean | object; + enableZstd: boolean | object; minBytes: number; priority: string[]; } From b5f50e613a7230752a7a6d10ff2bf8bf0dab1627 Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Sat, 2 Aug 2025 17:14:32 +0200 Subject: [PATCH 11/25] =?UTF-8?q?fix:=20fixate=20=F0=9F=AA=9B=20types,=20t?= =?UTF-8?q?ake=202=20=F0=9F=8E=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/types/server/options.d.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/types/server/options.d.ts b/lib/types/server/options.d.ts index 8bd6d1e85..d5d65dc88 100644 --- a/lib/types/server/options.d.ts +++ b/lib/types/server/options.d.ts @@ -1,5 +1,6 @@ import * as http from 'http'; import * as https from 'https'; +import { BrotliOptions, ZstdOptions } from 'node:zlib'; import { MimosOptions } from '@hapi/mimos'; @@ -9,8 +10,8 @@ import { CacheProvider, ServerOptionsCache } from './cache'; import { SameSitePolicy, ServerStateCookieOptions } from './state'; export interface ServerOptionsCompression { - enableBrotli: boolean | object; - enableZstd: boolean | object; + enableBrotli: boolean | BrotliOptions; + enableZstd: boolean | ZstdOptions; minBytes: number; priority: string[]; } From 3a64184fc006ebce71f78c43fcc20a46645d92de Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Sat, 2 Aug 2025 17:28:45 +0200 Subject: [PATCH 12/25] =?UTF-8?q?chore:=20clean=20up=20=F0=9F=A7=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/types/server/options.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/types/server/options.d.ts b/lib/types/server/options.d.ts index d5d65dc88..9ae89f1f2 100644 --- a/lib/types/server/options.d.ts +++ b/lib/types/server/options.d.ts @@ -1,6 +1,6 @@ import * as http from 'http'; import * as https from 'https'; -import { BrotliOptions, ZstdOptions } from 'node:zlib'; +import { BrotliOptions, ZstdOptions } from 'zlib'; import { MimosOptions } from '@hapi/mimos'; From 0f0510851b1b706ac17d960a834025c2e2632df7 Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Sun, 3 Aug 2025 11:08:50 +0200 Subject: [PATCH 13/25] =?UTF-8?q?feat:=20address=20comments=20=F0=9F=93=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 2 +- package.json | 6 +++--- test/payload.js | 3 ++- test/transmit.js | 15 ++++++++------- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/API.md b/API.md index 05f216a0a..935eedfc3 100755 --- a/API.md +++ b/API.md @@ -119,7 +119,7 @@ Available values: Default value: `false`. -Enables built-in support of `zstd` compression algorithm. +Enables built-in support of `zstd` compression algorithm (node `>=22.15.0`). Zstd compression is experimental (see [node Zstd documentation](https://nodejs.org/api/zlib.html#zlibcreatezstdcompressoptions)). Available values: diff --git a/package.json b/package.json index 02c4a934c..f15c430c0 100755 --- a/package.json +++ b/package.json @@ -47,14 +47,14 @@ "@hapi/code": "^9.0.3", "@hapi/eslint-plugin": "^6.0.0", "@hapi/inert": "^7.1.0", - "@hapi/joi-legacy-test": "npm:@hapi/joi@^15.0.0", + "@hapi/joi-legacy-test": "npm:@hapi/joi@^15.1.1", "@hapi/lab": "^25.3.2", "@hapi/vision": "^7.0.3", "@hapi/wreck": "^18.1.0", - "@types/node": "^22.17.2", + "@types/node": "^22.17.0", "handlebars": "^4.7.8", "joi": "^17.13.3", - "legacy-readable-stream": "npm:readable-stream@^1.0.34", + "legacy-readable-stream": "npm:readable-stream@^1.1.14", "typescript": "^4.9.5" }, "scripts": { diff --git a/test/payload.js b/test/payload.js index 336c96c0d..e1849fb6b 100755 --- a/test/payload.js +++ b/test/payload.js @@ -12,6 +12,7 @@ const Code = require('@hapi/code'); const Hapi = require('..'); const Hoek = require('@hapi/hoek'); const Lab = require('@hapi/lab'); +const Somever = require('@hapi/somever'); const Wreck = require('@hapi/wreck'); const internals = {}; @@ -549,7 +550,7 @@ describe('Payload', () => { expect(res.result).to.equal(message); }); - it('handles zstd payload', async () => { + it('handles zstd payload', { skip: Somever.match(process.version, '<22.15.0') }, async () => { const message = { 'msg': 'This message is going to be zstded.' }; const server = Hapi.server({ compression: { enableZstd: true } }); diff --git a/test/transmit.js b/test/transmit.js index 7332d0844..10dd16c74 100755 --- a/test/transmit.js +++ b/test/transmit.js @@ -16,6 +16,7 @@ const Hoek = require('@hapi/hoek'); const Bounce = require('@hapi/bounce'); const Inert = require('@hapi/inert'); const Lab = require('@hapi/lab'); +const Somever = require('@hapi/somever'); const Teamwork = require('@hapi/teamwork'); const Wreck = require('@hapi/wreck'); @@ -677,7 +678,7 @@ describe('transmission', () => { expect(res3.headers['last-modified']).to.equal(res2.headers['last-modified']); }); - it('returns a zstded file in the response when the request accepts zstd', async () => { + it('returns a zstded file in the response when the request accepts zstd', { skip: Somever.match(process.version, '<22.15.0') }, async () => { const server = Hapi.server({ compression: { enableZstd: true, minBytes: 1 }, routes: { files: { relativeTo: __dirname } } }); await server.register(Inert); @@ -808,7 +809,7 @@ describe('transmission', () => { expect(res.headers['content-length']).to.not.exist(); }); - it('returns a zstd response on a post request when accept-encoding: zstd is requested', async () => { + it('returns a zstd response on a post request when accept-encoding: zstd is requested', { skip: Somever.match(process.version, '<22.15.0') }, async () => { const data = '{"test":"true"}'; @@ -823,7 +824,7 @@ describe('transmission', () => { await server.stop(); }); - it('returns a zstd response on a get request when accept-encoding: zstd is requested', async () => { + it('returns a zstd response on a get request when accept-encoding: zstd is requested', { skip: Somever.match(process.version, '<22.15.0') }, async () => { const data = '{"test":"true"}'; @@ -1038,7 +1039,7 @@ describe('transmission', () => { await server.stop(); }); - it('returns a zstd response on a post request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=0.7; zstd;q=1 is requested', async () => { + it('returns a zstd response on a post request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=0.7; zstd;q=1 is requested', { skip: Somever.match(process.version, '<22.15.0') }, async () => { const data = '{"test":"true"}'; const server = Hapi.server({ compression: { enableZstd: true, minBytes: 1 } }); @@ -1052,7 +1053,7 @@ describe('transmission', () => { await server.stop(); }); - it('returns a zstd response on a get request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=0.7, zstd;q=1 is requested', async () => { + it('returns a zstd response on a get request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=0.7, zstd;q=1 is requested', { skip: Somever.match(process.version, '<22.15.0') }, async () => { const data = '{"test":"true"}'; const server = Hapi.server({ compression: { enableZstd: true, minBytes: 1 } }); @@ -1122,7 +1123,7 @@ describe('transmission', () => { await server.stop(); }); - it('returns a zstd response on a post request when accept-encoding: gzip, deflate, br, zstd is requested', async () => { + it('returns a zstd response on a post request when accept-encoding: gzip, deflate, br, zstd is requested', { skip: Somever.match(process.version, '<22.15.0') }, async () => { const data = '{"test":"true"}'; const server = Hapi.server({ compression: { enableZstd: true, minBytes: 1 } }); @@ -1136,7 +1137,7 @@ describe('transmission', () => { await server.stop(); }); - it('returns a zstd response on a get request when accept-encoding: gzip, deflate, br, zstd is requested', async () => { + it('returns a zstd response on a get request when accept-encoding: gzip, deflate, br, zstd is requested', { skip: Somever.match(process.version, '<22.15.0') }, async () => { const data = '{"test":"true"}'; const server = Hapi.server({ compression: { enableZstd: true, minBytes: 1 } }); From 6792069ba2a28cd0c00d52804dc8b644227d00cb Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Sun, 3 Aug 2025 11:18:03 +0200 Subject: [PATCH 14/25] =?UTF-8?q?feat:=20reverse=20=E2=86=A9=EF=B8=8F=20en?= =?UTF-8?q?gine=20=F0=9F=9A=82=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f15c430c0..cdd5bda4a 100755 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "main": "lib/index.js", "types": "lib/index.d.ts", "engines": { - "node": ">=22.15.0" + "node": ">=14.15.0" }, "files": [ "lib" From 7888aad1af3c141e206fb6de6b98c9ebc7bc88d7 Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Sun, 3 Aug 2025 11:20:19 +0200 Subject: [PATCH 15/25] =?UTF-8?q?feat:=20reverse=20=E2=86=A9=EF=B8=8F=20ci?= =?UTF-8?q?=20min=20node=20version=20=F0=9F=94=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci-module.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-module.yml b/.github/workflows/ci-module.yml index 7b9ef3270..7229300a8 100644 --- a/.github/workflows/ci-module.yml +++ b/.github/workflows/ci-module.yml @@ -12,4 +12,4 @@ jobs: test: uses: hapijs/.github/.github/workflows/ci-module.yml@master with: - min-node-version: 22 + min-node-version: 14 From 9a4336df9bfabaf4855559811cd35d00b89ff174 Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Sun, 3 Aug 2025 12:00:15 +0200 Subject: [PATCH 16/25] =?UTF-8?q?feat:=20betterment=20=F0=9F=92=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/compression.js | 1 + test/common.js | 3 +++ test/payload.js | 5 +++-- test/transmit.js | 15 +++++++-------- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/lib/compression.js b/lib/compression.js index e8052b3b6..f9050e2e6 100755 --- a/lib/compression.js +++ b/lib/compression.js @@ -134,6 +134,7 @@ exports = module.exports = internals.Compression = class { enableZstdCompression(compressionOptions) { + Hoek.assert(!!Zlib.constants.ZSTD_CLEVEL_DEFAULT, 'Zstd is not supported by the engine'); this.decoders.zstd = (options) => Zlib.createZstdDecompress({ ...options, ...compressionOptions }); this.encoders.zstd = (options) => Zlib.createZstdCompress({ ...options, ...compressionOptions }); this.setPriority(['zstd']); diff --git a/test/common.js b/test/common.js index eae48a36b..265de6992 100644 --- a/test/common.js +++ b/test/common.js @@ -3,6 +3,7 @@ const ChildProcess = require('child_process'); const Http = require('http'); const Net = require('net'); +const Zlib = require('zlib'); const internals = {}; @@ -30,3 +31,5 @@ internals.hasIPv6 = () => { exports.hasLsof = internals.hasLsof(); exports.hasIPv6 = internals.hasIPv6(); + +exports.hasZstd = !!Zlib.constants.ZSTD_CLEVEL_DEFAULT; diff --git a/test/payload.js b/test/payload.js index e1849fb6b..5074ec479 100755 --- a/test/payload.js +++ b/test/payload.js @@ -12,9 +12,10 @@ const Code = require('@hapi/code'); const Hapi = require('..'); const Hoek = require('@hapi/hoek'); const Lab = require('@hapi/lab'); -const Somever = require('@hapi/somever'); const Wreck = require('@hapi/wreck'); +const Common = require('./common'); + const internals = {}; @@ -550,7 +551,7 @@ describe('Payload', () => { expect(res.result).to.equal(message); }); - it('handles zstd payload', { skip: Somever.match(process.version, '<22.15.0') }, async () => { + it('handles zstd payload', { skip: !Common.hasZstd }, async () => { const message = { 'msg': 'This message is going to be zstded.' }; const server = Hapi.server({ compression: { enableZstd: true } }); diff --git a/test/transmit.js b/test/transmit.js index 10dd16c74..542c3242e 100755 --- a/test/transmit.js +++ b/test/transmit.js @@ -16,7 +16,6 @@ const Hoek = require('@hapi/hoek'); const Bounce = require('@hapi/bounce'); const Inert = require('@hapi/inert'); const Lab = require('@hapi/lab'); -const Somever = require('@hapi/somever'); const Teamwork = require('@hapi/teamwork'); const Wreck = require('@hapi/wreck'); @@ -678,7 +677,7 @@ describe('transmission', () => { expect(res3.headers['last-modified']).to.equal(res2.headers['last-modified']); }); - it('returns a zstded file in the response when the request accepts zstd', { skip: Somever.match(process.version, '<22.15.0') }, async () => { + it('returns a zstded file in the response when the request accepts zstd', { skip: !Common.hasZstd }, async () => { const server = Hapi.server({ compression: { enableZstd: true, minBytes: 1 }, routes: { files: { relativeTo: __dirname } } }); await server.register(Inert); @@ -809,7 +808,7 @@ describe('transmission', () => { expect(res.headers['content-length']).to.not.exist(); }); - it('returns a zstd response on a post request when accept-encoding: zstd is requested', { skip: Somever.match(process.version, '<22.15.0') }, async () => { + it('returns a zstd response on a post request when accept-encoding: zstd is requested', { skip: !Common.hasZstd }, async () => { const data = '{"test":"true"}'; @@ -824,7 +823,7 @@ describe('transmission', () => { await server.stop(); }); - it('returns a zstd response on a get request when accept-encoding: zstd is requested', { skip: Somever.match(process.version, '<22.15.0') }, async () => { + it('returns a zstd response on a get request when accept-encoding: zstd is requested', { skip: !Common.hasZstd }, async () => { const data = '{"test":"true"}'; @@ -1039,7 +1038,7 @@ describe('transmission', () => { await server.stop(); }); - it('returns a zstd response on a post request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=0.7; zstd;q=1 is requested', { skip: Somever.match(process.version, '<22.15.0') }, async () => { + it('returns a zstd response on a post request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=0.7; zstd;q=1 is requested', { skip: !Common.hasZstd }, async () => { const data = '{"test":"true"}'; const server = Hapi.server({ compression: { enableZstd: true, minBytes: 1 } }); @@ -1053,7 +1052,7 @@ describe('transmission', () => { await server.stop(); }); - it('returns a zstd response on a get request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=0.7, zstd;q=1 is requested', { skip: Somever.match(process.version, '<22.15.0') }, async () => { + it('returns a zstd response on a get request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=0.7, zstd;q=1 is requested', { skip: !Common.hasZstd }, async () => { const data = '{"test":"true"}'; const server = Hapi.server({ compression: { enableZstd: true, minBytes: 1 } }); @@ -1123,7 +1122,7 @@ describe('transmission', () => { await server.stop(); }); - it('returns a zstd response on a post request when accept-encoding: gzip, deflate, br, zstd is requested', { skip: Somever.match(process.version, '<22.15.0') }, async () => { + it('returns a zstd response on a post request when accept-encoding: gzip, deflate, br, zstd is requested', { skip: !Common.hasZstd }, async () => { const data = '{"test":"true"}'; const server = Hapi.server({ compression: { enableZstd: true, minBytes: 1 } }); @@ -1137,7 +1136,7 @@ describe('transmission', () => { await server.stop(); }); - it('returns a zstd response on a get request when accept-encoding: gzip, deflate, br, zstd is requested', { skip: Somever.match(process.version, '<22.15.0') }, async () => { + it('returns a zstd response on a get request when accept-encoding: gzip, deflate, br, zstd is requested', { skip: !Common.hasZstd }, async () => { const data = '{"test":"true"}'; const server = Hapi.server({ compression: { enableZstd: true, minBytes: 1 } }); From 31c1b4bdcd11f6810b9e728d638b34c1aadc8b8f Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Sun, 3 Aug 2025 13:20:16 +0200 Subject: [PATCH 17/25] =?UTF-8?q?feat:=20address=20comments=20=F0=9F=93=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 2 +- lib/compression.js | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/API.md b/API.md index 935eedfc3..bba9c2349 100755 --- a/API.md +++ b/API.md @@ -119,7 +119,7 @@ Available values: Default value: `false`. -Enables built-in support of `zstd` compression algorithm (node `>=22.15.0`). +Enables built-in support of `zstd` compression algorithm (node: `>=22.15.0`). Zstd compression is experimental (see [node Zstd documentation](https://nodejs.org/api/zlib.html#zlibcreatezstdcompressoptions)). Available values: diff --git a/lib/compression.js b/lib/compression.js index f9050e2e6..7ab3c2172 100755 --- a/lib/compression.js +++ b/lib/compression.js @@ -127,6 +127,12 @@ exports = module.exports = internals.Compression = class { enableBrotliCompression(compressionOptions) { + const defaults = { + params: { + [Zlib.constants.BROTLI_PARAM_QUALITY]: 4 + } + }; + compressionOptions = Hoek.applyToDefaults(defaults, compressionOptions); this.decoders.br = (options) => Zlib.createBrotliDecompress({ ...options, ...compressionOptions }); this.encoders.br = (options) => Zlib.createBrotliCompress({ ...options, ...compressionOptions }); this.setPriority(['br']); From 08bf768dc844452a4e193577160f4c50f4003166 Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Mon, 4 Aug 2025 19:00:08 +0200 Subject: [PATCH 18/25] =?UTF-8?q?fix:=20cure=20=F0=9F=A9=B9=20code=20cover?= =?UTF-8?q?age?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/compression.js | 2 ++ lib/core.js | 2 ++ 2 files changed, 4 insertions(+) diff --git a/lib/compression.js b/lib/compression.js index 7ab3c2172..6e1a79e80 100755 --- a/lib/compression.js +++ b/lib/compression.js @@ -140,10 +140,12 @@ exports = module.exports = internals.Compression = class { enableZstdCompression(compressionOptions) { + /* $lab:coverage:off$ */ Hoek.assert(!!Zlib.constants.ZSTD_CLEVEL_DEFAULT, 'Zstd is not supported by the engine'); this.decoders.zstd = (options) => Zlib.createZstdDecompress({ ...options, ...compressionOptions }); this.encoders.zstd = (options) => Zlib.createZstdCompress({ ...options, ...compressionOptions }); this.setPriority(['zstd']); + /* $lab:coverage:on$ */ } setPriority(priority) { diff --git a/lib/core.js b/lib/core.js index 7f733da3c..0cf1f4bb1 100755 --- a/lib/core.js +++ b/lib/core.js @@ -131,9 +131,11 @@ exports = module.exports = internals.Core = class { this.compression.enableBrotliCompression(this.settings.compression.enableBrotli); } + /* $lab:coverage:off$ */ if (this.settings.compression.enableZstd) { this.compression.enableZstdCompression(this.settings.compression.enableZstd); } + /* $lab:coverage:on$ */ if (this.settings.compression.priority) { this.compression.setPriority(this.settings.compression.priority); From 22fac0dfe2153ff55e82693d084bc3dd7269c9b4 Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Sun, 14 Sep 2025 11:20:11 +0200 Subject: [PATCH 19/25] =?UTF-8?q?refactor:=20refinement=20=E2=9A=97?= =?UTF-8?q?=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 30 +++++-------- lib/compression.js | 79 ++++++++++++++++++++--------------- lib/config.js | 26 ++++++++---- lib/core.js | 13 +----- lib/types/server/options.d.ts | 12 ++++-- package.json | 4 +- test/payload.js | 4 +- test/transmit.js | 38 +++++++++-------- 8 files changed, 108 insertions(+), 98 deletions(-) diff --git a/API.md b/API.md index bba9c2349..7c9dfa853 100755 --- a/API.md +++ b/API.md @@ -98,35 +98,27 @@ assigned one or more (array): #### `server.options.compression` -Default value: `{ minBytes: 1024 }`. +Default value: `{ encodings: { gzip: true, deflate: true, br: false, zstd: false }, minBytes: 1024 }`. Defines server handling of content encoding requests. If `false`, response content encoding is -disabled and no compression is performed by the server. +disabled, and no compression is performed by the server. -##### `server.options.compression.enableBrotli` +#### `server.options.compression.encodings` -Default value: `false`. - -Enables built-in support of `brotli` compression algorithm. +Default value: `{ gzip: true, deflate: true, br: false, zstd: false }`. -Available values: +Configures the built-in support of compression algorithms aka encodings, represented by the object of kv pairs. -- `false` - no compression. -- `true` - compression with system defaults. -- [`BrotliOptions`](https://nodejs.org/api/zlib.html#class-brotlioptions) - compression with specified options. +Available values for each kv pair: -##### `server.options.compression.enableZstd` +- `true` - enables the encoding with default options. +- `false` - disables the encoding. +- `{...options}` - enables the encoding using custom options specific to each particular algorithm. -Default value: `false`. - -Enables built-in support of `zstd` compression algorithm (node: `>=22.15.0`). Zstd compression is experimental (see [node Zstd documentation](https://nodejs.org/api/zlib.html#zlibcreatezstdcompressoptions)). -Available values: - -- `false` - no compression. -- `true` - compression with system defaults. -- [`ZstdOptions`](https://nodejs.org/api/zlib.html#class-zstdoptions) - compression with specified options. +Disabling an encoding allows custom compression algorithm to be applied by +[`server.encoder()`](#server.encoder()) and [`server.decoder()`](#server.decoder()). ##### `server.options.compression.minBytes` diff --git a/lib/compression.js b/lib/compression.js index 6e1a79e80..1ac2ae4f2 100755 --- a/lib/compression.js +++ b/lib/compression.js @@ -16,30 +16,64 @@ const internals = { 'deflate, gzip', 'gzip', 'deflate' - ] + ], + provision: new Map([ + ['zstd', [ + (options) => Zlib.createZstdCompress(options), + (options) => Zlib.createZstdDecompress(options), + { + params: { + [Zlib.constants.ZSTD_c_compressionLevel]: 6 + } + } + ]], + ['br', [ + (options) => Zlib.createBrotliCompress(options), + (options) => Zlib.createBrotliDecompress(options), + { + params: { + [Zlib.constants.BROTLI_PARAM_QUALITY]: 4 + } + } + ]], + ['deflate', [ + (options) => Zlib.createDeflate(options), + (options) => Zlib.createInflate(options) + ]], + ['gzip', [ + (options) => Zlib.createGzip(options), + (options) => Zlib.createGunzip(options) + ]] + ]) }; exports = module.exports = internals.Compression = class { - decoders = { - gzip: (options) => Zlib.createGunzip(options), - deflate: (options) => Zlib.createInflate(options) - }; + decoders = {}; - encodings = ['identity', 'gzip', 'deflate']; + encodings = ['identity']; encoders = { - identity: null, - gzip: (options) => Zlib.createGzip(options), - deflate: (options) => Zlib.createDeflate(options) + identity: null }; #common = null; - constructor() { + constructor({ compression }) { - this._updateCommons(); + if (!compression) { + this._updateCommons(); + } + + for (const [alg, [encoder, decoder, defaults = {}]] of internals.provision.entries()) { + let conditions = compression?.encodings?.[alg]; + if (conditions) { + conditions = Hoek.applyToDefaults(defaults, conditions); + this.addEncoder(alg, (options = {}) => encoder(Hoek.applyToDefaults(conditions, options))); + this.addDecoder(alg, (options = {}) => decoder(Hoek.applyToDefaults(conditions, options))); + } + } } _updateCommons() { @@ -125,29 +159,6 @@ exports = module.exports = internals.Compression = class { return encoder(request.route.settings.compression[encoding]); } - enableBrotliCompression(compressionOptions) { - - const defaults = { - params: { - [Zlib.constants.BROTLI_PARAM_QUALITY]: 4 - } - }; - compressionOptions = Hoek.applyToDefaults(defaults, compressionOptions); - this.decoders.br = (options) => Zlib.createBrotliDecompress({ ...options, ...compressionOptions }); - this.encoders.br = (options) => Zlib.createBrotliCompress({ ...options, ...compressionOptions }); - this.setPriority(['br']); - } - - enableZstdCompression(compressionOptions) { - - /* $lab:coverage:off$ */ - Hoek.assert(!!Zlib.constants.ZSTD_CLEVEL_DEFAULT, 'Zstd is not supported by the engine'); - this.decoders.zstd = (options) => Zlib.createZstdDecompress({ ...options, ...compressionOptions }); - this.encoders.zstd = (options) => Zlib.createZstdCompress({ ...options, ...compressionOptions }); - this.setPriority(['zstd']); - /* $lab:coverage:on$ */ - } - setPriority(priority) { this.encodings = [...new Set([...priority, ...this.encodings])]; diff --git a/lib/config.js b/lib/config.js index 19e85012b..0b526e7c4 100755 --- a/lib/config.js +++ b/lib/config.js @@ -241,14 +241,24 @@ internals.server = Validate.object({ autoListen: Validate.boolean(), cache: Validate.allow(null), // Validated elsewhere compression: Validate.object({ - enableBrotli: Validate.alternatives([ - Validate.boolean(), - Validate.object() - ]).default(false), - enableZstd: Validate.alternatives([ - Validate.boolean(), - Validate.object() - ]).default(false), + encodings: Validate.object({ + gzip: Validate.alternatives([ + Validate.boolean(), + Validate.object() + ]).default(true), + deflate: Validate.alternatives([ + Validate.boolean(), + Validate.object() + ]).default(true), + br: Validate.alternatives([ + Validate.boolean(), + Validate.object() + ]).default(false), + zstd: Validate.alternatives([ + Validate.boolean(), + Validate.object() + ]).default(false) + }).default(), minBytes: Validate.number().min(1).integer().default(1024), priority: Validate.array().items(Validate.string().valid('gzip', 'deflate', 'br', 'zstd')).default(null) }) diff --git a/lib/core.js b/lib/core.js index 0cf1f4bb1..fc663faa7 100755 --- a/lib/core.js +++ b/lib/core.js @@ -54,7 +54,7 @@ exports = module.exports = internals.Core = class { app = {}; auth = new Auth(this); caches = new Map(); // Cache clients - compression = new Compression(); + compression = null; controlled = null; // Other servers linked to the phases of this server dependencies = []; // Plugin dependencies events = new Podium.Podium(internals.events); @@ -119,6 +119,7 @@ exports = module.exports = internals.Core = class { this.settings = settings; this.type = type; + this.compression = new Compression(this.settings); this.heavy = new Heavy(this.settings.load); this.mime = new Mimos(this.settings.mime); this.router = new Call.Router(this.settings.router); @@ -127,16 +128,6 @@ exports = module.exports = internals.Core = class { this._debug(); this._initializeCache(); - if (this.settings.compression.enableBrotli) { - this.compression.enableBrotliCompression(this.settings.compression.enableBrotli); - } - - /* $lab:coverage:off$ */ - if (this.settings.compression.enableZstd) { - this.compression.enableZstdCompression(this.settings.compression.enableZstd); - } - /* $lab:coverage:on$ */ - if (this.settings.compression.priority) { this.compression.setPriority(this.settings.compression.priority); } diff --git a/lib/types/server/options.d.ts b/lib/types/server/options.d.ts index 9ae89f1f2..45d0fdaf8 100644 --- a/lib/types/server/options.d.ts +++ b/lib/types/server/options.d.ts @@ -1,6 +1,6 @@ import * as http from 'http'; import * as https from 'https'; -import { BrotliOptions, ZstdOptions } from 'zlib'; +import { BrotliOptions, ZstdOptions, ZlibOptions } from 'zlib'; import { MimosOptions } from '@hapi/mimos'; @@ -10,10 +10,14 @@ import { CacheProvider, ServerOptionsCache } from './cache'; import { SameSitePolicy, ServerStateCookieOptions } from './state'; export interface ServerOptionsCompression { - enableBrotli: boolean | BrotliOptions; - enableZstd: boolean | ZstdOptions; + encodings?: { + gzip?: boolean | ZlibOptions; + deflate?: boolean | ZlibOptions; + br?: boolean | BrotliOptions; + zstd?: boolean | ZstdOptions; + }; minBytes: number; - priority: string[]; + priority?: string[]; } /** diff --git a/package.json b/package.json index cdd5bda4a..43e2f991d 100755 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "@hapi/somever": "^4.1.1", "@hapi/statehood": "^8.2.0", "@hapi/subtext": "^8.1.1", - "@hapi/teamwork": "^6.0.0", + "@hapi/teamwork": "^6.0.1", "@hapi/topo": "^6.0.2", "@hapi/validate": "^2.0.1" }, @@ -51,7 +51,7 @@ "@hapi/lab": "^25.3.2", "@hapi/vision": "^7.0.3", "@hapi/wreck": "^18.1.0", - "@types/node": "^22.17.0", + "@types/node": "^22.18.3", "handlebars": "^4.7.8", "joi": "^17.13.3", "legacy-readable-stream": "npm:readable-stream@^1.1.14", diff --git a/test/payload.js b/test/payload.js index 5074ec479..ff57cdc34 100755 --- a/test/payload.js +++ b/test/payload.js @@ -530,7 +530,7 @@ describe('Payload', () => { it('handles br payload', async () => { const message = { 'msg': 'This message is going to be brotlied.' }; - const server = Hapi.server({ compression: { enableBrotli: true } }); + const server = Hapi.server({ compression: { encodings: { br: true } } }); server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); const compressed = await new Promise((resolve) => Zlib.brotliCompress(JSON.stringify(message), (ignore, result) => resolve(result))); @@ -554,7 +554,7 @@ describe('Payload', () => { it('handles zstd payload', { skip: !Common.hasZstd }, async () => { const message = { 'msg': 'This message is going to be zstded.' }; - const server = Hapi.server({ compression: { enableZstd: true } }); + const server = Hapi.server({ compression: { encodings: { zstd: true } } }); server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); const compressed = await new Promise((resolve) => Zlib.zstdCompress(JSON.stringify(message), (ignore, result) => resolve(result))); diff --git a/test/transmit.js b/test/transmit.js index 542c3242e..d22242dda 100755 --- a/test/transmit.js +++ b/test/transmit.js @@ -679,7 +679,7 @@ describe('transmission', () => { it('returns a zstded file in the response when the request accepts zstd', { skip: !Common.hasZstd }, async () => { - const server = Hapi.server({ compression: { enableZstd: true, minBytes: 1 }, routes: { files: { relativeTo: __dirname } } }); + const server = Hapi.server({ compression: { encodings: { zstd: true }, minBytes: 1 }, routes: { files: { relativeTo: __dirname } } }); await server.register(Inert); server.route({ method: 'GET', path: '/file', handler: (request, h) => h.file(__dirname + '/../package.json') }); @@ -692,7 +692,7 @@ describe('transmission', () => { it('returns a brotlied file in the response when the request accepts br', async () => { - const server = Hapi.server({ compression: { enableBrotli: true, minBytes: 1 }, routes: { files: { relativeTo: __dirname } } }); + const server = Hapi.server({ compression: { encodings: { br: true }, minBytes: 1 }, routes: { files: { relativeTo: __dirname } } }); await server.register(Inert); server.route({ method: 'GET', path: '/file', handler: (request, h) => h.file(__dirname + '/../package.json') }); @@ -770,7 +770,7 @@ describe('transmission', () => { it('returns a zstded stream response without a content-length header when accept-encoding is zstd', async () => { - const server = Hapi.server({ compression: { minBytes: 1 } }); + const server = Hapi.server({ compression: { encodings: { zstd: true }, minBytes: 1 } }); server.route({ method: 'GET', path: '/stream', handler: () => new internals.TimerStream() }); const res = await server.inject({ url: '/stream', headers: { 'Content-Type': 'application/json', 'accept-encoding': 'zstd' } }); @@ -780,7 +780,7 @@ describe('transmission', () => { it('returns a brotlied stream response without a content-length header when accept-encoding is br', async () => { - const server = Hapi.server({ compression: { minBytes: 1 } }); + const server = Hapi.server({ compression: { encodings: { br: true }, minBytes: 1 } }); server.route({ method: 'GET', path: '/stream', handler: () => new internals.TimerStream() }); const res = await server.inject({ url: '/stream', headers: { 'Content-Type': 'application/json', 'accept-encoding': 'br' } }); @@ -812,7 +812,7 @@ describe('transmission', () => { const data = '{"test":"true"}'; - const server = Hapi.server({ compression: { enableZstd: true, minBytes: 1 } }); + const server = Hapi.server({ compression: { encodings: { zstd: true }, minBytes: 1 } }); server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); await server.start(); @@ -827,7 +827,7 @@ describe('transmission', () => { const data = '{"test":"true"}'; - const server = Hapi.server({ compression: { enableZstd: true, minBytes: 1 } }); + const server = Hapi.server({ compression: { encodings: { zstd: true }, minBytes: 1 } }); server.route({ method: 'GET', path: '/', handler: () => data }); await server.start(); @@ -842,7 +842,7 @@ describe('transmission', () => { const data = '{"test":"true"}'; - const server = Hapi.server({ compression: { enableBrotli: true, minBytes: 1 } }); + const server = Hapi.server({ compression: { encodings: { br: true }, minBytes: 1 } }); server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); await server.start(); @@ -857,7 +857,7 @@ describe('transmission', () => { const data = '{"test":"true"}'; - const server = Hapi.server({ compression: { enableBrotli: true, minBytes: 1 } }); + const server = Hapi.server({ compression: { encodings: { br: true }, minBytes: 1 } }); server.route({ method: 'GET', path: '/', handler: () => data }); await server.start(); @@ -908,8 +908,9 @@ describe('transmission', () => { await server.start(); const uri = 'http://localhost:' + server.info.port; + const zipped = await internals.compress('gzip', Buffer.from(data)); const { payload } = await Wreck.post(uri, { headers: { 'accept-encoding': '*' }, payload: data }); - expect(payload.toString()).to.equal(data); + expect(payload.toString()).to.equal(zipped.toString()); await server.stop(); }); @@ -921,8 +922,9 @@ describe('transmission', () => { await server.start(); const uri = 'http://localhost:' + server.info.port; + const zipped = await internals.compress('gzip', Buffer.from(data)); const { payload } = await Wreck.get(uri, { headers: { 'accept-encoding': '*' } }); - expect(payload.toString()).to.equal(data); + expect(payload.toString()).to.equal(zipped.toString()); await server.stop(); }); @@ -1013,7 +1015,7 @@ describe('transmission', () => { it('returns a br response on a post request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=1, zstd;q=0.8 is requested', async () => { const data = '{"test":"true"}'; - const server = Hapi.server({ compression: { enableBrotli: true, minBytes: 1 } }); + const server = Hapi.server({ compression: { encodings: { br: true }, minBytes: 1, priority: ['br'] } }); server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); await server.start(); @@ -1027,7 +1029,7 @@ describe('transmission', () => { it('returns a br response on a get request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=1, zstd;q=0.8 is requested', async () => { const data = '{"test":"true"}'; - const server = Hapi.server({ compression: { enableBrotli: true, minBytes: 1 } }); + const server = Hapi.server({ compression: { encodings: { br: true }, minBytes: 1, priority: ['br'] } }); server.route({ method: 'GET', path: '/', handler: () => data }); await server.start(); @@ -1041,7 +1043,7 @@ describe('transmission', () => { it('returns a zstd response on a post request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=0.7; zstd;q=1 is requested', { skip: !Common.hasZstd }, async () => { const data = '{"test":"true"}'; - const server = Hapi.server({ compression: { enableZstd: true, minBytes: 1 } }); + const server = Hapi.server({ compression: { encodings: { zstd: true }, minBytes: 1, priority: ['zstd'] } }); server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); await server.start(); @@ -1055,7 +1057,7 @@ describe('transmission', () => { it('returns a zstd response on a get request when accept-encoding: gzip;q=1, deflate;q=0.5, br;q=0.7, zstd;q=1 is requested', { skip: !Common.hasZstd }, async () => { const data = '{"test":"true"}'; - const server = Hapi.server({ compression: { enableZstd: true, minBytes: 1 } }); + const server = Hapi.server({ compression: { encodings: { zstd: true }, minBytes: 1, priority: ['zstd'] } }); server.route({ method: 'GET', path: '/', handler: () => data }); await server.start(); @@ -1097,7 +1099,7 @@ describe('transmission', () => { it('returns a br response on a post request when accept-encoding: gzip, deflate, br is requested', async () => { const data = '{"test":"true"}'; - const server = Hapi.server({ compression: { enableBrotli: true, minBytes: 1 } }); + const server = Hapi.server({ compression: { encodings: { br: true }, minBytes: 1, priority: ['br'] } }); server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); await server.start(); @@ -1111,7 +1113,7 @@ describe('transmission', () => { it('returns a br response on a get request when accept-encoding: gzip, deflate, br is requested', async () => { const data = '{"test":"true"}'; - const server = Hapi.server({ compression: { enableBrotli: true, minBytes: 1 } }); + const server = Hapi.server({ compression: { encodings: { br: true }, minBytes: 1, priority: ['br'] } }); server.route({ method: 'GET', path: '/', handler: () => data }); await server.start(); @@ -1125,7 +1127,7 @@ describe('transmission', () => { it('returns a zstd response on a post request when accept-encoding: gzip, deflate, br, zstd is requested', { skip: !Common.hasZstd }, async () => { const data = '{"test":"true"}'; - const server = Hapi.server({ compression: { enableZstd: true, minBytes: 1 } }); + const server = Hapi.server({ compression: { encodings: { zstd: true }, minBytes: 1, priority: ['zstd'] } }); server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); await server.start(); @@ -1139,7 +1141,7 @@ describe('transmission', () => { it('returns a zstd response on a get request when accept-encoding: gzip, deflate, br, zstd is requested', { skip: !Common.hasZstd }, async () => { const data = '{"test":"true"}'; - const server = Hapi.server({ compression: { enableZstd: true, minBytes: 1 } }); + const server = Hapi.server({ compression: { encodings: { zstd: true }, minBytes: 1, priority: ['zstd'] } }); server.route({ method: 'GET', path: '/', handler: () => data }); await server.start(); From 2163c18a09af6db5fc32f8784dfe2c38e5fa953e Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Sun, 14 Sep 2025 15:40:34 +0200 Subject: [PATCH 20/25] =?UTF-8?q?fix:=20adjust=20=F0=9F=AA=9B=20default=20?= =?UTF-8?q?params?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/compression.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/compression.js b/lib/compression.js index 1ac2ae4f2..43f1b6b21 100755 --- a/lib/compression.js +++ b/lib/compression.js @@ -23,7 +23,8 @@ const internals = { (options) => Zlib.createZstdDecompress(options), { params: { - [Zlib.constants.ZSTD_c_compressionLevel]: 6 + [Zlib.constants.ZSTD_c_compressionLevel]: 6, + [Zlib.constants.ZSTD_d_windowLogMax]: 0 } } ]], From df96b0f1ea96e26dbd0ac915c619e99d35ad72e9 Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Sun, 14 Sep 2025 18:35:13 +0200 Subject: [PATCH 21/25] =?UTF-8?q?feat:=20betterment=20=F0=9F=92=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/compression.js | 16 +++++++++------- lib/core.js | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/lib/compression.js b/lib/compression.js index 43f1b6b21..cf5f91ad4 100755 --- a/lib/compression.js +++ b/lib/compression.js @@ -60,19 +60,21 @@ exports = module.exports = internals.Compression = class { }; #common = null; + #options = null; - constructor({ compression }) { + constructor(options) { - if (!compression) { + this.#options = options; + if (!this.#options) { this._updateCommons(); } for (const [alg, [encoder, decoder, defaults = {}]] of internals.provision.entries()) { - let conditions = compression?.encodings?.[alg]; + let conditions = this.#options?.encodings?.[alg]; if (conditions) { conditions = Hoek.applyToDefaults(defaults, conditions); - this.addEncoder(alg, (options = {}) => encoder(Hoek.applyToDefaults(conditions, options))); - this.addDecoder(alg, (options = {}) => decoder(Hoek.applyToDefaults(conditions, options))); + this.addEncoder(alg, (opts = {}) => encoder(Hoek.applyToDefaults(conditions, opts))); + this.addDecoder(alg, (opts = {}) => decoder(Hoek.applyToDefaults(conditions, opts))); } } } @@ -133,8 +135,8 @@ exports = module.exports = internals.Compression = class { } const request = response.request; - if (!request._core.settings.compression || - length !== null && length < request._core.settings.compression.minBytes) { + if (!this.#options || + length !== null && length < this.#options.minBytes) { return null; } diff --git a/lib/core.js b/lib/core.js index fc663faa7..42691c00f 100755 --- a/lib/core.js +++ b/lib/core.js @@ -119,7 +119,7 @@ exports = module.exports = internals.Core = class { this.settings = settings; this.type = type; - this.compression = new Compression(this.settings); + this.compression = new Compression(this.settings.compression); this.heavy = new Heavy(this.settings.load); this.mime = new Mimos(this.settings.mime); this.router = new Call.Router(this.settings.router); From d5e7fa85808ab6c71e29d8be20a810bbed893664 Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Sun, 14 Sep 2025 18:38:08 +0200 Subject: [PATCH 22/25] =?UTF-8?q?feat:=20betterment=20=F0=9F=92=88=20take?= =?UTF-8?q?=202=20=F0=9F=8E=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/compression.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/compression.js b/lib/compression.js index cf5f91ad4..7695b70b9 100755 --- a/lib/compression.js +++ b/lib/compression.js @@ -134,13 +134,13 @@ exports = module.exports = internals.Compression = class { return null; } - const request = response.request; if (!this.#options || length !== null && length < this.#options.minBytes) { return null; } + const request = response.request; const mime = request._core.mime.type(response.headers['content-type'] || 'application/octet-stream'); if (!mime.compressible) { return null; From af8b50b3fa518f7edbdeeb206073da4580ba57a6 Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Mon, 15 Sep 2025 12:30:39 +0200 Subject: [PATCH 23/25] =?UTF-8?q?refactor:=20refinement=20=E2=9A=97?= =?UTF-8?q?=EF=B8=8F=20take=202=20=F0=9F=8E=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 9 ++++---- lib/compression.js | 40 +++++++++++++++++------------------ lib/config.js | 20 ++++-------------- lib/types/server/options.d.ts | 9 ++++---- lib/types/server/server.d.ts | 4 ++-- 5 files changed, 34 insertions(+), 48 deletions(-) diff --git a/API.md b/API.md index 7c9dfa853..1bd8d3c2a 100755 --- a/API.md +++ b/API.md @@ -113,7 +113,8 @@ Available values for each kv pair: - `true` - enables the encoding with default options. - `false` - disables the encoding. -- `{...options}` - enables the encoding using custom options specific to each particular algorithm. + +Note that default encoder and decoder options can be configured at the server default [route configuration](#server.options.routes) Zstd compression is experimental (see [node Zstd documentation](https://nodejs.org/api/zlib.html#zlibcreatezstdcompressoptions)). @@ -1314,8 +1315,7 @@ are called, where: ### `server.decoder(encoding, decoder)` -Registers a custom content decoding compressor to extend the built-in support for `'gzip'` and -'`deflate`' where: +Registers a custom content decoding compressor to extend the built-in support where: - `encoding` - the decoder name string. @@ -1511,8 +1511,7 @@ The `dependencies` configuration accepts one of: ### `server.encoder(encoding, encoder)` -Registers a custom content encoding compressor to extend the built-in support for `'gzip'` and -'`deflate`' where: +Registers a custom content encoding compressor to extend the built-in support where: - `encoding` - the encoder name string. diff --git a/lib/compression.js b/lib/compression.js index 7695b70b9..dec5c57a6 100755 --- a/lib/compression.js +++ b/lib/compression.js @@ -6,6 +6,18 @@ const Accept = require('@hapi/accept'); const Bounce = require('@hapi/bounce'); const Hoek = require('@hapi/hoek'); +const defaultBrotliOptions = { + params: { + [Zlib.constants.BROTLI_PARAM_QUALITY]: 4 + } +}; + +const defaultZstdOptions = { + params: { + [Zlib.constants.ZSTD_c_compressionLevel]: 6 + } +}; + const internals = { common: [ 'gzip, deflate, br, zstd', @@ -19,23 +31,12 @@ const internals = { ], provision: new Map([ ['zstd', [ - (options) => Zlib.createZstdCompress(options), - (options) => Zlib.createZstdDecompress(options), - { - params: { - [Zlib.constants.ZSTD_c_compressionLevel]: 6, - [Zlib.constants.ZSTD_d_windowLogMax]: 0 - } - } + (options = {}) => Zlib.createZstdCompress(Hoek.applyToDefaults(defaultZstdOptions, options)), + (options) => Zlib.createZstdDecompress(options) ]], ['br', [ - (options) => Zlib.createBrotliCompress(options), - (options) => Zlib.createBrotliDecompress(options), - { - params: { - [Zlib.constants.BROTLI_PARAM_QUALITY]: 4 - } - } + (options = {}) => Zlib.createBrotliCompress(Hoek.applyToDefaults(defaultBrotliOptions, options)), + (options) => Zlib.createBrotliDecompress(options) ]], ['deflate', [ (options) => Zlib.createDeflate(options), @@ -69,12 +70,11 @@ exports = module.exports = internals.Compression = class { this._updateCommons(); } - for (const [alg, [encoder, decoder, defaults = {}]] of internals.provision.entries()) { - let conditions = this.#options?.encodings?.[alg]; + for (const [encoding, [encoder, decoder]] of internals.provision.entries()) { + const conditions = this.#options?.encodings?.[encoding]; if (conditions) { - conditions = Hoek.applyToDefaults(defaults, conditions); - this.addEncoder(alg, (opts = {}) => encoder(Hoek.applyToDefaults(conditions, opts))); - this.addDecoder(alg, (opts = {}) => decoder(Hoek.applyToDefaults(conditions, opts))); + this.addEncoder(encoding, encoder); + this.addDecoder(encoding, decoder); } } } diff --git a/lib/config.js b/lib/config.js index 0b526e7c4..f0716a768 100755 --- a/lib/config.js +++ b/lib/config.js @@ -242,22 +242,10 @@ internals.server = Validate.object({ cache: Validate.allow(null), // Validated elsewhere compression: Validate.object({ encodings: Validate.object({ - gzip: Validate.alternatives([ - Validate.boolean(), - Validate.object() - ]).default(true), - deflate: Validate.alternatives([ - Validate.boolean(), - Validate.object() - ]).default(true), - br: Validate.alternatives([ - Validate.boolean(), - Validate.object() - ]).default(false), - zstd: Validate.alternatives([ - Validate.boolean(), - Validate.object() - ]).default(false) + gzip: Validate.boolean().default(true), + deflate: Validate.boolean().default(true), + br: Validate.boolean().default(false), + zstd: Validate.boolean().default(false) }).default(), minBytes: Validate.number().min(1).integer().default(1024), priority: Validate.array().items(Validate.string().valid('gzip', 'deflate', 'br', 'zstd')).default(null) diff --git a/lib/types/server/options.d.ts b/lib/types/server/options.d.ts index 45d0fdaf8..bd5f72dea 100644 --- a/lib/types/server/options.d.ts +++ b/lib/types/server/options.d.ts @@ -1,6 +1,5 @@ import * as http from 'http'; import * as https from 'https'; -import { BrotliOptions, ZstdOptions, ZlibOptions } from 'zlib'; import { MimosOptions } from '@hapi/mimos'; @@ -11,10 +10,10 @@ import { SameSitePolicy, ServerStateCookieOptions } from './state'; export interface ServerOptionsCompression { encodings?: { - gzip?: boolean | ZlibOptions; - deflate?: boolean | ZlibOptions; - br?: boolean | BrotliOptions; - zstd?: boolean | ZstdOptions; + gzip?: boolean; + deflate?: boolean; + br?: boolean; + zstd?: boolean; }; minBytes: number; priority?: string[]; diff --git a/lib/types/server/server.d.ts b/lib/types/server/server.d.ts index 4d64710e1..e32b8faf0 100644 --- a/lib/types/server/server.d.ts +++ b/lib/types/server/server.d.ts @@ -332,7 +332,7 @@ export class Server { cache: ServerCache; /** - * Registers a custom content decoding compressor to extend the built-in support for 'gzip' and 'deflate' where: + * Registers a custom content decoding compressor to extend the built-in support where: * @param encoding - the decoder name string. * @param decoder - a function using the signature function(options) where options are the encoding specific options configured in the route payload.compression configuration option, and the * return value is an object compatible with the output of node's zlib.createGunzip(). @@ -392,7 +392,7 @@ export class Server { dependency(dependencies: Dependencies, after?: ((server: Server) => Promise) | undefined): void; /** - * Registers a custom content encoding compressor to extend the built-in support for 'gzip' and 'deflate' where: + * Registers a custom content encoding compressor to extend the built-in support where: * @param encoding - the encoder name string. * @param encoder - a function using the signature function(options) where options are the encoding specific options configured in the route compression option, and the return value is an object * compatible with the output of node's zlib.createGzip(). From 498ba9f1b9b47a0de7845ad0a3064812b2521424 Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Mon, 15 Sep 2025 14:00:44 +0200 Subject: [PATCH 24/25] =?UTF-8?q?feat:=20add=20opt=20to=20disable=20decomp?= =?UTF-8?q?ression=20=E2=9B=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 9 ++++++++- lib/compression.js | 6 ++++-- lib/config.js | 1 + lib/types/server/options.d.ts | 1 + test/payload.js | 24 ++++++++++++++++++++++++ 5 files changed, 38 insertions(+), 3 deletions(-) diff --git a/API.md b/API.md index 1bd8d3c2a..fdcc44881 100755 --- a/API.md +++ b/API.md @@ -98,11 +98,18 @@ assigned one or more (array): #### `server.options.compression` -Default value: `{ encodings: { gzip: true, deflate: true, br: false, zstd: false }, minBytes: 1024 }`. +Default value: `{ decompress: true, encodings: { gzip: true, deflate: true, br: false, zstd: false }, minBytes: 1024 }`. Defines server handling of content encoding requests. If `false`, response content encoding is disabled, and no compression is performed by the server. +#### `server.options.compression.decompress` + +Default value: `true`. + +Controls whether the server automatically decompresses incoming content encoding requests. +If `false`, no decompression automatically performed by the server. + #### `server.options.compression.encodings` Default value: `{ gzip: true, deflate: true, br: false, zstd: false }`. diff --git a/lib/compression.js b/lib/compression.js index dec5c57a6..2373851bb 100755 --- a/lib/compression.js +++ b/lib/compression.js @@ -67,14 +67,16 @@ exports = module.exports = internals.Compression = class { this.#options = options; if (!this.#options) { - this._updateCommons(); + return this._updateCommons(); } for (const [encoding, [encoder, decoder]] of internals.provision.entries()) { const conditions = this.#options?.encodings?.[encoding]; if (conditions) { this.addEncoder(encoding, encoder); - this.addDecoder(encoding, decoder); + if (this.#options.decompress !== false) { + this.addDecoder(encoding, decoder); + } } } } diff --git a/lib/config.js b/lib/config.js index f0716a768..1153c135a 100755 --- a/lib/config.js +++ b/lib/config.js @@ -241,6 +241,7 @@ internals.server = Validate.object({ autoListen: Validate.boolean(), cache: Validate.allow(null), // Validated elsewhere compression: Validate.object({ + decompress: Validate.boolean().default(true), encodings: Validate.object({ gzip: Validate.boolean().default(true), deflate: Validate.boolean().default(true), diff --git a/lib/types/server/options.d.ts b/lib/types/server/options.d.ts index bd5f72dea..8e294341b 100644 --- a/lib/types/server/options.d.ts +++ b/lib/types/server/options.d.ts @@ -9,6 +9,7 @@ import { CacheProvider, ServerOptionsCache } from './cache'; import { SameSitePolicy, ServerStateCookieOptions } from './state'; export interface ServerOptionsCompression { + decompress?: boolean; encodings?: { gzip?: boolean; deflate?: boolean; diff --git a/test/payload.js b/test/payload.js index ff57cdc34..4ab1e50ea 100755 --- a/test/payload.js +++ b/test/payload.js @@ -479,6 +479,30 @@ describe('Payload', () => { expect(peeked).to.be.true(); }); + it('rejects compressed payload when decompression is disabled', async () => { + + const message = { 'msg': 'This message is going to be gzipped.' }; + const server = Hapi.server({ compression: { decompress: false } }); + server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); + + const compressed = await new Promise((resolve) => Zlib.gzip(JSON.stringify(message), (ignore, result) => resolve(result))); + + const request = { + method: 'POST', + url: '/', + headers: { + 'content-type': 'application/json', + 'content-encoding': 'gzip', + 'content-length': compressed.length + }, + payload: compressed + }; + + const res = await server.inject(request); + expect(res.result).to.exist(); + expect(res.statusCode).to.be.range(400, 499); + }); + it('handles gzipped payload', async () => { const message = { 'msg': 'This message is going to be gzipped.' }; From f5607aa5b8274c459245587fdd60f34cd2e5af77 Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Mon, 15 Sep 2025 14:26:55 +0200 Subject: [PATCH 25/25] =?UTF-8?q?feat:=20improve=20tests=20=F0=9F=A7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/compression.js | 7 +++++++ test/payload.js | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/compression.js b/lib/compression.js index 2373851bb..4a8889816 100755 --- a/lib/compression.js +++ b/lib/compression.js @@ -5,6 +5,7 @@ const Zlib = require('zlib'); const Accept = require('@hapi/accept'); const Bounce = require('@hapi/bounce'); const Hoek = require('@hapi/hoek'); +const Boom = require('@hapi/boom'); const defaultBrotliOptions = { params: { @@ -77,6 +78,12 @@ exports = module.exports = internals.Compression = class { if (this.#options.decompress !== false) { this.addDecoder(encoding, decoder); } + else { + this.addDecoder(encoding, () => { + + throw Boom.unsupportedMediaType(); + }); + } } } } diff --git a/test/payload.js b/test/payload.js index 4ab1e50ea..b1e53864f 100755 --- a/test/payload.js +++ b/test/payload.js @@ -500,7 +500,7 @@ describe('Payload', () => { const res = await server.inject(request); expect(res.result).to.exist(); - expect(res.statusCode).to.be.range(400, 499); + expect(res.statusCode).to.equal(415); }); it('handles gzipped payload', async () => {