refactor: cleanup configuration namespace
[e-mobility-charging-stations-simulator.git] / src / utils / Configuration.ts
1 import { type FSWatcher, readFileSync, watch } from 'node:fs';
2 import { dirname, join, resolve } from 'node:path';
3 import { fileURLToPath } from 'node:url';
4
5 import chalk from 'chalk';
6 import merge from 'just-merge';
7
8 import { Constants } from './Constants';
9 import { hasOwnProp, isCFEnvironment, isNotEmptyString, isUndefined } from './Utils';
10 import {
11 ApplicationProtocol,
12 type ConfigurationData,
13 FileType,
14 type LogConfiguration,
15 type StationTemplateUrl,
16 type StorageConfiguration,
17 StorageType,
18 SupervisionUrlDistribution,
19 type UIServerConfiguration,
20 type WorkerConfiguration,
21 } from '../types';
22 import { WorkerConstants, WorkerProcessType } from '../worker';
23
24 enum ConfigurationSection {
25 log = 'log',
26 performanceStorage = 'performanceStorage',
27 worker = 'worker',
28 uiServer = 'uiServer',
29 }
30
31 export class Configuration {
32 private static configurationFile = join(
33 dirname(fileURLToPath(import.meta.url)),
34 'assets',
35 'config.json',
36 );
37
38 private static configurationFileWatcher: FSWatcher | undefined;
39 private static configurationData: ConfigurationData | null = null;
40 private static configurationSectionCache = new Map<
41 ConfigurationSection,
42 LogConfiguration | StorageConfiguration | WorkerConfiguration | UIServerConfiguration
43 >();
44
45 private static configurationChangeCallback: () => Promise<void>;
46
47 private constructor() {
48 // This is intentional
49 }
50
51 public static setConfigurationChangeCallback(cb: () => Promise<void>): void {
52 Configuration.configurationChangeCallback = cb;
53 }
54
55 public static getUIServer(): UIServerConfiguration {
56 if (hasOwnProp(Configuration.getConfigurationData(), 'uiWebSocketServer')) {
57 console.error(
58 `${chalk.green(Configuration.logPrefix())} ${chalk.red(
59 `Deprecated configuration section 'uiWebSocketServer' usage. Use '${ConfigurationSection.uiServer}' instead`,
60 )}`,
61 );
62 }
63 let uiServerConfiguration: UIServerConfiguration = {
64 enabled: false,
65 type: ApplicationProtocol.WS,
66 options: {
67 host: Constants.DEFAULT_UI_SERVER_HOST,
68 port: Constants.DEFAULT_UI_SERVER_PORT,
69 },
70 };
71 if (hasOwnProp(Configuration.getConfigurationData(), ConfigurationSection.uiServer)) {
72 uiServerConfiguration = merge<UIServerConfiguration>(
73 uiServerConfiguration,
74 Configuration.getConfigurationData()!.uiServer!,
75 );
76 }
77 if (isCFEnvironment() === true) {
78 delete uiServerConfiguration.options?.host;
79 uiServerConfiguration.options!.port = parseInt(process.env.PORT!);
80 }
81 return Configuration.getConfigurationSection<UIServerConfiguration>(
82 ConfigurationSection.uiServer,
83 uiServerConfiguration,
84 );
85 }
86
87 public static getPerformanceStorage(): StorageConfiguration {
88 Configuration.warnDeprecatedConfigurationKey(
89 'URI',
90 ConfigurationSection.performanceStorage,
91 "Use 'uri' instead",
92 );
93 let storageConfiguration: StorageConfiguration = {
94 enabled: false,
95 type: StorageType.JSON_FILE,
96 uri: this.getDefaultPerformanceStorageUri(StorageType.JSON_FILE),
97 };
98 if (hasOwnProp(Configuration.getConfigurationData(), ConfigurationSection.performanceStorage)) {
99 storageConfiguration = {
100 ...storageConfiguration,
101 ...Configuration.getConfigurationData()?.performanceStorage,
102 ...(Configuration.getConfigurationData()?.performanceStorage?.type ===
103 StorageType.JSON_FILE &&
104 Configuration.getConfigurationData()?.performanceStorage?.uri && {
105 uri: Configuration.buildPerformanceUriFilePath(
106 new URL(Configuration.getConfigurationData()!.performanceStorage!.uri!).pathname,
107 ),
108 }),
109 };
110 }
111 return Configuration.getConfigurationSection<StorageConfiguration>(
112 ConfigurationSection.performanceStorage,
113 storageConfiguration,
114 );
115 }
116
117 public static getAutoReconnectMaxRetries(): number | undefined {
118 Configuration.warnDeprecatedConfigurationKey(
119 'autoReconnectTimeout',
120 undefined,
121 "Use 'ConnectionTimeOut' OCPP parameter in charging station template instead",
122 );
123 Configuration.warnDeprecatedConfigurationKey(
124 'connectionTimeout',
125 undefined,
126 "Use 'ConnectionTimeOut' OCPP parameter in charging station template instead",
127 );
128 Configuration.warnDeprecatedConfigurationKey(
129 'autoReconnectMaxRetries',
130 undefined,
131 'Use it in charging station template instead',
132 );
133 if (hasOwnProp(Configuration.getConfigurationData(), 'autoReconnectMaxRetries')) {
134 return Configuration.getConfigurationData()?.autoReconnectMaxRetries;
135 }
136 }
137
138 public static getStationTemplateUrls(): StationTemplateUrl[] | undefined {
139 Configuration.warnDeprecatedConfigurationKey(
140 'stationTemplateURLs',
141 undefined,
142 "Use 'stationTemplateUrls' instead",
143 );
144 // eslint-disable-next-line @typescript-eslint/dot-notation
145 !isUndefined(Configuration.getConfigurationData()!['stationTemplateURLs']) &&
146 (Configuration.getConfigurationData()!.stationTemplateUrls =
147 Configuration.getConfigurationData()![
148 // eslint-disable-next-line @typescript-eslint/dot-notation
149 'stationTemplateURLs'
150 ] as StationTemplateUrl[]);
151 Configuration.getConfigurationData()!.stationTemplateUrls.forEach(
152 (stationTemplateUrl: StationTemplateUrl) => {
153 // eslint-disable-next-line @typescript-eslint/dot-notation
154 if (!isUndefined(stationTemplateUrl['numberOfStation'])) {
155 console.error(
156 `${chalk.green(Configuration.logPrefix())} ${chalk.red(
157 `Deprecated configuration key 'numberOfStation' usage for template file '${stationTemplateUrl.file}' in 'stationTemplateUrls'. Use 'numberOfStations' instead`,
158 )}`,
159 );
160 }
161 },
162 );
163 return Configuration.getConfigurationData()?.stationTemplateUrls;
164 }
165
166 public static getLog(): LogConfiguration {
167 Configuration.warnDeprecatedConfigurationKey(
168 'logEnabled',
169 undefined,
170 `Use '${ConfigurationSection.log}' section to define the logging enablement instead`,
171 );
172 Configuration.warnDeprecatedConfigurationKey(
173 'logFile',
174 undefined,
175 `Use '${ConfigurationSection.log}' section to define the log file instead`,
176 );
177 Configuration.warnDeprecatedConfigurationKey(
178 'logErrorFile',
179 undefined,
180 `Use '${ConfigurationSection.log}' section to define the log error file instead`,
181 );
182 Configuration.warnDeprecatedConfigurationKey(
183 'logConsole',
184 undefined,
185 `Use '${ConfigurationSection.log}' section to define the console logging enablement instead`,
186 );
187 Configuration.warnDeprecatedConfigurationKey(
188 'logStatisticsInterval',
189 undefined,
190 `Use '${ConfigurationSection.log}' section to define the log statistics interval instead`,
191 );
192 Configuration.warnDeprecatedConfigurationKey(
193 'logLevel',
194 undefined,
195 `Use '${ConfigurationSection.log}' section to define the log level instead`,
196 );
197 Configuration.warnDeprecatedConfigurationKey(
198 'logFormat',
199 undefined,
200 `Use '${ConfigurationSection.log}' section to define the log format instead`,
201 );
202 Configuration.warnDeprecatedConfigurationKey(
203 'logRotate',
204 undefined,
205 `Use '${ConfigurationSection.log}' section to define the log rotation enablement instead`,
206 );
207 Configuration.warnDeprecatedConfigurationKey(
208 'logMaxFiles',
209 undefined,
210 `Use '${ConfigurationSection.log}' section to define the log maximum files instead`,
211 );
212 Configuration.warnDeprecatedConfigurationKey(
213 'logMaxSize',
214 undefined,
215 `Use '${ConfigurationSection.log}' section to define the log maximum size instead`,
216 );
217 const defaultLogConfiguration: LogConfiguration = {
218 enabled: true,
219 file: 'logs/combined.log',
220 errorFile: 'logs/error.log',
221 statisticsInterval: Constants.DEFAULT_LOG_STATISTICS_INTERVAL,
222 level: 'info',
223 format: 'simple',
224 rotate: true,
225 };
226 const deprecatedLogConfiguration: LogConfiguration = {
227 ...(hasOwnProp(Configuration.getConfigurationData(), 'logEnabled') && {
228 enabled: Configuration.getConfigurationData()?.logEnabled,
229 }),
230 ...(hasOwnProp(Configuration.getConfigurationData(), 'logFile') && {
231 file: Configuration.getConfigurationData()?.logFile,
232 }),
233 ...(hasOwnProp(Configuration.getConfigurationData(), 'logErrorFile') && {
234 errorFile: Configuration.getConfigurationData()?.logErrorFile,
235 }),
236 ...(hasOwnProp(Configuration.getConfigurationData(), 'logStatisticsInterval') && {
237 statisticsInterval: Configuration.getConfigurationData()?.logStatisticsInterval,
238 }),
239 ...(hasOwnProp(Configuration.getConfigurationData(), 'logLevel') && {
240 level: Configuration.getConfigurationData()?.logLevel,
241 }),
242 ...(hasOwnProp(Configuration.getConfigurationData(), 'logConsole') && {
243 console: Configuration.getConfigurationData()?.logConsole,
244 }),
245 ...(hasOwnProp(Configuration.getConfigurationData(), 'logFormat') && {
246 format: Configuration.getConfigurationData()?.logFormat,
247 }),
248 ...(hasOwnProp(Configuration.getConfigurationData(), 'logRotate') && {
249 rotate: Configuration.getConfigurationData()?.logRotate,
250 }),
251 ...(hasOwnProp(Configuration.getConfigurationData(), 'logMaxFiles') && {
252 maxFiles: Configuration.getConfigurationData()?.logMaxFiles,
253 }),
254 ...(hasOwnProp(Configuration.getConfigurationData(), 'logMaxSize') && {
255 maxSize: Configuration.getConfigurationData()?.logMaxSize,
256 }),
257 };
258 const logConfiguration: LogConfiguration = {
259 ...defaultLogConfiguration,
260 ...deprecatedLogConfiguration,
261 ...(hasOwnProp(Configuration.getConfigurationData(), ConfigurationSection.log) &&
262 Configuration.getConfigurationData()?.log),
263 };
264 return Configuration.getConfigurationSection<LogConfiguration>(
265 ConfigurationSection.log,
266 logConfiguration,
267 );
268 }
269
270 public static getWorker(): WorkerConfiguration {
271 Configuration.warnDeprecatedConfigurationKey(
272 'useWorkerPool',
273 undefined,
274 `Use '${ConfigurationSection.worker}' section to define the type of worker process model instead`,
275 );
276 Configuration.warnDeprecatedConfigurationKey(
277 'workerProcess',
278 undefined,
279 `Use '${ConfigurationSection.worker}' section to define the type of worker process model instead`,
280 );
281 Configuration.warnDeprecatedConfigurationKey(
282 'workerStartDelay',
283 undefined,
284 `Use '${ConfigurationSection.worker}' section to define the worker start delay instead`,
285 );
286 Configuration.warnDeprecatedConfigurationKey(
287 'chargingStationsPerWorker',
288 undefined,
289 `Use '${ConfigurationSection.worker}' section to define the number of element(s) per worker instead`,
290 );
291 Configuration.warnDeprecatedConfigurationKey(
292 'elementStartDelay',
293 undefined,
294 `Use '${ConfigurationSection.worker}' section to define the worker's element start delay instead`,
295 );
296 Configuration.warnDeprecatedConfigurationKey(
297 'workerPoolMinSize',
298 undefined,
299 `Use '${ConfigurationSection.worker}' section to define the worker pool minimum size instead`,
300 );
301 Configuration.warnDeprecatedConfigurationKey(
302 'workerPoolSize;',
303 undefined,
304 `Use '${ConfigurationSection.worker}' section to define the worker pool maximum size instead`,
305 );
306 Configuration.warnDeprecatedConfigurationKey(
307 'workerPoolMaxSize;',
308 undefined,
309 `Use '${ConfigurationSection.worker}' section to define the worker pool maximum size instead`,
310 );
311 Configuration.warnDeprecatedConfigurationKey(
312 'workerPoolStrategy;',
313 undefined,
314 `Use '${ConfigurationSection.worker}' section to define the worker pool strategy instead`,
315 );
316 const defaultWorkerConfiguration: WorkerConfiguration = {
317 processType: WorkerProcessType.workerSet,
318 startDelay: WorkerConstants.DEFAULT_WORKER_START_DELAY,
319 elementsPerWorker: 'auto',
320 elementStartDelay: WorkerConstants.DEFAULT_ELEMENT_START_DELAY,
321 poolMinSize: WorkerConstants.DEFAULT_POOL_MIN_SIZE,
322 poolMaxSize: WorkerConstants.DEFAULT_POOL_MAX_SIZE,
323 };
324 hasOwnProp(Configuration.getConfigurationData(), 'workerPoolStrategy') &&
325 delete Configuration.getConfigurationData()?.workerPoolStrategy;
326 const deprecatedWorkerConfiguration: WorkerConfiguration = {
327 ...(hasOwnProp(Configuration.getConfigurationData(), 'workerProcess') && {
328 processType: Configuration.getConfigurationData()?.workerProcess,
329 }),
330 ...(hasOwnProp(Configuration.getConfigurationData(), 'workerStartDelay') && {
331 startDelay: Configuration.getConfigurationData()?.workerStartDelay,
332 }),
333 ...(hasOwnProp(Configuration.getConfigurationData(), 'chargingStationsPerWorker') && {
334 elementsPerWorker: Configuration.getConfigurationData()?.chargingStationsPerWorker,
335 }),
336 ...(hasOwnProp(Configuration.getConfigurationData(), 'elementStartDelay') && {
337 elementStartDelay: Configuration.getConfigurationData()?.elementStartDelay,
338 }),
339 ...(hasOwnProp(Configuration.getConfigurationData(), 'workerPoolMinSize') && {
340 poolMinSize: Configuration.getConfigurationData()?.workerPoolMinSize,
341 }),
342 ...(hasOwnProp(Configuration.getConfigurationData(), 'workerPoolMaxSize') && {
343 poolMaxSize: Configuration.getConfigurationData()?.workerPoolMaxSize,
344 }),
345 };
346 Configuration.warnDeprecatedConfigurationKey(
347 'poolStrategy',
348 ConfigurationSection.worker,
349 'Not publicly exposed to end users',
350 );
351 const workerConfiguration: WorkerConfiguration = {
352 ...defaultWorkerConfiguration,
353 ...deprecatedWorkerConfiguration,
354 ...(hasOwnProp(Configuration.getConfigurationData(), ConfigurationSection.worker) &&
355 Configuration.getConfigurationData()?.worker),
356 };
357 if (!Object.values(WorkerProcessType).includes(workerConfiguration.processType!)) {
358 throw new SyntaxError(
359 `Invalid worker process type '${workerConfiguration.processType}' defined in configuration`,
360 );
361 }
362 return Configuration.getConfigurationSection<WorkerConfiguration>(
363 ConfigurationSection.worker,
364 workerConfiguration,
365 );
366 }
367
368 public static workerPoolInUse(): boolean {
369 return [WorkerProcessType.dynamicPool, WorkerProcessType.staticPool].includes(
370 Configuration.getWorker().processType!,
371 );
372 }
373
374 public static workerDynamicPoolInUse(): boolean {
375 return Configuration.getWorker().processType === WorkerProcessType.dynamicPool;
376 }
377
378 public static getSupervisionUrls(): string | string[] | undefined {
379 Configuration.warnDeprecatedConfigurationKey(
380 'supervisionURLs',
381 undefined,
382 "Use 'supervisionUrls' instead",
383 );
384 // eslint-disable-next-line @typescript-eslint/dot-notation
385 if (!isUndefined(Configuration.getConfigurationData()!['supervisionURLs'])) {
386 Configuration.getConfigurationData()!.supervisionUrls = Configuration.getConfigurationData()![
387 // eslint-disable-next-line @typescript-eslint/dot-notation
388 'supervisionURLs'
389 ] as string | string[];
390 }
391 return Configuration.getConfigurationData()?.supervisionUrls;
392 }
393
394 public static getSupervisionUrlDistribution(): SupervisionUrlDistribution | undefined {
395 Configuration.warnDeprecatedConfigurationKey(
396 'distributeStationToTenantEqually',
397 undefined,
398 "Use 'supervisionUrlDistribution' instead",
399 );
400 Configuration.warnDeprecatedConfigurationKey(
401 'distributeStationsToTenantsEqually',
402 undefined,
403 "Use 'supervisionUrlDistribution' instead",
404 );
405 return hasOwnProp(Configuration.getConfigurationData(), 'supervisionUrlDistribution')
406 ? Configuration.getConfigurationData()?.supervisionUrlDistribution
407 : SupervisionUrlDistribution.ROUND_ROBIN;
408 }
409
410 private static logPrefix = (): string => {
411 return `${new Date().toLocaleString()} Simulator configuration |`;
412 };
413
414 private static warnDeprecatedConfigurationKey(
415 key: string,
416 sectionName?: string,
417 logMsgToAppend = '',
418 ) {
419 if (
420 sectionName &&
421 !isUndefined(Configuration.getConfigurationData()![sectionName]) &&
422 !isUndefined(
423 (Configuration.getConfigurationData()![sectionName] as Record<string, unknown>)[key],
424 )
425 ) {
426 console.error(
427 `${chalk.green(Configuration.logPrefix())} ${chalk.red(
428 `Deprecated configuration key '${key}' usage in section '${sectionName}'${
429 logMsgToAppend.trim().length > 0 ? `. ${logMsgToAppend}` : ''
430 }`,
431 )}`,
432 );
433 } else if (!isUndefined(Configuration.getConfigurationData()![key])) {
434 console.error(
435 `${chalk.green(Configuration.logPrefix())} ${chalk.red(
436 `Deprecated configuration key '${key}' usage${
437 logMsgToAppend.trim().length > 0 ? `. ${logMsgToAppend}` : ''
438 }`,
439 )}`,
440 );
441 }
442 }
443
444 private static getConfigurationSection<T>(
445 sectionName: ConfigurationSection,
446 sectionConfiguration?: T,
447 ): T {
448 if (!Configuration.configurationSectionCache.has(sectionName) && sectionConfiguration) {
449 Configuration.configurationSectionCache.set(sectionName, sectionConfiguration);
450 }
451 return Configuration.configurationSectionCache.get(sectionName) as T;
452 }
453
454 private static getConfigurationData(): ConfigurationData | null {
455 if (!Configuration.configurationData) {
456 try {
457 Configuration.configurationData = JSON.parse(
458 readFileSync(Configuration.configurationFile, 'utf8'),
459 ) as ConfigurationData;
460 } catch (error) {
461 Configuration.handleFileException(
462 Configuration.configurationFile,
463 FileType.Configuration,
464 error as NodeJS.ErrnoException,
465 Configuration.logPrefix(),
466 );
467 }
468 if (!Configuration.configurationFileWatcher) {
469 Configuration.configurationFileWatcher = Configuration.getConfigurationFileWatcher();
470 }
471 }
472 return Configuration.configurationData;
473 }
474
475 private static getConfigurationFileWatcher(): FSWatcher | undefined {
476 try {
477 return watch(Configuration.configurationFile, (event, filename): void => {
478 if (filename!.trim()!.length > 0 && event === 'change') {
479 // Nullify to force configuration file reading
480 Configuration.configurationData = null;
481 Configuration.configurationSectionCache.clear();
482 if (!isUndefined(Configuration.configurationChangeCallback)) {
483 Configuration.configurationChangeCallback().catch((error) => {
484 throw typeof error === 'string' ? new Error(error) : error;
485 });
486 }
487 }
488 });
489 } catch (error) {
490 Configuration.handleFileException(
491 Configuration.configurationFile,
492 FileType.Configuration,
493 error as NodeJS.ErrnoException,
494 Configuration.logPrefix(),
495 );
496 }
497 }
498
499 private static handleFileException(
500 file: string,
501 fileType: FileType,
502 error: NodeJS.ErrnoException,
503 logPrefix: string,
504 ): void {
505 const prefix = isNotEmptyString(logPrefix) ? `${logPrefix} ` : '';
506 let logMsg: string;
507 switch (error.code) {
508 case 'ENOENT':
509 logMsg = `${fileType} file ${file} not found:`;
510 break;
511 case 'EEXIST':
512 logMsg = `${fileType} file ${file} already exists:`;
513 break;
514 case 'EACCES':
515 logMsg = `${fileType} file ${file} access denied:`;
516 break;
517 case 'EPERM':
518 logMsg = `${fileType} file ${file} permission denied:`;
519 break;
520 default:
521 logMsg = `${fileType} file ${file} error:`;
522 }
523 console.error(`${chalk.green(prefix)}${chalk.red(`${logMsg} `)}`, error);
524 throw error;
525 }
526
527 private static getDefaultPerformanceStorageUri(storageType: StorageType) {
528 switch (storageType) {
529 case StorageType.JSON_FILE:
530 return Configuration.buildPerformanceUriFilePath(
531 `${Constants.DEFAULT_PERFORMANCE_DIRECTORY}/${Constants.DEFAULT_PERFORMANCE_RECORDS_FILENAME}`,
532 );
533 case StorageType.SQLITE:
534 return Configuration.buildPerformanceUriFilePath(
535 `${Constants.DEFAULT_PERFORMANCE_DIRECTORY}/${Constants.DEFAULT_PERFORMANCE_RECORDS_DB_NAME}.db`,
536 );
537 default:
538 throw new Error(`Performance storage URI is mandatory with storage type '${storageType}'`);
539 }
540 }
541
542 private static buildPerformanceUriFilePath(file: string) {
543 return `file://${join(resolve(dirname(fileURLToPath(import.meta.url)), '../'), file)}`;
544 }
545 }