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