1 |
|
2 | import { isDeepStrictEqual } from 'node:util';
|
3 | import process from 'node:process';
|
4 | import fs from 'node:fs';
|
5 | import path from 'node:path';
|
6 | import crypto from 'node:crypto';
|
7 | import assert from 'node:assert';
|
8 | import { getProperty, hasProperty, setProperty, deleteProperty } from 'dot-prop';
|
9 | import envPaths from 'env-paths';
|
10 | import { writeFileSync as atomicWriteFileSync } from 'atomically';
|
11 | import AjvModule from 'ajv';
|
12 | import ajvFormatsModule from 'ajv-formats';
|
13 | import debounceFn from 'debounce-fn';
|
14 | import semver from 'semver';
|
15 | import { concatUint8Arrays, stringToUint8Array, uint8ArrayToString, } from 'uint8array-extras';
|
16 |
|
17 | const Ajv = AjvModule.default;
|
18 | const ajvFormats = ajvFormatsModule.default;
|
19 | const encryptionAlgorithm = 'aes-256-cbc';
|
20 | const createPlainObject = () => Object.create(null);
|
21 | const isExist = (data) => data !== undefined && data !== null;
|
22 | const 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 | };
|
33 | const INTERNAL_KEY = '__internal__';
|
34 | const MIGRATION_KEY = `${INTERNAL_KEY}.migrations.version`;
|
35 | export 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)) {
|
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 |
|
165 |
|
166 |
|
167 |
|
168 |
|
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 |
|
184 | delete store[key];
|
185 | }
|
186 | this.store = store;
|
187 | }
|
188 | |
189 |
|
190 |
|
191 |
|
192 |
|
193 | clear() {
|
194 | this.store = createPlainObject();
|
195 | for (const key of Object.keys(this.#defaultValues)) {
|
196 | this.reset(key);
|
197 | }
|
198 | }
|
199 | |
200 |
|
201 |
|
202 |
|
203 |
|
204 |
|
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 |
|
217 |
|
218 |
|
219 |
|
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 |
|
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 |
|
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 |
|
319 |
|
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 |
|
329 |
|
330 |
|
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 |
|
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 | }
|