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