1 | import * as fs from 'fs-extra';
|
2 | import * as path from 'path';
|
3 | import { defer, Observable, Observer } from 'rxjs';
|
4 | import { distinctUntilChanged, mergeScan, switchMap } from 'rxjs/operators';
|
5 | import SeamlessImmutable from 'seamless-immutable';
|
6 |
|
7 | type Event = 'change';
|
8 |
|
9 | const watchConfig$ = (file: string): Observable<Event> =>
|
10 | Observable.create((observer: Observer<string>) => {
|
11 |
|
12 |
|
13 | let watcher: any | undefined;
|
14 | let closed = false;
|
15 | import('chokidar')
|
16 | .then((chokidar) => {
|
17 | if (!closed) {
|
18 | watcher = chokidar.watch(file, { ignoreInitial: false });
|
19 | watcher.on('add', () => {
|
20 | observer.next('change');
|
21 | });
|
22 | watcher.on('change', () => {
|
23 | observer.next('change');
|
24 | });
|
25 | watcher.on('error', (error: Error) => {
|
26 | observer.error(error);
|
27 | });
|
28 | watcher.on('unlink', () => {
|
29 | observer.error(new Error('Configuration file deleted.'));
|
30 | });
|
31 | }
|
32 | })
|
33 | .catch((error) => observer.error(error));
|
34 |
|
35 | return () => {
|
36 | closed = true;
|
37 | if (watcher !== undefined) {
|
38 | watcher.close();
|
39 | }
|
40 | };
|
41 | });
|
42 |
|
43 | export class Config<TConfig extends object> {
|
44 | public readonly config$: Observable<TConfig>;
|
45 | public readonly configPath: string;
|
46 | private readonly defaultConfig: TConfig;
|
47 |
|
48 | private readonly schema: any;
|
49 |
|
50 | private mutableValidateConfig: any;
|
51 |
|
52 | public constructor({
|
53 | name: configName,
|
54 | defaultConfig,
|
55 | schema,
|
56 | configPath,
|
57 | }: {
|
58 | readonly name: string;
|
59 | readonly defaultConfig: TConfig;
|
60 |
|
61 | readonly schema: any;
|
62 | readonly configPath: string;
|
63 | }) {
|
64 | this.configPath = path.resolve(configPath, `${configName}.json`);
|
65 | this.defaultConfig = defaultConfig;
|
66 | this.schema = schema;
|
67 | this.config$ = defer(async () => this.getConfig()).pipe(
|
68 | switchMap((config) =>
|
69 | watchConfig$(this.configPath).pipe(
|
70 | mergeScan((prevConfig) => defer(async () => this.getConfig({ config: prevConfig })), config, 1),
|
71 | ),
|
72 | ),
|
73 |
|
74 | distinctUntilChanged(),
|
75 | );
|
76 | }
|
77 |
|
78 | public async update({ config }: { readonly config: TConfig }): Promise<TConfig> {
|
79 | await this.validate(config);
|
80 | await fs.ensureDir(path.dirname(this.configPath));
|
81 | await fs.writeFile(this.configPath, JSON.stringify(config));
|
82 |
|
83 | return config;
|
84 | }
|
85 |
|
86 | private async getConfig({ config = this.defaultConfig }: { readonly config?: TConfig } = {}): Promise<TConfig> {
|
87 | let contents;
|
88 | try {
|
89 | contents = await fs.readFile(this.configPath, 'utf8');
|
90 | } catch (error) {
|
91 | if (error.code === 'ENOENT') {
|
92 | return this.update({ config });
|
93 | }
|
94 |
|
95 | throw error;
|
96 | }
|
97 |
|
98 | const currentConfig = JSON.parse(contents);
|
99 | await this.validate(currentConfig);
|
100 |
|
101 | if (config !== undefined) {
|
102 |
|
103 | return (SeamlessImmutable as any).merge(config, currentConfig, {
|
104 | deep: true,
|
105 | });
|
106 | }
|
107 |
|
108 | return currentConfig;
|
109 | }
|
110 |
|
111 | private async validate(config: TConfig): Promise<void> {
|
112 | const validateConfig = await this.getValidateConfig();
|
113 | const isValid = validateConfig(config);
|
114 | if (!isValid) {
|
115 | const error = new Error('Invalid config');
|
116 |
|
117 | (error as any).errors = validateConfig.errors;
|
118 | throw error;
|
119 | }
|
120 | }
|
121 |
|
122 |
|
123 |
|
124 | private async getValidateConfig(): Promise<any> {
|
125 | if (this.mutableValidateConfig !== undefined) {
|
126 | return this.mutableValidateConfig;
|
127 | }
|
128 |
|
129 | const ajv = await import('ajv');
|
130 |
|
131 | const validateConfig = new ajv.default().compile(this.schema);
|
132 | this.mutableValidateConfig = validateConfig;
|
133 |
|
134 | return validateConfig;
|
135 | }
|
136 | }
|