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