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