From 346ebea2d91c8709a530af1e694b373d6ec264a1 Mon Sep 17 00:00:00 2001 From: Ravi Kumar L Date: Wed, 10 Jun 2026 08:49:50 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20refactor:=20brotli=20asset?= =?UTF-8?q?=20serving=20behind=20a=20feature=20toggle=20(#13641)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 + .../utils/__tests__/staticCache.spec.js | 59 ++++++++++++++++++- api/server/utils/staticCache.js | 8 ++- 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 5e8d023ded..24268d3963 100644 --- a/.env.example +++ b/.env.example @@ -833,6 +833,9 @@ ALLOW_SHARED_LINKS_PUBLIC=false # If you have another service in front of your LibreChat doing compression, disable express based compression here # DISABLE_COMPRESSION=true +# Serve precompressed Brotli versions of static app assets when available. +# ENABLE_STATIC_ASSET_BROTLI=true + # If you have gzipped version of uploaded image images in the same folder, this will enable gzip scan and serving of these images # Note: The images folder will be scanned on startup and a ma kept in memory. Be careful for large number of images. # ENABLE_IMAGE_OUTPUT_GZIP_SCAN=true diff --git a/api/server/utils/__tests__/staticCache.spec.js b/api/server/utils/__tests__/staticCache.spec.js index 5d285017bd..2b22223393 100644 --- a/api/server/utils/__tests__/staticCache.spec.js +++ b/api/server/utils/__tests__/staticCache.spec.js @@ -5,6 +5,12 @@ const request = require('supertest'); const zlib = require('zlib'); const staticCache = require('../staticCache'); +const binaryParser = (res, callback) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => callback(null, Buffer.concat(chunks))); +}; + describe('staticCache', () => { let app; let testDir; @@ -36,10 +42,15 @@ describe('staticCache', () => { fs.writeFileSync(manifestFile, jsonContent); fs.writeFileSync(swFile, swContent); - // Create gzipped versions of some files + // Create precompressed versions of some files fs.writeFileSync(testFile + '.gz', zlib.gzipSync(jsContent)); + fs.writeFileSync(testFile + '.br', zlib.brotliCompressSync(jsContent)); fs.writeFileSync(path.join(testDir, 'test.css'), 'body { color: red; }'); fs.writeFileSync(path.join(testDir, 'test.css.gz'), zlib.gzipSync('body { color: red; }')); + fs.writeFileSync( + path.join(testDir, 'test.css.br'), + zlib.brotliCompressSync('body { color: red; }'), + ); // Create a file that only exists in gzipped form fs.writeFileSync( @@ -67,6 +78,7 @@ describe('staticCache', () => { delete process.env.NODE_ENV; delete process.env.STATIC_CACHE_S_MAX_AGE; delete process.env.STATIC_CACHE_MAX_AGE; + delete process.env.ENABLE_STATIC_ASSET_BROTLI; }); describe('cache headers in production', () => { beforeEach(() => { @@ -193,6 +205,51 @@ describe('staticCache', () => { process.env.NODE_ENV = 'production'; }); + it('should serve Brotli files when client accepts Brotli encoding', async () => { + process.env.ENABLE_STATIC_ASSET_BROTLI = 'true'; + app.use(staticCache(testDir, { skipGzipScan: false })); + + const response = await request(app) + .get('/test.js') + .set('Accept-Encoding', 'br, gzip, deflate') + .buffer(true) + .parse(binaryParser) + .expect(200); + + expect(response.headers['content-encoding']).toBe('br'); + expect(response.headers['content-type']).toMatch(/javascript/); + expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400'); + expect(zlib.brotliDecompressSync(response.body).toString()).toBe('console.log("test");'); + }); + + it('should prefer Brotli over gzip when both encodings are accepted', async () => { + process.env.ENABLE_STATIC_ASSET_BROTLI = 'true'; + app.use(staticCache(testDir, { skipGzipScan: false })); + + const response = await request(app) + .get('/test.css') + .set('Accept-Encoding', 'gzip, br') + .buffer(true) + .parse(binaryParser) + .expect(200); + + expect(response.headers['content-encoding']).toBe('br'); + expect(response.headers['content-type']).toMatch(/css/); + expect(zlib.brotliDecompressSync(response.body).toString()).toBe('body { color: red; }'); + }); + + it('should keep serving gzip when Brotli is not enabled', async () => { + app.use(staticCache(testDir, { skipGzipScan: false })); + + const response = await request(app) + .get('/test.js') + .set('Accept-Encoding', 'br, gzip, deflate') + .expect(200); + + expect(response.headers['content-encoding']).toBe('gzip'); + expect(response.text).toBe('console.log("test");'); + }); + it('should serve gzipped files when client accepts gzip encoding', async () => { app.use(staticCache(testDir, { skipGzipScan: false })); diff --git a/api/server/utils/staticCache.js b/api/server/utils/staticCache.js index ecaea856d0..3f9d347bb0 100644 --- a/api/server/utils/staticCache.js +++ b/api/server/utils/staticCache.js @@ -6,9 +6,10 @@ const oneDayInSeconds = 24 * 60 * 60; const sMaxAge = process.env.STATIC_CACHE_S_MAX_AGE || oneDayInSeconds; const maxAge = process.env.STATIC_CACHE_MAX_AGE || oneDayInSeconds * 2; +const isEnabled = (value) => value === true || String(value).toLowerCase() === 'true'; /** - * Creates an Express static middleware with optional gzip compression and configurable caching + * Creates an Express static middleware with optional precompressed asset serving and configurable caching * * @param {string} staticPath - The file system path to serve static files from * @param {Object} [options={}] - Configuration options @@ -18,6 +19,7 @@ const maxAge = process.env.STATIC_CACHE_MAX_AGE || oneDayInSeconds * 2; */ function staticCache(staticPath, options = {}) { const { noCache = false, skipGzipScan = false } = options; + const enableBrotli = isEnabled(process.env.ENABLE_STATIC_ASSET_BROTLI); const setHeaders = (res, filePath) => { if (process.env.NODE_ENV?.toLowerCase() !== 'production') { @@ -51,8 +53,8 @@ function staticCache(staticPath, options = {}) { }); } else { return expressStaticGzip(staticPath, { - enableBrotli: false, - orderPreference: ['gz'], + enableBrotli, + orderPreference: enableBrotli ? ['br', 'gz'] : ['gz'], setHeaders, index: false, });