From 2daee295477607f5a727043f5d37348101be851e Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Thu, 12 Feb 2026 20:05:29 +0100 Subject: [PATCH] feat(ui-server): add transparent response compression - HTTP: gzip compression for responses > 1KB (Accept-Encoding negotiated) - WebSocket: perMessageDeflate with memory-optimized settings - Add DEFAULT_COMPRESSION_THRESHOLD constant (1KB) - Add tests for compression and security constants --- .../ui-server/UIHttpServer.ts | 32 ++++++- .../ui-server/UIServerSecurity.ts | 1 + .../ui-server/UIWebSocketServer.ts | 17 +++- .../ui-server/UIHttpServer.test.ts | 90 +++++++++++++++++++ .../ui-server/UIServerSecurity.test.ts | 31 +++++++ .../ui-server/UIServerTestUtils.ts | 16 +++- 6 files changed, 182 insertions(+), 5 deletions(-) diff --git a/src/charging-station/ui-server/UIHttpServer.ts b/src/charging-station/ui-server/UIHttpServer.ts index ce655c2d..7b50c195 100644 --- a/src/charging-station/ui-server/UIHttpServer.ts +++ b/src/charging-station/ui-server/UIHttpServer.ts @@ -1,6 +1,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http' import { StatusCodes } from 'http-status-codes' +import { createGzip } from 'node:zlib' import { BaseError } from '../../exception/index.js' import { @@ -14,6 +15,7 @@ import { type RequestPayload, ResponseStatus, type UIServerConfiguration, + type UUIDv4, } from '../../types/index.js' import { generateUUID, @@ -26,6 +28,7 @@ import { AbstractUIServer } from './AbstractUIServer.js' import { createBodySizeLimiter, createRateLimiter, + DEFAULT_COMPRESSION_THRESHOLD, DEFAULT_MAX_PAYLOAD_SIZE, DEFAULT_RATE_LIMIT, DEFAULT_RATE_WINDOW, @@ -44,8 +47,11 @@ enum HttpMethods { } export class UIHttpServer extends AbstractUIServer { + private readonly acceptsGzip: Map + public constructor (protected override readonly uiServerConfiguration: UIServerConfiguration) { super(uiServerConfiguration) + this.acceptsGzip = new Map() } public logPrefix = (modName?: string, methodName?: string, prefixSuffix?: string): string => { @@ -70,11 +76,27 @@ export class UIHttpServer extends AbstractUIServer { try { if (this.hasResponseHandler(uuid)) { const res = this.responseHandlers.get(uuid) as ServerResponse - res - .writeHead(this.responseStatusToStatusCode(payload.status), { + const body = JSONStringify(payload, undefined, MapStringifyFormat.object) + const shouldCompress = + this.acceptsGzip.get(uuid) === true && + Buffer.byteLength(body) >= DEFAULT_COMPRESSION_THRESHOLD + + if (shouldCompress) { + res.writeHead(this.responseStatusToStatusCode(payload.status), { + 'Content-Encoding': 'gzip', 'Content-Type': 'application/json', + Vary: 'Accept-Encoding', }) - .end(JSONStringify(payload, undefined, MapStringifyFormat.object)) + const gzip = createGzip() + gzip.pipe(res) + gzip.end(body) + } else { + res + .writeHead(this.responseStatusToStatusCode(payload.status), { + 'Content-Type': 'application/json', + }) + .end(body) + } } else { logger.error( `${this.logPrefix(moduleName, 'sendResponse')} Response for unknown request id: ${uuid}` @@ -87,6 +109,7 @@ export class UIHttpServer extends AbstractUIServer { ) } finally { this.responseHandlers.delete(uuid) + this.acceptsGzip.delete(uuid) } } @@ -125,8 +148,11 @@ export class UIHttpServer extends AbstractUIServer { const uuid = generateUUID() this.responseHandlers.set(uuid, res) + const acceptEncoding = req.headers['accept-encoding'] ?? '' + this.acceptsGzip.set(uuid, /\bgzip\b/.test(acceptEncoding)) res.on('close', () => { this.responseHandlers.delete(uuid) + this.acceptsGzip.delete(uuid) }) try { // Expected request URL pathname: /ui/:version/:procedureName diff --git a/src/charging-station/ui-server/UIServerSecurity.ts b/src/charging-station/ui-server/UIServerSecurity.ts index 844537b0..306a0644 100644 --- a/src/charging-station/ui-server/UIServerSecurity.ts +++ b/src/charging-station/ui-server/UIServerSecurity.ts @@ -10,6 +10,7 @@ export const DEFAULT_RATE_LIMIT = 100 export const DEFAULT_RATE_WINDOW = 60000 export const DEFAULT_MAX_STATIONS = 100 export const DEFAULT_MAX_TRACKED_IPS = 10000 +export const DEFAULT_COMPRESSION_THRESHOLD = 1024 export const isValidCredential = (provided: string, expected: string): boolean => { try { diff --git a/src/charging-station/ui-server/UIWebSocketServer.ts b/src/charging-station/ui-server/UIWebSocketServer.ts index 16675caa..d42cf798 100644 --- a/src/charging-station/ui-server/UIWebSocketServer.ts +++ b/src/charging-station/ui-server/UIWebSocketServer.ts @@ -20,7 +20,7 @@ import { validateUUID, } from '../../utils/index.js' import { AbstractUIServer } from './AbstractUIServer.js' -import { DEFAULT_MAX_PAYLOAD_SIZE } from './UIServerSecurity.js' +import { DEFAULT_COMPRESSION_THRESHOLD, DEFAULT_MAX_PAYLOAD_SIZE } from './UIServerSecurity.js' import { getProtocolAndVersion, handleProtocols, @@ -38,6 +38,21 @@ export class UIWebSocketServer extends AbstractUIServer { handleProtocols, maxPayload: DEFAULT_MAX_PAYLOAD_SIZE, noServer: true, + perMessageDeflate: { + clientNoContextTakeover: true, + concurrencyLimit: 10, + serverMaxWindowBits: 12, + serverNoContextTakeover: true, + threshold: DEFAULT_COMPRESSION_THRESHOLD, + zlibDeflateOptions: { + chunkSize: 16 * 1024, + level: 6, + memLevel: 7, + }, + zlibInflateOptions: { + chunkSize: 16 * 1024, + }, + }, }) } diff --git a/tests/charging-station/ui-server/UIHttpServer.test.ts b/tests/charging-station/ui-server/UIHttpServer.test.ts index bf0f760a..33803562 100644 --- a/tests/charging-station/ui-server/UIHttpServer.test.ts +++ b/tests/charging-station/ui-server/UIHttpServer.test.ts @@ -2,10 +2,12 @@ import { expect } from '@std/expect' import { describe, it } from 'node:test' +import { gunzipSync } from 'node:zlib' import type { UUIDv4 } from '../../../src/types/index.js' import { UIHttpServer } from '../../../src/charging-station/ui-server/UIHttpServer.js' +import { DEFAULT_COMPRESSION_THRESHOLD } from '../../../src/charging-station/ui-server/UIServerSecurity.js' import { ApplicationProtocol, ResponseStatus } from '../../../src/types/index.js' import { TEST_UUID } from './UIServerTestConstants.js' import { createMockUIServerConfiguration, MockServerResponse } from './UIServerTestUtils.js' @@ -18,6 +20,10 @@ class TestableUIHttpServer extends UIHttpServer { public getResponseHandlersSize (): number { return this.responseHandlers.size } + + public setAcceptsGzip (uuid: UUIDv4, value: boolean): void { + ;(this as unknown as { acceptsGzip: Map }).acceptsGzip.set(uuid, value) + } } await describe('UIHttpServer test suite', async () => { @@ -172,4 +178,88 @@ await describe('UIHttpServer test suite', async () => { const server = new UIHttpServer(config) expect(server).toBeDefined() }) + + await describe('Gzip compression', async () => { + await it('Verify sendResponse() does not compress when acceptsGzip is false', () => { + const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP }) + const server = new TestableUIHttpServer(config) + const res = new MockServerResponse() + const largeData = 'x'.repeat(DEFAULT_COMPRESSION_THRESHOLD + 100) + const payload = { + data: largeData, + status: ResponseStatus.SUCCESS, + } + + server.addResponseHandler(TEST_UUID, res) + server.setAcceptsGzip(TEST_UUID, false) + server.sendResponse([TEST_UUID, payload]) + + expect(res.headers['Content-Encoding']).toBeUndefined() + expect(res.headers['Content-Type']).toBe('application/json') + }) + + await it('Verify sendResponse() does not compress small responses', () => { + const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP }) + const server = new TestableUIHttpServer(config) + const res = new MockServerResponse() + const payload = { + status: ResponseStatus.SUCCESS, + } + + server.addResponseHandler(TEST_UUID, res) + server.setAcceptsGzip(TEST_UUID, true) + server.sendResponse([TEST_UUID, payload]) + + expect(res.headers['Content-Encoding']).toBeUndefined() + expect(res.headers['Content-Type']).toBe('application/json') + }) + + await it('Verify sendResponse() compresses large responses when client accepts gzip', async () => { + const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP }) + const server = new TestableUIHttpServer(config) + const res = new MockServerResponse() + const largeData = 'x'.repeat(DEFAULT_COMPRESSION_THRESHOLD + 100) + const payload = { + data: largeData, + status: ResponseStatus.SUCCESS, + } + + server.addResponseHandler(TEST_UUID, res) + server.setAcceptsGzip(TEST_UUID, true) + server.sendResponse([TEST_UUID, payload]) + + await new Promise(resolve => { + setTimeout(resolve, 50) + }) + + expect(res.headers['Content-Encoding']).toBe('gzip') + expect(res.headers['Content-Type']).toBe('application/json') + expect(res.headers.Vary).toBe('Accept-Encoding') + }) + + await it('Verify compressed response can be decompressed to original payload', async () => { + const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP }) + const server = new TestableUIHttpServer(config) + const res = new MockServerResponse() + const largeData = 'x'.repeat(DEFAULT_COMPRESSION_THRESHOLD + 100) + const payload = { + data: largeData, + status: ResponseStatus.SUCCESS, + } + + server.addResponseHandler(TEST_UUID, res) + server.setAcceptsGzip(TEST_UUID, true) + server.sendResponse([TEST_UUID, payload]) + + await new Promise(resolve => { + setTimeout(resolve, 50) + }) + + expect(res.body).toBeDefined() + const decompressed = gunzipSync(Buffer.from(res.body ?? '', 'binary')).toString('utf8') + const parsedBody = JSON.parse(decompressed) as Record + expect(parsedBody.status).toBe('success') + expect(parsedBody.data).toBe(largeData) + }) + }) }) diff --git a/tests/charging-station/ui-server/UIServerSecurity.test.ts b/tests/charging-station/ui-server/UIServerSecurity.test.ts index 7a4ad9c3..5c95d292 100644 --- a/tests/charging-station/ui-server/UIServerSecurity.test.ts +++ b/tests/charging-station/ui-server/UIServerSecurity.test.ts @@ -6,7 +6,12 @@ import { describe, it } from 'node:test' import { createBodySizeLimiter, createRateLimiter, + DEFAULT_COMPRESSION_THRESHOLD, + DEFAULT_MAX_PAYLOAD_SIZE, DEFAULT_MAX_STATIONS, + DEFAULT_MAX_TRACKED_IPS, + DEFAULT_RATE_LIMIT, + DEFAULT_RATE_WINDOW, isValidCredential, isValidNumberOfStations, } from '../../../src/charging-station/ui-server/UIServerSecurity.js' @@ -136,4 +141,30 @@ await describe('UIServerSecurity test suite', async () => { expect(result).toBe(false) }) }) + + await describe('Security constants', async () => { + await it('should have correct DEFAULT_MAX_PAYLOAD_SIZE value', () => { + expect(DEFAULT_MAX_PAYLOAD_SIZE).toBe(1048576) // 1MB + }) + + await it('should have correct DEFAULT_RATE_LIMIT value', () => { + expect(DEFAULT_RATE_LIMIT).toBe(100) + }) + + await it('should have correct DEFAULT_RATE_WINDOW value', () => { + expect(DEFAULT_RATE_WINDOW).toBe(60000) // 60 seconds + }) + + await it('should have correct DEFAULT_MAX_STATIONS value', () => { + expect(DEFAULT_MAX_STATIONS).toBe(100) + }) + + await it('should have correct DEFAULT_MAX_TRACKED_IPS value', () => { + expect(DEFAULT_MAX_TRACKED_IPS).toBe(10000) + }) + + await it('should have correct DEFAULT_COMPRESSION_THRESHOLD value', () => { + expect(DEFAULT_COMPRESSION_THRESHOLD).toBe(1024) // 1KB + }) + }) }) diff --git a/tests/charging-station/ui-server/UIServerTestUtils.ts b/tests/charging-station/ui-server/UIServerTestUtils.ts index 6b38d182..0c1b8f72 100644 --- a/tests/charging-station/ui-server/UIServerTestUtils.ts +++ b/tests/charging-station/ui-server/UIServerTestUtils.ts @@ -55,9 +55,14 @@ export class MockServerResponse extends EventEmitter { public ended = false public headers: Record = {} public statusCode?: number + private chunks: Buffer[] = [] public end (data?: string): this { - this.body = data + if (data != null) { + this.body = data + } else if (this.chunks.length > 0) { + this.body = Buffer.concat(this.chunks).toString() + } this.ended = true this.emit('finish') return this @@ -70,6 +75,15 @@ export class MockServerResponse extends EventEmitter { return JSON.parse(this.body) as ProtocolResponse } + public write (chunk: Buffer | string): boolean { + if (typeof chunk === 'string') { + this.chunks.push(Buffer.from(chunk)) + } else { + this.chunks.push(chunk) + } + return true + } + public writeHead (statusCode: number, headers?: Record): this { this.statusCode = statusCode if (headers != null) { -- 2.43.0