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 {
type RequestPayload,
ResponseStatus,
type UIServerConfiguration,
+ type UUIDv4,
} from '../../types/index.js'
import {
generateUUID,
import {
createBodySizeLimiter,
createRateLimiter,
+ DEFAULT_COMPRESSION_THRESHOLD,
DEFAULT_MAX_PAYLOAD_SIZE,
DEFAULT_RATE_LIMIT,
DEFAULT_RATE_WINDOW,
}
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 => {
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}`
)
} finally {
this.responseHandlers.delete(uuid)
+ this.acceptsGzip.delete(uuid)
}
}
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
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 {
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,
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,
+ },
+ },
})
}
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'
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 () => {
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)
+ })
+ })
})
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'
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
+ })
+ })
})
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
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) {