From ae8345976fd84795bb89e9f4a5d794c0317d5a40 Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Sun, 19 May 2024 11:33:55 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20add=20brotli=20to=20supported=20com?= =?UTF-8?q?pression=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 40d8ffe88..a4b7bc2db 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 43329536f..41459331c 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 68408152a..78cff66a3 100644 --- a/lib/types/server/options.d.ts +++ b/lib/types/server/options.d.ts @@ -10,6 +10,7 @@ import { SameSitePolicy } from './state'; export interface ServerOptionsCompression { minBytes: number; + priority: string[]; } /** diff --git a/test/payload.js b/test/payload.js index 0cfadedd6..9a758b99a 100755 --- a/test/payload.js +++ b/test/payload.js @@ -407,6 +407,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 523bd24a7..78b8c9b30 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 ab0b6d4c22ee76d99f5fa4beee689213046700e4 Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Sun, 19 May 2024 14:14:29 +0200 Subject: [PATCH 2/3] =?UTF-8?q?chore:=20address=20comments=20=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 a4b7bc2db..40d8ffe88 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 41459331c..43329536f 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 78cff66a3..68408152a 100644 --- a/lib/types/server/options.d.ts +++ b/lib/types/server/options.d.ts @@ -10,7 +10,6 @@ import { SameSitePolicy } from './state'; export interface ServerOptionsCompression { minBytes: number; - priority: string[]; } /** diff --git a/test/transmit.js b/test/transmit.js index 78b8c9b30..433f6f013 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 5466504838f30c1da8ea564ac58eecac3df546d2 Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Sun, 26 May 2024 11:34:52 +0200 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20address=20comments=20=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; }