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:
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:
| logFile | | combined.log | string | log file relative path |
| logErrorFile | | error.log | string | error log file relative path |
| worker | | {<br />"processType": "workerSet",<br />"startDelay": 500,<br />"elementStartDelay": 0,<br />"elementsPerWorker": 1,<br />"poolMinSize": 4,<br />"poolMaxSize": 16,<br />"poolStrategy": "ROUND_ROBBIN"<br />} | {<br />processType: WorkerProcessType;<br />startDelay: number;<br />elementStartDelay: number;<br />elementsPerWorker: number;<br />poolMinSize: number;<br />poolMaxSize: number;<br />poolStrategy: WorkerChoiceStrategy;<br />} | Worker configuration section:<br />- processType: worker threads process type (workerSet/staticPool/dynamicPool)<br />- startDelay: milliseconds to wait at worker threads startup (only for workerSet threads process type)<br />- elementStartDelay: milliseconds to wait at charging station startup<br />- elementsPerWorker: number of charging stations per worker threads for the `workerSet` process type<br />- poolMinSize: worker threads pool minimum number of threads</br >- poolMaxSize: worker threads pool maximum number of threads<br />- poolStrategy: worker threads pool [poolifier](https://github.com/poolifier/poolifier) worker choice strategy |
-| uiServer | | {<br />"enabled": true,<br />"options": {<br />"host: "localhost",<br />"port": 8080<br />}<br />} | {<br />enabled: boolean;<br />options: ServerOptions;<br />} | UI WebSocket server configuration section |
+| uiServer | | {<br />"enabled": true,<br />"type": "ws",<br />"options": {<br />"host: "localhost",<br />"port": 8080<br />}<br />} | {<br />enabled: boolean;<br />type: ApplicationProtocol;<br />options: ServerOptions;<br />} | UI server configuration section |
| performanceStorage | | {<br />"enabled": false,<br />"type": "jsonfile",<br />"file:///performanceRecords.json"<br />} | {<br />enabled: boolean;<br />type: string;<br />URI: string;<br />}<br />where type can be 'jsonfile' or 'mongodb' | performance storage configuration section |
| stationTemplateUrls | | {}[] | {<br />file: string;<br />numberOfStations: number;<br />}[] | array of charging station configuration templates URIs configuration section (charging station configuration template file name and number of stations) |
--- /dev/null
+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);
+}
"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",
"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"
},
"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",
"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",
},
"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",
"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",
"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"
}
'chalk',
'crypto',
'fs',
+ 'http',
'mnemonist/lru-map-with-delete',
'moment',
'mongodb',
} 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';
);
this.initialize();
Configuration.getUIServer().enabled &&
- (this.uiServer = UIServerFactory.getUIServerImplementation(ApplicationProtocol.WS, {
+ (this.uiServer = UIServerFactory.getUIServerImplementation(Configuration.getUIServer().type, {
...Configuration.getUIServer().options,
handleProtocols: UIServiceUtils.handleProtocols,
}));
--- /dev/null
+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<string, responseHandler>;
+
+ public constructor(private options?: ServerOptions) {
+ super();
+ this.server = new Server(this.requestListener.bind(this) as RequestListener);
+ this.responseHandlers = new Map<string, responseHandler>();
+ }
+
+ 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]);
+ }
+}
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 {
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;
}
this.uiServiceWorkerBroadcastChannel = new UIServiceWorkerBroadcastChannel(this);
}
- public async requestHandler(request: RawData): Promise<void> {
+ public async requestHandler(request: RawData | JsonType): Promise<void> {
let messageId: string;
let command: ProcedureName;
let requestPayload: RequestPayload | undefined;
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,
if (responsePayload !== undefined) {
// Send the response
- this.uiServer.sendResponse(this.buildProtocolResponse(messageId ?? 'error', responsePayload));
+ this.sendResponse(messageId ?? 'error', responsePayload);
}
}
// 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[];
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;
}
}
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
import { ServerOptions as WSServerOptions } from 'ws';
import { StorageType } from './Storage';
+import { ApplicationProtocol } from './UIProtocol';
import { WorkerProcessType } from './Worker';
export type ServerOptions = WSServerOptions & ListenOptions;
export interface UIServerConfiguration {
enabled?: boolean;
+ type?: ApplicationProtocol;
options?: ServerOptions;
}
import { v4 as uuidv4 } from 'uuid';
type ResponseHandler = {
+ procedureName: ProcedureName;
resolve: (value: ResponsePayload | PromiseLike<ResponsePayload>) => void;
reject: (reason?: any) => void;
- procedureName: ProcedureName;
};
export default class UIClient {
private setResponseHandler(
id: string,
+ procedureName: ProcedureName,
resolve: (value: ResponsePayload | PromiseLike<ResponsePayload>) => void,
- reject: (reason?: any) => void,
- procedureName: ProcedureName
+ reject: (reason?: any) => void
): void {
this._responseHandlers.set(id, { resolve, reject, procedureName });
}
return this._responseHandlers.get(id);
}
+ private deleteResponseHandler(id: string): boolean {
+ return this._responseHandlers.delete(id);
+ }
+
private async sendRequest(command: ProcedureName, data: JsonType): Promise<ResponsePayload> {
let uuid: string;
return Utils.promiseWithTimeout(
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`),
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));
}
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';
}
let uiServerConfiguration: UIServerConfiguration = {
enabled: true,
+ type: ApplicationProtocol.WS,
options: {
host: Constants.DEFAULT_UI_WEBSOCKET_SERVER_HOST,
port: Constants.DEFAULT_UI_WEBSOCKET_SERVER_PORT,