From: Jérôme Benoit Date: Fri, 26 Aug 2022 21:53:46 +0000 (+0200) Subject: Add UI HTTP server (#6) X-Git-Tag: v1.1.68~28 X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=1f7fa4de53a1b28324f362402f61b81b28d75c2c;p=e-mobility-charging-stations-simulator.git Add UI HTTP server (#6) --- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3feffe8b..1f702e1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: strategy: matrix: os: [windows-latest, macos-latest, ubuntu-latest] - node: ['14.x', '16.x', '18.x'] + node: ['16.x', '18.x'] steps: - uses: actions/checkout@v3 with: @@ -46,7 +46,7 @@ jobs: strategy: matrix: os: [windows-latest, macos-latest, ubuntu-latest] - node: ['14.x', '16.x', '18.x'] + node: ['16.x', '18.x'] steps: - uses: actions/checkout@v3 with: diff --git a/README.md b/README.md index 563b2e31..edc08c6c 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ But the modifications to test have to be done to the files in the build target d | logFile | | combined.log | string | log file relative path | | logErrorFile | | error.log | string | error log file relative path | | worker | | {
"processType": "workerSet",
"startDelay": 500,
"elementStartDelay": 0,
"elementsPerWorker": 1,
"poolMinSize": 4,
"poolMaxSize": 16,
"poolStrategy": "ROUND_ROBBIN"
} | {
processType: WorkerProcessType;
startDelay: number;
elementStartDelay: number;
elementsPerWorker: number;
poolMinSize: number;
poolMaxSize: number;
poolStrategy: WorkerChoiceStrategy;
} | Worker configuration section:
- processType: worker threads process type (workerSet/staticPool/dynamicPool)
- startDelay: milliseconds to wait at worker threads startup (only for workerSet threads process type)
- elementStartDelay: milliseconds to wait at charging station startup
- elementsPerWorker: number of charging stations per worker threads for the `workerSet` process type
- poolMinSize: worker threads pool minimum number of threads
- poolMaxSize: worker threads pool maximum number of threads
- poolStrategy: worker threads pool [poolifier](https://github.com/poolifier/poolifier) worker choice strategy | -| uiServer | | {
"enabled": true,
"options": {
"host: "localhost",
"port": 8080
}
} | {
enabled: boolean;
options: ServerOptions;
} | UI WebSocket server configuration section | +| uiServer | | {
"enabled": true,
"type": "ws",
"options": {
"host: "localhost",
"port": 8080
}
} | {
enabled: boolean;
type: ApplicationProtocol;
options: ServerOptions;
} | UI server configuration section | | performanceStorage | | {
"enabled": false,
"type": "jsonfile",
"file:///performanceRecords.json"
} | {
enabled: boolean;
type: string;
URI: string;
}
where type can be 'jsonfile' or 'mongodb' | performance storage configuration section | | stationTemplateUrls | | {}[] | {
file: string;
numberOfStations: number;
}[] | array of charging station configuration templates URIs configuration section (charging station configuration template file name and number of stations) | diff --git a/build-requirements.js b/build-requirements.js new file mode 100644 index 00000000..ebcd5ef0 --- /dev/null +++ b/build-requirements.js @@ -0,0 +1,15 @@ +const chalk = require('chalk'); +// eslint-disable-next-line node/no-unpublished-require +const SemVer = require('semver'); + +const enginesNodeVersion = require('./package.json').engines.node; + +if (SemVer.satisfies(process.version, enginesNodeVersion) === false) { + console.error( + chalk.red( + `Required node version ${enginesNodeVersion} not satisfied with current version ${process.version}.` + ) + ); + // eslint-disable-next-line no-process-exit + process.exit(1); +} diff --git a/package-lock.json b/package-lock.json index 7ba74294..d1abcf59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "ajv-formats": "^2.1.1", "basic-ftp": "^5.0.1", "chalk": "^4.1.2", + "http-status-codes": "^2.2.0", "mnemonist": "^0.39.2", "moment": "^2.29.4", "mongodb": "^4.9.0", @@ -71,6 +72,7 @@ "rollup-plugin-istanbul": "^3.0.0", "rollup-plugin-terser": "^7.0.2", "rollup-plugin-ts": "^3.0.2", + "semver": "^7.3.7", "ts-node": "^10.9.1", "typescript": "^4.8.2" }, @@ -7780,6 +7782,11 @@ "npm": ">=1.3.7" } }, + "node_modules/http-status-codes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.2.0.tgz", + "integrity": "sha512-feERVo9iWxvnejp3SEfm/+oNG517npqL2/PIA8ORjyOZjGC7TwCRQsZylciLS64i6pJ0wRYz3rkXLRwbtFa8Ng==" + }, "node_modules/http2-wrapper": { "version": "2.1.11", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.1.11.tgz", @@ -23591,6 +23598,11 @@ "sshpk": "^1.7.0" } }, + "http-status-codes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.2.0.tgz", + "integrity": "sha512-feERVo9iWxvnejp3SEfm/+oNG517npqL2/PIA8ORjyOZjGC7TwCRQsZylciLS64i6pJ0wRYz3rkXLRwbtFa8Ng==" + }, "http2-wrapper": { "version": "2.1.11", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.1.11.tgz", diff --git a/package.json b/package.json index 9bfc481b..ac3c6270 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,8 @@ }, "scripts": { "prepare": "node prepare.js", - "prestart": "npm run build", + "build-requirements": "node build-requirements.js", + "prestart": "npm run build-requirements && npm run build", "start": "cross-env NODE_ENV=production node -r source-map-support/register dist/start.cjs", "start:debug": "cross-env NODE_ENV=production node -r source-map-support/register --inspect dist/start.cjs", "start:dev": "npm run build:dev && cross-env NODE_ENV=development node -r source-map-support/register dist/start.cjs", @@ -88,6 +89,7 @@ "ajv-formats": "^2.1.1", "basic-ftp": "^5.0.1", "chalk": "^4.1.2", + "http-status-codes": "^2.2.0", "mnemonist": "^0.39.2", "moment": "^2.29.4", "mongodb": "^4.9.0", @@ -145,6 +147,7 @@ "rollup-plugin-istanbul": "^3.0.0", "rollup-plugin-terser": "^7.0.2", "rollup-plugin-ts": "^3.0.2", + "semver": "^7.3.7", "ts-node": "^10.9.1", "typescript": "^4.8.2" } diff --git a/rollup.config.mjs b/rollup.config.mjs index 2f1c5d69..2dc4f5c7 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -42,6 +42,7 @@ export default { 'chalk', 'crypto', 'fs', + 'http', 'mnemonist/lru-map-with-delete', 'moment', 'mongodb', diff --git a/src/charging-station/Bootstrap.ts b/src/charging-station/Bootstrap.ts index d1e2efd3..9fc759ff 100644 --- a/src/charging-station/Bootstrap.ts +++ b/src/charging-station/Bootstrap.ts @@ -19,7 +19,6 @@ import { } from '../types/ChargingStationWorker'; import { StationTemplateUrl } from '../types/ConfigurationData'; import Statistics from '../types/Statistics'; -import { ApplicationProtocol } from '../types/UIProtocol'; import Configuration from '../utils/Configuration'; import logger from '../utils/Logger'; import Utils from '../utils/Utils'; @@ -56,7 +55,7 @@ export class Bootstrap { ); this.initialize(); Configuration.getUIServer().enabled && - (this.uiServer = UIServerFactory.getUIServerImplementation(ApplicationProtocol.WS, { + (this.uiServer = UIServerFactory.getUIServerImplementation(Configuration.getUIServer().type, { ...Configuration.getUIServer().options, handleProtocols: UIServiceUtils.handleProtocols, })); diff --git a/src/charging-station/ui-server/UIHttpServer.ts b/src/charging-station/ui-server/UIHttpServer.ts new file mode 100644 index 00000000..01c0f1b6 --- /dev/null +++ b/src/charging-station/ui-server/UIHttpServer.ts @@ -0,0 +1,140 @@ +import { IncomingMessage, RequestListener, Server, ServerResponse } from 'http'; + +import StatusCodes from 'http-status-codes'; + +import BaseError from '../../exception/BaseError'; +import { ServerOptions } from '../../types/ConfigurationData'; +import { + ProcedureName, + Protocol, + ProtocolResponse, + ProtocolVersion, + RequestPayload, + ResponsePayload, + ResponseStatus, +} from '../../types/UIProtocol'; +import Configuration from '../../utils/Configuration'; +import logger from '../../utils/Logger'; +import Utils from '../../utils/Utils'; +import { AbstractUIServer } from './AbstractUIServer'; +import UIServiceFactory from './ui-services/UIServiceFactory'; +import { UIServiceUtils } from './ui-services/UIServiceUtils'; + +const moduleName = 'UIHttpServer'; + +type responseHandler = { procedureName: ProcedureName; res: ServerResponse }; + +export default class UIHttpServer extends AbstractUIServer { + private readonly responseHandlers: Map; + + public constructor(private options?: ServerOptions) { + super(); + this.server = new Server(this.requestListener.bind(this) as RequestListener); + this.responseHandlers = new Map(); + } + + public start(): void { + (this.server as Server).listen(this.options ?? Configuration.getUIServer().options); + } + + public stop(): void { + this.chargingStations.clear(); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public sendRequest(request: string): void { + // This is intentionally left blank + } + + public sendResponse(response: string): void { + const [uuid, payload] = JSON.parse(response) as ProtocolResponse; + let statusCode: number; + switch (payload.status) { + case ResponseStatus.SUCCESS: + statusCode = StatusCodes.OK; + break; + case ResponseStatus.FAILURE: + default: + statusCode = StatusCodes.BAD_REQUEST; + break; + } + if (this.responseHandlers.has(uuid)) { + const { procedureName, res } = this.responseHandlers.get(uuid); + res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.write(JSON.stringify(payload)); + res.end(); + this.responseHandlers.delete(uuid); + } else { + logger.error( + `${this.logPrefix()} ${moduleName}.sendResponse: Response received for unknown request: ${response}` + ); + } + } + + public logPrefix(modName?: string, methodName?: string): string { + const logMsg = + modName && methodName ? ` UI HTTP Server | ${modName}.${methodName}:` : ' UI HTTP Server |'; + return Utils.logPrefix(logMsg); + } + + private requestListener(req: IncomingMessage, res: ServerResponse): void { + // Expected request URL pathname: /ui/:version/:procedureName + const [protocol, version, procedureName] = req.url?.split('/').slice(1) as [ + Protocol, + ProtocolVersion, + ProcedureName + ]; + const uuid = Utils.generateUUID(); + this.responseHandlers.set(uuid, { procedureName, res }); + try { + if (UIServiceUtils.isProtocolSupported(protocol, version) === false) { + throw new BaseError(`Unsupported UI protocol version: '/${protocol}/${version}'`); + } + req.on('error', (error) => { + logger.error( + `${this.logPrefix( + moduleName, + 'requestListener.req.onerror' + )} Error at incoming request handling:`, + error + ); + }); + if (!this.uiServices.has(version)) { + this.uiServices.set(version, UIServiceFactory.getUIServiceImplementation(version, this)); + } + if (req.method === 'POST') { + const bodyBuffer = []; + let body: RequestPayload; + req + .on('data', (chunk) => { + bodyBuffer.push(chunk); + }) + .on('end', () => { + body = JSON.parse(Buffer.concat(bodyBuffer).toString()) as RequestPayload; + this.uiServices + .get(version) + .requestHandler(this.buildRequest(uuid, procedureName, body ?? {})) + .catch(() => { + this.sendResponse(this.buildResponse(uuid, { status: ResponseStatus.FAILURE })); + }); + }); + } else { + throw new BaseError(`Unsupported HTTP method: '${req.method}'`); + } + } catch (error) { + this.sendResponse(this.buildResponse(uuid, { status: ResponseStatus.FAILURE })); + } + } + + private buildRequest( + id: string, + procedureName: ProcedureName, + requestPayload: RequestPayload + ): string { + return JSON.stringify([id, procedureName, requestPayload]); + } + + private buildResponse(id: string, responsePayload: ResponsePayload): string { + return JSON.stringify([id, responsePayload]); + } +} diff --git a/src/charging-station/ui-server/UIServerFactory.ts b/src/charging-station/ui-server/UIServerFactory.ts index b0020b2e..90f61c7a 100644 --- a/src/charging-station/ui-server/UIServerFactory.ts +++ b/src/charging-station/ui-server/UIServerFactory.ts @@ -5,6 +5,7 @@ import { ApplicationProtocol } from '../../types/UIProtocol'; import Configuration from '../../utils/Configuration'; import { AbstractUIServer } from './AbstractUIServer'; import { UIServiceUtils } from './ui-services/UIServiceUtils'; +import UIHttpServer from './UIHttpServer'; import UIWebSocketServer from './UIWebSocketServer'; export default class UIServerFactory { @@ -26,6 +27,8 @@ export default class UIServerFactory { switch (applicationProtocol) { case ApplicationProtocol.WS: return new UIWebSocketServer(options ?? Configuration.getUIServer().options); + case ApplicationProtocol.HTTP: + return new UIHttpServer(options ?? Configuration.getUIServer().options); default: return null; } diff --git a/src/charging-station/ui-server/ui-services/AbstractUIService.ts b/src/charging-station/ui-server/ui-services/AbstractUIService.ts index 092742b7..e27577d3 100644 --- a/src/charging-station/ui-server/ui-services/AbstractUIService.ts +++ b/src/charging-station/ui-server/ui-services/AbstractUIService.ts @@ -36,7 +36,7 @@ export default abstract class AbstractUIService { this.uiServiceWorkerBroadcastChannel = new UIServiceWorkerBroadcastChannel(this); } - public async requestHandler(request: RawData): Promise { + public async requestHandler(request: RawData | JsonType): Promise { let messageId: string; let command: ProcedureName; let requestPayload: RequestPayload | undefined; @@ -58,10 +58,7 @@ export default abstract class AbstractUIService { responsePayload = await this.requestHandlers.get(command)(messageId, requestPayload); } catch (error) { // Log - logger.error( - `${this.uiServer.logPrefix(moduleName, 'messageHandler')} Handle request error:`, - error - ); + logger.error(`${this.logPrefix(moduleName, 'messageHandler')} Handle request error:`, error); responsePayload = { status: ResponseStatus.FAILURE, command, @@ -74,7 +71,7 @@ export default abstract class AbstractUIService { if (responsePayload !== undefined) { // Send the response - this.uiServer.sendResponse(this.buildProtocolResponse(messageId ?? 'error', responsePayload)); + this.sendResponse(messageId ?? 'error', responsePayload); } } @@ -108,12 +105,12 @@ export default abstract class AbstractUIService { // Validate the raw data received from the WebSocket // TODO: should probably be moved to the ws verify clients callback - private requestValidation(rawData: RawData): ProtocolRequest { + private requestValidation(rawData: RawData | JsonType): ProtocolRequest { // logger.debug( - // `${this.uiServer.logPrefix( + // `${this.logPrefix( // moduleName, - // 'dataValidation' - // )} Raw data received: ${rawData.toString()}` + // 'requestValidation' + // )} Data received in string format: ${rawData.toString()}` // ); const data = JSON.parse(rawData.toString()) as JsonType[]; diff --git a/src/charging-station/ui-server/ui-services/UIServiceUtils.ts b/src/charging-station/ui-server/ui-services/UIServiceUtils.ts index cf89b0c6..06db5a47 100644 --- a/src/charging-station/ui-server/ui-services/UIServiceUtils.ts +++ b/src/charging-station/ui-server/ui-services/UIServiceUtils.ts @@ -23,10 +23,7 @@ export class UIServiceUtils { protocolIndex + Protocol.UI.length ) as Protocol; version = fullProtocol.substring(protocolIndex + Protocol.UI.length) as ProtocolVersion; - if ( - Object.values(Protocol).includes(protocol) && - Object.values(ProtocolVersion).includes(version) - ) { + if (UIServiceUtils.isProtocolSupported(protocol, version) === true) { return fullProtocol; } } @@ -38,6 +35,9 @@ export class UIServiceUtils { return false; }; + public static isProtocolSupported = (protocol: Protocol, version: ProtocolVersion): boolean => + Object.values(Protocol).includes(protocol) && Object.values(ProtocolVersion).includes(version); + public static isLoopback(address: string): boolean { const isLoopbackRegExp = new RegExp( // eslint-disable-next-line no-useless-escape diff --git a/src/types/ConfigurationData.ts b/src/types/ConfigurationData.ts index e5493681..32470f7b 100644 --- a/src/types/ConfigurationData.ts +++ b/src/types/ConfigurationData.ts @@ -4,6 +4,7 @@ import type { WorkerChoiceStrategy } from 'poolifier'; import { ServerOptions as WSServerOptions } from 'ws'; import { StorageType } from './Storage'; +import { ApplicationProtocol } from './UIProtocol'; import { WorkerProcessType } from './Worker'; export type ServerOptions = WSServerOptions & ListenOptions; @@ -21,6 +22,7 @@ export interface StationTemplateUrl { export interface UIServerConfiguration { enabled?: boolean; + type?: ApplicationProtocol; options?: ServerOptions; } diff --git a/src/ui/web/src/composable/UIClient.ts b/src/ui/web/src/composable/UIClient.ts index 22e3a7f2..f699a837 100644 --- a/src/ui/web/src/composable/UIClient.ts +++ b/src/ui/web/src/composable/UIClient.ts @@ -10,9 +10,9 @@ import config from '@/assets/config'; import { v4 as uuidv4 } from 'uuid'; type ResponseHandler = { + procedureName: ProcedureName; resolve: (value: ResponsePayload | PromiseLike) => void; reject: (reason?: any) => void; - procedureName: ProcedureName; }; export default class UIClient { @@ -101,9 +101,9 @@ export default class UIClient { private setResponseHandler( id: string, + procedureName: ProcedureName, resolve: (value: ResponsePayload | PromiseLike) => void, - reject: (reason?: any) => void, - procedureName: ProcedureName + reject: (reason?: any) => void ): void { this._responseHandlers.set(id, { resolve, reject, procedureName }); } @@ -112,6 +112,10 @@ export default class UIClient { return this._responseHandlers.get(id); } + private deleteResponseHandler(id: string): boolean { + return this._responseHandlers.delete(id); + } + private async sendRequest(command: ProcedureName, data: JsonType): Promise { let uuid: string; return Utils.promiseWithTimeout( @@ -128,7 +132,7 @@ export default class UIClient { throw new Error(`Send request ${command} message: connection not opened`); } - this.setResponseHandler(uuid, resolve, reject, command); + this.setResponseHandler(uuid, command, resolve, reject); }), 60 * 1000, Error(`Send request ${command} message timeout`), @@ -158,6 +162,7 @@ export default class UIClient { default: throw new Error(`Response status not supported: ${response.status}`); } + this.deleteResponseHandler(uuid); } else { throw new Error('Not a response to a request: ' + JSON.stringify(data, null, 2)); } diff --git a/src/utils/Configuration.ts b/src/utils/Configuration.ts index 5de86714..f4eb39ad 100644 --- a/src/utils/Configuration.ts +++ b/src/utils/Configuration.ts @@ -15,6 +15,7 @@ import { EmptyObject } from '../types/EmptyObject'; import { HandleErrorParams } from '../types/Error'; import { FileType } from '../types/FileType'; import { StorageType } from '../types/Storage'; +import { ApplicationProtocol } from '../types/UIProtocol'; import { WorkerProcessType } from '../types/Worker'; import WorkerConstants from '../worker/WorkerConstants'; import Constants from './Constants'; @@ -58,6 +59,7 @@ export default class Configuration { } let uiServerConfiguration: UIServerConfiguration = { enabled: true, + type: ApplicationProtocol.WS, options: { host: Constants.DEFAULT_UI_WEBSOCKET_SERVER_HOST, port: Constants.DEFAULT_UI_WEBSOCKET_SERVER_PORT,