-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathOptionsWatcher.ts
More file actions
354 lines (312 loc) · 11.6 KB
/
OptionsWatcher.ts
File metadata and controls
354 lines (312 loc) · 11.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
import { EventEmitter, once } from 'events';
import yaml from 'yaml';
import chokidar, { type FSWatcher } from 'chokidar';
import { readFile } from 'node:fs/promises';
import { isDeepStrictEqual } from 'util';
import harperLogger from '../utility/logging/harper_logger.js';
import { DEFAULT_CONFIG } from './DEFAULT_CONFIG.js';
import { cloneDeep } from 'lodash';
import type { Logger } from './Logger.ts';
export interface Config {
[key: string]: ConfigValue;
}
export type ConfigValue = undefined | null | string | number | boolean | Array<ConfigValue> | Config;
export type OptionsWatcherEventMap = {
ready: [config?: ConfigValue];
change: [key: string[], value: ConfigValue, config: ConfigValue];
remove: [];
error: [error: unknown];
close: [];
};
// This is uniquely for errors coming from the chokidar watcher of the config file.
export class OptionsWatcherConfigFileError extends Error {
constructor(configFilePath: string, error: unknown) {
super(
`Error watching config file ${configFilePath}: ${typeof error === 'object' && error !== null && 'message' in error ? error.message : error}`
);
this.name = 'OptionsWatcherConfigFileError';
}
}
export class UninitializedOptionsWatcherError extends Error {
constructor() {
super(
'OptionsWatcher has not been initialized yet. Await `ready()` or the `ready` event of the respective OptionsWatcher instance.'
);
this.name = 'UninitializedOptionsWatcherError';
}
}
export class InvariantUninitializedOptionsWatcherError extends Error {
constructor() {
super('Invariant: OptionsWatcher has not been initialized yet. This should never happen.');
this.name = 'InvariantUninitializedOptionsWatcherError';
}
}
export class InvalidValueTypeError extends Error {
constructor(keys: string[], value: unknown) {
super(
`Invalid value type for key ${keys.join('.')}. Expected object, string, array, number, boolean, or undefined. Received ${typeof value}.`
);
this.name = 'InvalidValueTypeError';
}
}
export class KeyDoesNotExistError extends Error {
constructor(keys: string[], key: string) {
super(`Cannot set property ${keys.join('.')} as ${key} does not exist.`);
this.name = 'KeyDoesNotExistError';
}
}
export class CannotSetPropertyError extends Error {
constructor(keys: string[]) {
super(`Cannot set property ${keys.join('.')} as parent is not an object.`);
this.name = 'CannotSetPropertyError';
}
}
/**
* Watches a YAML configuration file for changes and provides methods to access the configuration.
*
* @emits ready - When the configuration file is initially loaded and values are available
* @emits change - When any value in the configuration changes (with key, new value, and full config)
* @emits remove - When the configuration file is removed or the extension is removed from the config
* @emits error - When an error occurs reading or parsing the file
* @emits close - When the watcher is closed
*/
export class OptionsWatcher extends EventEmitter<OptionsWatcherEventMap> {
#filePath: string;
#watcher: FSWatcher;
#scopedConfig?: ConfigValue;
#rootConfig?: Config;
#name: string;
#logger: Logger;
ready: Promise<any[]>;
constructor(name: string, filePath: string, logger?: Logger) {
super();
this.#name = name;
this.#filePath = filePath;
this.#logger = logger || harperLogger.loggerWithTag(name);
this.ready = once(this, 'ready');
this.#watcher = chokidar
.watch(filePath, { persistent: false })
.on('add', this.#handleChange.bind(this))
.on('change', this.#handleChange.bind(this))
.on('error', this.#handleError.bind(this))
.on('unlink', this.#handleUnlink.bind(this))
.on('ready', this.#handleChange.bind(this));
}
#handleChange() {
readFile(this.#filePath, 'utf-8')
.then((contents) => {
this.#rootConfig = yaml.parse(contents);
// If the extension is in the config file
if (this.#rootConfig && this.#name in this.#rootConfig) {
// If a config object does not exist
if (!this.#scopedConfig) {
// set it
this.#scopedConfig = this.#rootConfig[this.#name];
// and emit a ready event
this.emit('ready', this.#scopedConfig);
} else {
// Otherwise, merge the new config with the old config
this.#merge(this.#rootConfig[this.#name], this.#scopedConfig);
}
} else {
// Otherwise, if the extension is not in the config file
// This means the plugin was removed from the config file
if (this.#scopedConfig) {
// and a config exists, remove it
this.#scopedConfig = undefined;
this.emit('remove');
}
// Otherwise do nothing - the user may add the config back in later
}
})
.catch((error) => {
// If the config file does not exist
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
// And a config already exists, reset it to the default
if (this.#rootConfig) {
this.#resetConfig();
this.emit('remove');
} else {
// Otherwise, if no config exists, then just set to default and emit ready
this.#resetConfig();
this.emit('ready');
}
return;
}
this.emit('error', error);
});
}
#handleError(error: unknown) {
this.emit('error', new OptionsWatcherConfigFileError(this.#filePath, error));
}
#handleUnlink(path: string) {
this.#logger.warn(
`Configuration file ${path} was deleted. Reverting to default configuration. Recreate it to restore the options watcher.`
);
this.#resetConfig();
this.emit('remove');
}
#resetConfig() {
this.#rootConfig = DEFAULT_CONFIG;
this.#scopedConfig = this.#rootConfig[this.#name];
}
/**
* This merge algorithm is best thought off as a diff and overwrite.
* The new config object will completely overwrite the old config object,
* but we need to recursively iterate over the new entries and emit the
* necessary change events.
*
* All events are considered to be a `change`.
*/
#merge(newConfigValue: ConfigValue, currentConfigValue: ConfigValue, prevKeys: string[] = []) {
// First, ensure current and new config values are Config objects (not null, undefined, or a primitive)
if (!this.#isConfig(currentConfigValue) || !this.#isConfig(newConfigValue)) {
// If either is not a config, then just set as there is no need to diff/merge
this.#setValue(prevKeys, newConfigValue);
return;
}
// Check for any missing keys (new config has removed keys from current config)
for (const key of Object.keys(currentConfigValue)) {
if (!(key in newConfigValue)) {
this.#setValue(prevKeys.concat(key), undefined);
}
}
// Then, iterate of the keys in the new config and check for any changes to the current config
for (const [key, newValue] of Object.entries(newConfigValue)) {
const keys = prevKeys.concat(key);
const currentValue = this.#getValue(keys);
// If the new value is not the same type as the current value, then no equivalency check is necessary
// Just set the value and continue
if (
typeof newValue !== typeof currentValue ||
// one exception to the above rule is if the `currentValue` is being changed from an array to an object or vice versa
// Check for this and shortcut as it can be treated as a type change
(Array.isArray(newValue) && !Array.isArray(currentValue)) ||
(!Array.isArray(newValue) && Array.isArray(currentValue))
) {
this.#setValue(keys, newValue);
continue;
}
// If the new value is an object (non null nor an array), now merge it with the current value
if (!Array.isArray(newValue) && typeof newValue === 'object' && newValue !== null) {
if (this.#isConfig(currentValue)) {
// Now we're sure currentValue is a Config
this.#merge(newValue, currentValue, keys);
} else {
// If currentValue is not a Config, just set newValue
this.#setValue(keys, newValue);
}
continue;
}
if (!isDeepStrictEqual(newValue, currentValue)) {
this.#setValue(keys, newValue);
}
}
}
#isConfig(value: ConfigValue): value is Config {
return typeof value === 'object' && value !== null && value !== undefined && !Array.isArray(value);
}
#getValue(keys: string[]): undefined | ConfigValue {
let value: ConfigValue = this.#scopedConfig;
for (const key of keys) {
if (value === null || value === undefined || typeof value !== 'object' || !(key in value)) return undefined;
value = value[key];
}
return cloneDeep(value);
}
#setValue(keys: string[], value: ConfigValue) {
// This method is only called by `merge`, which is only called by `changeHandler` if `this.#config` is defined.
// So this should never happen, but just in case, throw an error.
// If this ever does get triggered:
// - Did something else other than `merge` call this method?
// - Did the `merge` method get called differently?
// - Did the `merge` method become async and the `this.#config` get set to undefined sometime in between?
if (!this.#scopedConfig) {
throw new InvariantUninitializedOptionsWatcherError();
}
if (!['object', 'string', 'array', 'number', 'boolean', 'undefined'].includes(typeof value)) {
throw new InvalidValueTypeError(keys, value);
}
let obj: ConfigValue = this.#scopedConfig;
for (const key of keys.slice(0, -1)) {
if (obj === null || obj === undefined || typeof obj !== 'object' || !(key in obj)) {
throw new KeyDoesNotExistError(keys, key);
}
obj = obj[key];
}
if (obj === null || obj === undefined || typeof obj !== 'object') {
throw new CannotSetPropertyError(keys);
}
obj[keys[keys.length - 1]] = value;
this.emit('change', keys, value, this.#scopedConfig);
}
/**
* Closes the underlying file watcher, emits the `close` event, and removes any listeners on the OptionsWatcher instance
*/
close() {
this.#watcher.close();
this.emit('close');
this.removeAllListeners();
return this;
}
/**
* Get a value from the configuration using an array of strings representing the key.
*
* For example, if the configuration is:
* ```yaml
* foo:
* bar:
* baz: 42
* ```
* Then `get(['foo','bar','baz'])` will return `42`.
*
* If the key does not exist, `undefined` will be returned.
* @param key an array of strings representing the key.
* @returns
*/
get(key: string[]): ConfigValue | undefined {
return this.#scopedConfig ? this.#getValue(key) : undefined;
}
/**
* Get the entire configuration object.
*
* @returns A deep clone of the entire configuration object.
*/
getAll(): ConfigValue | undefined {
return cloneDeep(this.#scopedConfig);
}
/**
* Get the entire root configuration object from the config file.
*/
getRoot(): Config | undefined {
return this.#rootConfig;
}
// Not sure if we want to enable runtime changes to the config - any changes to the config should be done in the config file.
// /**
// * Set a value in the configuration using a dot-separated key. Any existing value can be replaced with any new value, regardless of type.
// *
// * For example, with the configuration:
// *
// * ```yaml
// * foo:
// * bar:
// * baz: 42
// * ```
// *
// * The call `set('foo.bar.baz', 'harper')` will set `foo.bar.baz` to `'harper'`.
// *
// * This method will allow you to set new values in the configuration, but it will not generate nested objects.
// *
// * For example, using the configuration above, `set('foo.fuzz', 'buzz')`, will work fine.
// *
// * But `set('foo.x.y', 0)` will throw an error, because it is attempting to set `y` on the non-existent `x`.
// *
// * This method will emit a `change` event when the value is set.
// *
// * @param key Dot-separated key to set the value for.
// * @param value Value to set.
// */
// set(key: string, value: any) {
// this.setValue(key.split('.'), cloneDeep(value));
// }
}