feat: add configuration cache
[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 = 'storage',
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 configuration: 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.getConfig(), '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.getConfig(), ConfigurationSection.uiServer)) {
72 uiServerConfiguration = merge<UIServerConfiguration>(
73 uiServerConfiguration,
74 Configuration.getConfig()!.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.getConfig(), ConfigurationSection.performanceStorage)) {
99 storageConfiguration = {
100 ...storageConfiguration,
101 ...Configuration.getConfig()?.performanceStorage,
102 ...(Configuration.getConfig()?.performanceStorage?.type === StorageType.JSON_FILE &&
103 Configuration.getConfig()?.performanceStorage?.uri && {
104 uri: Configuration.buildPerformanceUriFilePath(
105 new URL(Configuration.getConfig()!.performanceStorage!.uri!).pathname,
106 ),
107 }),
108 };
109 }
110 return Configuration.getConfigurationSection<StorageConfiguration>(
111 ConfigurationSection.performanceStorage,
112 storageConfiguration,
113 );
114 }
115
116 public static getAutoReconnectMaxRetries(): number | undefined {
117 Configuration.warnDeprecatedConfigurationKey(
118 'autoReconnectTimeout',
119 undefined,
120 "Use 'ConnectionTimeOut' OCPP parameter in charging station template instead",
121 );
122 Configuration.warnDeprecatedConfigurationKey(
123 'connectionTimeout',
124 undefined,
125 "Use 'ConnectionTimeOut' OCPP parameter in charging station template instead",
126 );
127 Configuration.warnDeprecatedConfigurationKey(
128 'autoReconnectMaxRetries',
129 undefined,
130 'Use it in charging station template instead',
131 );
132 // Read conf
133 if (hasOwnProp(Configuration.getConfig(), 'autoReconnectMaxRetries')) {
134 return Configuration.getConfig()?.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.getConfig()!['stationTemplateURLs']) &&
146 (Configuration.getConfig()!.stationTemplateUrls = Configuration.getConfig()![
147 // eslint-disable-next-line @typescript-eslint/dot-notation
148 'stationTemplateURLs'
149 ] as StationTemplateUrl[]);
150 Configuration.getConfig()!.stationTemplateUrls.forEach(
151 (stationTemplateUrl: StationTemplateUrl) => {
152 // eslint-disable-next-line @typescript-eslint/dot-notation
153 if (!isUndefined(stationTemplateUrl['numberOfStation'])) {
154 console.error(
155 `${chalk.green(Configuration.logPrefix())} ${chalk.red(
156 `Deprecated configuration key 'numberOfStation' usage for template file '${stationTemplateUrl.file}' in 'stationTemplateUrls'. Use 'numberOfStations' instead`,
157 )}`,
158 );
159 }
160 },
161 );
162 // Read conf
163 return Configuration.getConfig()?.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.getConfig(), 'logEnabled') && {
228 enabled: Configuration.getConfig()?.logEnabled,
229 }),
230 ...(hasOwnProp(Configuration.getConfig(), 'logFile') && {
231 file: Configuration.getConfig()?.logFile,
232 }),
233 ...(hasOwnProp(Configuration.getConfig(), 'logErrorFile') && {
234 errorFile: Configuration.getConfig()?.logErrorFile,
235 }),
236 ...(hasOwnProp(Configuration.getConfig(), 'logStatisticsInterval') && {
237 statisticsInterval: Configuration.getConfig()?.logStatisticsInterval,
238 }),
239 ...(hasOwnProp(Configuration.getConfig(), 'logLevel') && {
240 level: Configuration.getConfig()?.logLevel,
241 }),
242 ...(hasOwnProp(Configuration.getConfig(), 'logConsole') && {
243 console: Configuration.getConfig()?.logConsole,
244 }),
245 ...(hasOwnProp(Configuration.getConfig(), 'logFormat') && {
246 format: Configuration.getConfig()?.logFormat,
247 }),
248 ...(hasOwnProp(Configuration.getConfig(), 'logRotate') && {
249 rotate: Configuration.getConfig()?.logRotate,
250 }),
251 ...(hasOwnProp(Configuration.getConfig(), 'logMaxFiles') && {
252 maxFiles: Configuration.getConfig()?.logMaxFiles,
253 }),
254 ...(hasOwnProp(Configuration.getConfig(), 'logMaxSize') && {
255 maxSize: Configuration.getConfig()?.logMaxSize,
256 }),
257 };
258 const logConfiguration: LogConfiguration = {
259 ...defaultLogConfiguration,
260 ...deprecatedLogConfiguration,
261 ...(hasOwnProp(Configuration.getConfig(), ConfigurationSection.log) &&
262 Configuration.getConfig()?.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.getConfig(), 'workerPoolStrategy') &&
325 delete Configuration.getConfig()?.workerPoolStrategy;
326 const deprecatedWorkerConfiguration: WorkerConfiguration = {
327 ...(hasOwnProp(Configuration.getConfig(), 'workerProcess') && {
328 processType: Configuration.getConfig()?.workerProcess,
329 }),
330 ...(hasOwnProp(Configuration.getConfig(), 'workerStartDelay') && {
331 startDelay: Configuration.getConfig()?.workerStartDelay,
332 }),
333 ...(hasOwnProp(Configuration.getConfig(), 'chargingStationsPerWorker') && {
334 elementsPerWorker: Configuration.getConfig()?.chargingStationsPerWorker,
335 }),
336 ...(hasOwnProp(Configuration.getConfig(), 'elementStartDelay') && {
337 elementStartDelay: Configuration.getConfig()?.elementStartDelay,
338 }),
339 ...(hasOwnProp(Configuration.getConfig(), 'workerPoolMinSize') && {
340 poolMinSize: Configuration.getConfig()?.workerPoolMinSize,
341 }),
342 ...(hasOwnProp(Configuration.getConfig(), 'workerPoolMaxSize') && {
343 poolMaxSize: Configuration.getConfig()?.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.getConfig(), ConfigurationSection.worker) &&
355 Configuration.getConfig()?.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.getConfig()!['supervisionURLs'])) {
386 // eslint-disable-next-line @typescript-eslint/dot-notation
387 Configuration.getConfig()!.supervisionUrls = Configuration.getConfig()!['supervisionURLs'] as
388 | string
389 | string[];
390 }
391 // Read conf
392 return Configuration.getConfig()?.supervisionUrls;
393 }
394
395 public static getSupervisionUrlDistribution(): SupervisionUrlDistribution | undefined {
396 Configuration.warnDeprecatedConfigurationKey(
397 'distributeStationToTenantEqually',
398 undefined,
399 "Use 'supervisionUrlDistribution' instead",
400 );
401 Configuration.warnDeprecatedConfigurationKey(
402 'distributeStationsToTenantsEqually',
403 undefined,
404 "Use 'supervisionUrlDistribution' instead",
405 );
406 return hasOwnProp(Configuration.getConfig(), 'supervisionUrlDistribution')
407 ? Configuration.getConfig()?.supervisionUrlDistribution
408 : SupervisionUrlDistribution.ROUND_ROBIN;
409 }
410
411 private static logPrefix = (): string => {
412 return `${new Date().toLocaleString()} Simulator configuration |`;
413 };
414
415 private static warnDeprecatedConfigurationKey(
416 key: string,
417 sectionName?: string,
418 logMsgToAppend = '',
419 ) {
420 if (
421 sectionName &&
422 !isUndefined(Configuration.getConfig()![sectionName]) &&
423 !isUndefined((Configuration.getConfig()![sectionName] as Record<string, unknown>)[key])
424 ) {
425 console.error(
426 `${chalk.green(Configuration.logPrefix())} ${chalk.red(
427 `Deprecated configuration key '${key}' usage in section '${sectionName}'${
428 logMsgToAppend.trim().length > 0 ? `. ${logMsgToAppend}` : ''
429 }`,
430 )}`,
431 );
432 } else if (!isUndefined(Configuration.getConfig()![key])) {
433 console.error(
434 `${chalk.green(Configuration.logPrefix())} ${chalk.red(
435 `Deprecated configuration key '${key}' usage${
436 logMsgToAppend.trim().length > 0 ? `. ${logMsgToAppend}` : ''
437 }`,
438 )}`,
439 );
440 }
441 }
442
443 private static getConfigurationSection<T>(
444 sectionName: ConfigurationSection,
445 sectionConfiguration?: T,
446 ): T {
447 if (!Configuration.configurationSectionCache.has(sectionName) && sectionConfiguration) {
448 Configuration.configurationSectionCache.set(sectionName, sectionConfiguration);
449 }
450 return Configuration.configurationSectionCache.get(sectionName) as T;
451 }
452
453 private static getConfig(): ConfigurationData | null {
454 if (!Configuration.configuration) {
455 try {
456 Configuration.configuration = JSON.parse(
457 readFileSync(Configuration.configurationFile, 'utf8'),
458 ) as ConfigurationData;
459 } catch (error) {
460 Configuration.handleFileException(
461 Configuration.configurationFile,
462 FileType.Configuration,
463 error as NodeJS.ErrnoException,
464 Configuration.logPrefix(),
465 );
466 }
467 if (!Configuration.configurationFileWatcher) {
468 Configuration.configurationFileWatcher = Configuration.getConfigurationFileWatcher();
469 }
470 }
471 return Configuration.configuration;
472 }
473
474 private static getConfigurationFileWatcher(): FSWatcher | undefined {
475 try {
476 return watch(Configuration.configurationFile, (event, filename): void => {
477 if (filename!.trim()!.length > 0 && event === 'change') {
478 // Nullify to force configuration file reading
479 Configuration.configuration = null;
480 Configuration.configurationSectionCache.clear();
481 if (!isUndefined(Configuration.configurationChangeCallback)) {
482 Configuration.configurationChangeCallback().catch((error) => {
483 throw typeof error === 'string' ? new Error(error) : error;
484 });
485 }
486 }
487 });
488 } catch (error) {
489 Configuration.handleFileException(
490 Configuration.configurationFile,
491 FileType.Configuration,
492 error as NodeJS.ErrnoException,
493 Configuration.logPrefix(),
494 );
495 }
496 }
497
498 private static handleFileException(
499 file: string,
500 fileType: FileType,
501 error: NodeJS.ErrnoException,
502 logPrefix: string,
503 ): void {
504 const prefix = isNotEmptyString(logPrefix) ? `${logPrefix} ` : '';
505 let logMsg: string;
506 switch (error.code) {
507 case 'ENOENT':
508 logMsg = `${fileType} file ${file} not found:`;
509 break;
510 case 'EEXIST':
511 logMsg = `${fileType} file ${file} already exists:`;
512 break;
513 case 'EACCES':
514 logMsg = `${fileType} file ${file} access denied:`;
515 break;
516 case 'EPERM':
517 logMsg = `${fileType} file ${file} permission denied:`;
518 break;
519 default:
520 logMsg = `${fileType} file ${file} error:`;
521 }
522 console.error(`${chalk.green(prefix)}${chalk.red(`${logMsg} `)}`, error);
523 throw error;
524 }
525
526 private static getDefaultPerformanceStorageUri(storageType: StorageType) {
527 switch (storageType) {
528 case StorageType.JSON_FILE:
529 return Configuration.buildPerformanceUriFilePath(
530 `${Constants.DEFAULT_PERFORMANCE_DIRECTORY}/${Constants.DEFAULT_PERFORMANCE_RECORDS_FILENAME}`,
531 );
532 case StorageType.SQLITE:
533 return Configuration.buildPerformanceUriFilePath(
534 `${Constants.DEFAULT_PERFORMANCE_DIRECTORY}/${Constants.DEFAULT_PERFORMANCE_RECORDS_DB_NAME}.db`,
535 );
536 default:
537 throw new Error(`Performance storage URI is mandatory with storage type '${storageType}'`);
538 }
539 }
540
541 private static buildPerformanceUriFilePath(file: string) {
542 return `file://${join(resolve(dirname(fileURLToPath(import.meta.url)), '../'), file)}`;
543 }
544 }