]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(ui-server): add transparent response compression
authorJérôme Benoit <jerome.benoit@sap.com>
Thu, 12 Feb 2026 19:05:29 +0000 (20:05 +0100)
committerJérôme Benoit <jerome.benoit@sap.com>
Thu, 12 Feb 2026 19:05:29 +0000 (20:05 +0100)
- 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

src/charging-station/ui-server/UIHttpServer.ts
src/charging-station/ui-server/UIServerSecurity.ts
src/charging-station/ui-server/UIWebSocketServer.ts
tests/charging-station/ui-server/UIHttpServer.test.ts
tests/charging-station/ui-server/UIServerSecurity.test.ts
tests/charging-station/ui-server/UIServerTestUtils.ts

index ce655c2df3a2d02daeb03c3ccc599189412475a8..7b50c1952d04ac383fb422f92e47675b6bde434b 100644 (file)
@@ -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<UUIDv4, boolean>
+
   public constructor (protected override readonly uiServerConfiguration: UIServerConfiguration) {
     super(uiServerConfiguration)
+    this.acceptsGzip = new Map<UUIDv4, boolean>()
   }
 
   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
index 844537b0fff94f315ae64db14eea8d77eb68db3d..306a0644490e238d11f73adf173a56142742f9f2 100644 (file)
@@ -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 {
index 16675caa153d153a833bc549fb0480e15cfd5531..d42cf798490398548c03583a67e811c39d60e296 100644 (file)
@@ -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,
+        },
+      },
     })
   }
 
index bf0f760a6c9bd2b226d79b0ed4e74e1bebcd9f1f..3380356293ef10a100c78db5e3d052843e18ad8b 100644 (file)
@@ -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<UUIDv4, boolean> }).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<string, unknown>
+      expect(parsedBody.status).toBe('success')
+      expect(parsedBody.data).toBe(largeData)
+    })
+  })
 })
index 7a4ad9c30f4a072791a75b7a935b28f4fd643e40..5c95d29297762e3edf0a4e05fd0fd86ab364c7bf 100644 (file)
@@ -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
+    })
+  })
 })
index 6b38d18249661072d5b83b510c0efdc54ef129a7..0c1b8f720e7c1bfc053865c9e5990b750dbcd7aa 100644 (file)
@@ -55,9 +55,14 @@ export class MockServerResponse extends EventEmitter {
   public ended = false
   public headers: Record<string, string> = {}
   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<string, string>): this {
     this.statusCode = statusCode
     if (headers != null) {