UNPKG

4.01 kBPlain TextView Raw
1import * as fs from 'fs-extra';
2import * as path from 'path';
3import { defer, Observable, Observer } from 'rxjs';
4import { distinctUntilChanged, mergeScan, switchMap } from 'rxjs/operators';
5import SeamlessImmutable from 'seamless-immutable';
6
7type Event = 'change';
8
9const watchConfig$ = (file: string): Observable<Event> =>
10 Observable.create((observer: Observer<string>) => {
11 // import('chokidar').FSWatcher
12 // tslint:disable-next-line no-any
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
43export class Config<TConfig extends object> {
44 public readonly config$: Observable<TConfig>;
45 public readonly configPath: string;
46 private readonly defaultConfig: TConfig;
47 // tslint:disable-next-line no-any
48 private readonly schema: any;
49 // tslint:disable-next-line no-any
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 // tslint:disable-next-line no-any
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 // tslint:disable-next-line no-any
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 // tslint:disable-next-line no-object-mutation no-any
117 (error as any).errors = validateConfig.errors;
118 throw error;
119 }
120 }
121
122 // Promise<import('ajv').ValidateFunction>
123 // tslint:disable-next-line no-any
124 private async getValidateConfig(): Promise<any> {
125 if (this.mutableValidateConfig !== undefined) {
126 return this.mutableValidateConfig;
127 }
128
129 const ajv = await import('ajv');
130 // tslint:disable-next-line no-any
131 const validateConfig = new ajv.default().compile(this.schema);
132 this.mutableValidateConfig = validateConfig;
133
134 return validateConfig;
135 }
136}