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