UNPKG

16.7 kBJavaScriptView Raw
1/* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/no-unsafe-return */
2import { isDeepStrictEqual } from 'node:util';
3import process from 'node:process';
4import fs from 'node:fs';
5import path from 'node:path';
6import crypto from 'node:crypto';
7import assert from 'node:assert';
8import { getProperty, hasProperty, setProperty, deleteProperty } from 'dot-prop';
9import envPaths from 'env-paths';
10import { writeFileSync as atomicWriteFileSync } from 'atomically';
11import AjvModule from 'ajv';
12import ajvFormatsModule from 'ajv-formats';
13import debounceFn from 'debounce-fn';
14import semver from 'semver';
15import { concatUint8Arrays, stringToUint8Array, uint8ArrayToString, } from 'uint8array-extras';
16// FIXME: https://github.com/ajv-validator/ajv/issues/2047
17const Ajv = AjvModule.default;
18const ajvFormats = ajvFormatsModule.default;
19const encryptionAlgorithm = 'aes-256-cbc';
20const createPlainObject = () => Object.create(null);
21const isExist = (data) => data !== undefined && data !== null;
22const checkValueType = (key, value) => {
23 const nonJsonTypes = new Set([
24 'undefined',
25 'symbol',
26 'function',
27 ]);
28 const type = typeof value;
29 if (nonJsonTypes.has(type)) {
30 throw new TypeError(`Setting a value of type \`${type}\` for key \`${key}\` is not allowed as it's not supported by JSON`);
31 }
32};
33const INTERNAL_KEY = '__internal__';
34const MIGRATION_KEY = `${INTERNAL_KEY}.migrations.version`;
35export default class Conf {
36 path;
37 events;
38 #validator;
39 #encryptionKey;
40 #options;
41 #defaultValues = {};
42 constructor(partialOptions = {}) {
43 const options = {
44 configName: 'config',
45 fileExtension: 'json',
46 projectSuffix: 'nodejs',
47 clearInvalidConfig: false,
48 accessPropertiesByDotNotation: true,
49 configFileMode: 0o666,
50 ...partialOptions,
51 };
52 if (!options.cwd) {
53 if (!options.projectName) {
54 throw new Error('Please specify the `projectName` option.');
55 }
56 options.cwd = envPaths(options.projectName, { suffix: options.projectSuffix }).config;
57 }
58 this.#options = options;
59 if (options.schema) {
60 if (typeof options.schema !== 'object') {
61 throw new TypeError('The `schema` option must be an object.');
62 }
63 const ajv = new Ajv({
64 allErrors: true,
65 useDefaults: true,
66 });
67 ajvFormats(ajv);
68 const schema = {
69 type: 'object',
70 properties: options.schema,
71 };
72 this.#validator = ajv.compile(schema);
73 for (const [key, value] of Object.entries(options.schema)) { // TODO: Remove the `as any`.
74 if (value?.default) {
75 this.#defaultValues[key] = value.default; // eslint-disable-line @typescript-eslint/no-unsafe-assignment
76 }
77 }
78 }
79 if (options.defaults) {
80 this.#defaultValues = {
81 ...this.#defaultValues,
82 ...options.defaults,
83 };
84 }
85 if (options.serialize) {
86 this._serialize = options.serialize;
87 }
88 if (options.deserialize) {
89 this._deserialize = options.deserialize;
90 }
91 this.events = new EventTarget();
92 this.#encryptionKey = options.encryptionKey;
93 const fileExtension = options.fileExtension ? `.${options.fileExtension}` : '';
94 this.path = path.resolve(options.cwd, `${options.configName ?? 'config'}${fileExtension}`);
95 const fileStore = this.store;
96 const store = Object.assign(createPlainObject(), options.defaults, fileStore);
97 this._validate(store);
98 try {
99 assert.deepEqual(fileStore, store);
100 }
101 catch {
102 this.store = store;
103 }
104 if (options.watch) {
105 this._watch();
106 }
107 if (options.migrations) {
108 if (!options.projectVersion) {
109 throw new Error('Please specify the `projectVersion` option.');
110 }
111 this._migrate(options.migrations, options.projectVersion, options.beforeEachMigration);
112 }
113 }
114 get(key, defaultValue) {
115 if (this.#options.accessPropertiesByDotNotation) {
116 return this._get(key, defaultValue);
117 }
118 const { store } = this;
119 return key in store ? store[key] : defaultValue;
120 }
121 set(key, value) {
122 if (typeof key !== 'string' && typeof key !== 'object') {
123 throw new TypeError(`Expected \`key\` to be of type \`string\` or \`object\`, got ${typeof key}`);
124 }
125 if (typeof key !== 'object' && value === undefined) {
126 throw new TypeError('Use `delete()` to clear values');
127 }
128 if (this._containsReservedKey(key)) {
129 throw new TypeError(`Please don't use the ${INTERNAL_KEY} key, as it's used to manage this module internal operations.`);
130 }
131 const { store } = this;
132 const set = (key, value) => {
133 checkValueType(key, value);
134 if (this.#options.accessPropertiesByDotNotation) {
135 setProperty(store, key, value);
136 }
137 else {
138 store[key] = value;
139 }
140 };
141 if (typeof key === 'object') {
142 const object = key;
143 for (const [key, value] of Object.entries(object)) {
144 set(key, value);
145 }
146 }
147 else {
148 set(key, value);
149 }
150 this.store = store;
151 }
152 /**
153 Check if an item exists.
154
155 @param key - The key of the item to check.
156 */
157 has(key) {
158 if (this.#options.accessPropertiesByDotNotation) {
159 return hasProperty(this.store, key);
160 }
161 return key in this.store;
162 }
163 /**
164 Reset items to their default values, as defined by the `defaults` or `schema` option.
165
166 @see `clear()` to reset all items.
167
168 @param keys - The keys of the items to reset.
169 */
170 reset(...keys) {
171 for (const key of keys) {
172 if (isExist(this.#defaultValues[key])) {
173 this.set(key, this.#defaultValues[key]);
174 }
175 }
176 }
177 delete(key) {
178 const { store } = this;
179 if (this.#options.accessPropertiesByDotNotation) {
180 deleteProperty(store, key);
181 }
182 else {
183 // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
184 delete store[key];
185 }
186 this.store = store;
187 }
188 /**
189 Delete all items.
190
191 This resets known items to their default values, if defined by the `defaults` or `schema` option.
192 */
193 clear() {
194 this.store = createPlainObject();
195 for (const key of Object.keys(this.#defaultValues)) {
196 this.reset(key);
197 }
198 }
199 /**
200 Watches the given `key`, calling `callback` on any changes.
201
202 @param key - The key wo watch.
203 @param callback - A callback function that is called on any changes. When a `key` is first set `oldValue` will be `undefined`, and when a key is deleted `newValue` will be `undefined`.
204 @returns A function, that when called, will unsubscribe.
205 */
206 onDidChange(key, callback) {
207 if (typeof key !== 'string') {
208 throw new TypeError(`Expected \`key\` to be of type \`string\`, got ${typeof key}`);
209 }
210 if (typeof callback !== 'function') {
211 throw new TypeError(`Expected \`callback\` to be of type \`function\`, got ${typeof callback}`);
212 }
213 return this._handleChange(() => this.get(key), callback);
214 }
215 /**
216 Watches the whole config object, calling `callback` on any changes.
217
218 @param callback - A callback function that is called on any changes. When a `key` is first set `oldValue` will be `undefined`, and when a key is deleted `newValue` will be `undefined`.
219 @returns A function, that when called, will unsubscribe.
220 */
221 onDidAnyChange(callback) {
222 if (typeof callback !== 'function') {
223 throw new TypeError(`Expected \`callback\` to be of type \`function\`, got ${typeof callback}`);
224 }
225 return this._handleChange(() => this.store, callback);
226 }
227 get size() {
228 return Object.keys(this.store).length;
229 }
230 get store() {
231 try {
232 const data = fs.readFileSync(this.path, this.#encryptionKey ? null : 'utf8');
233 const dataString = this._encryptData(data);
234 const deserializedData = this._deserialize(dataString);
235 this._validate(deserializedData);
236 return Object.assign(createPlainObject(), deserializedData);
237 }
238 catch (error) {
239 if (error?.code === 'ENOENT') {
240 this._ensureDirectory();
241 return createPlainObject();
242 }
243 if (this.#options.clearInvalidConfig && error.name === 'SyntaxError') {
244 return createPlainObject();
245 }
246 throw error;
247 }
248 }
249 set store(value) {
250 this._ensureDirectory();
251 this._validate(value);
252 this._write(value);
253 this.events.dispatchEvent(new Event('change'));
254 }
255 *[Symbol.iterator]() {
256 for (const [key, value] of Object.entries(this.store)) {
257 yield [key, value];
258 }
259 }
260 _encryptData(data) {
261 if (!this.#encryptionKey) {
262 return typeof data === 'string' ? data : uint8ArrayToString(data);
263 }
264 // Check if an initialization vector has been used to encrypt the data.
265 try {
266 const initializationVector = data.slice(0, 16);
267 const password = crypto.pbkdf2Sync(this.#encryptionKey, initializationVector.toString(), 10000, 32, 'sha512');
268 const decipher = crypto.createDecipheriv(encryptionAlgorithm, password, initializationVector);
269 const slice = data.slice(17);
270 const dataUpdate = typeof slice === 'string' ? stringToUint8Array(slice) : slice;
271 return uint8ArrayToString(concatUint8Arrays([decipher.update(dataUpdate), decipher.final()]));
272 }
273 catch { }
274 return data.toString();
275 }
276 _handleChange(getter, callback) {
277 let currentValue = getter();
278 const onChange = () => {
279 const oldValue = currentValue;
280 const newValue = getter();
281 if (isDeepStrictEqual(newValue, oldValue)) {
282 return;
283 }
284 currentValue = newValue;
285 callback.call(this, newValue, oldValue);
286 };
287 this.events.addEventListener('change', onChange);
288 return () => {
289 this.events.removeEventListener('change', onChange);
290 };
291 }
292 _deserialize = value => JSON.parse(value);
293 _serialize = value => JSON.stringify(value, undefined, '\t');
294 _validate(data) {
295 if (!this.#validator) {
296 return;
297 }
298 const valid = this.#validator(data);
299 if (valid || !this.#validator.errors) {
300 return;
301 }
302 const errors = this.#validator.errors
303 .map(({ instancePath, message = '' }) => `\`${instancePath.slice(1)}\` ${message}`);
304 throw new Error('Config schema violation: ' + errors.join('; '));
305 }
306 _ensureDirectory() {
307 // Ensure the directory exists as it could have been deleted in the meantime.
308 fs.mkdirSync(path.dirname(this.path), { recursive: true });
309 }
310 _write(value) {
311 let data = this._serialize(value);
312 if (this.#encryptionKey) {
313 const initializationVector = crypto.randomBytes(16);
314 const password = crypto.pbkdf2Sync(this.#encryptionKey, initializationVector.toString(), 10000, 32, 'sha512');
315 const cipher = crypto.createCipheriv(encryptionAlgorithm, password, initializationVector);
316 data = concatUint8Arrays([initializationVector, stringToUint8Array(':'), cipher.update(stringToUint8Array(data)), cipher.final()]);
317 }
318 // Temporary workaround for Conf being packaged in a Ubuntu Snap app.
319 // See https://github.com/sindresorhus/conf/pull/82
320 if (process.env.SNAP) {
321 fs.writeFileSync(this.path, data, { mode: this.#options.configFileMode });
322 }
323 else {
324 try {
325 atomicWriteFileSync(this.path, data, { mode: this.#options.configFileMode });
326 }
327 catch (error) {
328 // Fix for https://github.com/sindresorhus/electron-store/issues/106
329 // Sometimes on Windows, we will get an EXDEV error when atomic writing
330 // (even though to the same directory), so we fall back to non atomic write
331 if (error?.code === 'EXDEV') {
332 fs.writeFileSync(this.path, data, { mode: this.#options.configFileMode });
333 return;
334 }
335 throw error;
336 }
337 }
338 }
339 _watch() {
340 this._ensureDirectory();
341 if (!fs.existsSync(this.path)) {
342 this._write(createPlainObject());
343 }
344 if (process.platform === 'win32') {
345 fs.watch(this.path, { persistent: false }, debounceFn(() => {
346 // On Linux and Windows, writing to the config file emits a `rename` event, so we skip checking the event type.
347 this.events.dispatchEvent(new Event('change'));
348 }, { wait: 100 }));
349 }
350 else {
351 fs.watchFile(this.path, { persistent: false }, debounceFn(() => {
352 this.events.dispatchEvent(new Event('change'));
353 }, { wait: 5000 }));
354 }
355 }
356 _migrate(migrations, versionToMigrate, beforeEachMigration) {
357 let previousMigratedVersion = this._get(MIGRATION_KEY, '0.0.0');
358 const newerVersions = Object.keys(migrations)
359 .filter(candidateVersion => this._shouldPerformMigration(candidateVersion, previousMigratedVersion, versionToMigrate));
360 let storeBackup = { ...this.store };
361 for (const version of newerVersions) {
362 try {
363 if (beforeEachMigration) {
364 beforeEachMigration(this, {
365 fromVersion: previousMigratedVersion,
366 toVersion: version,
367 finalVersion: versionToMigrate,
368 versions: newerVersions,
369 });
370 }
371 const migration = migrations[version];
372 migration?.(this);
373 this._set(MIGRATION_KEY, version);
374 previousMigratedVersion = version;
375 storeBackup = { ...this.store };
376 }
377 catch (error) {
378 this.store = storeBackup;
379 throw new Error(`Something went wrong during the migration! Changes applied to the store until this failed migration will be restored. ${error}`);
380 }
381 }
382 if (this._isVersionInRangeFormat(previousMigratedVersion) || !semver.eq(previousMigratedVersion, versionToMigrate)) {
383 this._set(MIGRATION_KEY, versionToMigrate);
384 }
385 }
386 _containsReservedKey(key) {
387 if (typeof key === 'object') {
388 const firsKey = Object.keys(key)[0];
389 if (firsKey === INTERNAL_KEY) {
390 return true;
391 }
392 }
393 if (typeof key !== 'string') {
394 return false;
395 }
396 if (this.#options.accessPropertiesByDotNotation) {
397 if (key.startsWith(`${INTERNAL_KEY}.`)) {
398 return true;
399 }
400 return false;
401 }
402 return false;
403 }
404 _isVersionInRangeFormat(version) {
405 return semver.clean(version) === null;
406 }
407 _shouldPerformMigration(candidateVersion, previousMigratedVersion, versionToMigrate) {
408 if (this._isVersionInRangeFormat(candidateVersion)) {
409 if (previousMigratedVersion !== '0.0.0' && semver.satisfies(previousMigratedVersion, candidateVersion)) {
410 return false;
411 }
412 return semver.satisfies(versionToMigrate, candidateVersion);
413 }
414 if (semver.lte(candidateVersion, previousMigratedVersion)) {
415 return false;
416 }
417 if (semver.gt(candidateVersion, versionToMigrate)) {
418 return false;
419 }
420 return true;
421 }
422 _get(key, defaultValue) {
423 return getProperty(this.store, key, defaultValue);
424 }
425 _set(key, value) {
426 const { store } = this;
427 setProperty(store, key, value);
428 this.store = store;
429 }
430}