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