UNPKG

7.78 kBJavaScriptView Raw
1/* eslint-disable node/no-deprecated-api */
2'use strict';
3const fs = require('fs');
4const path = require('path');
5const crypto = require('crypto');
6const assert = require('assert');
7const EventEmitter = require('events');
8const dotProp = require('dot-prop');
9const makeDir = require('make-dir');
10const pkgUp = require('pkg-up');
11const envPaths = require('env-paths');
12const writeFileAtomic = require('write-file-atomic');
13const Ajv = require('ajv');
14
15const plainObject = () => Object.create(null);
16const encryptionAlgorithm = 'aes-256-cbc';
17
18// Prevent caching of this module so module.parent is always accurate
19delete require.cache[__filename];
20const parentDir = path.dirname((module.parent && module.parent.filename) || '.');
21
22const checkValueType = (key, value) => {
23 const nonJsonTypes = [
24 'undefined',
25 'symbol',
26 'function'
27 ];
28
29 const type = typeof value;
30
31 if (nonJsonTypes.includes(type)) {
32 throw new TypeError(`Setting a value of type \`${type}\` for key \`${key}\` is not allowed as it's not supported by JSON`);
33 }
34};
35
36class Conf {
37 constructor(options) {
38 options = {
39 configName: 'config',
40 fileExtension: 'json',
41 projectSuffix: 'nodejs',
42 clearInvalidConfig: true,
43 serialize: value => JSON.stringify(value, null, '\t'),
44 deserialize: JSON.parse,
45 accessPropertiesByDotNotation: true,
46 ...options
47 };
48
49 if (!options.cwd) {
50 if (!options.projectName) {
51 const pkgPath = pkgUp.sync(parentDir);
52 // Can't use `require` because of Webpack being annoying:
53 // https://github.com/webpack/webpack/issues/196
54 options.projectName = pkgPath && JSON.parse(fs.readFileSync(pkgPath, 'utf8')).name;
55 }
56
57 if (!options.projectName) {
58 throw new Error('Project name could not be inferred. Please specify the `projectName` option.');
59 }
60
61 options.cwd = envPaths(options.projectName, {suffix: options.projectSuffix}).config;
62 }
63
64 this._options = options;
65
66 if (options.schema) {
67 if (typeof options.schema !== 'object') {
68 throw new TypeError('The `schema` option must be an object.');
69 }
70
71 const ajv = new Ajv({
72 allErrors: true,
73 format: 'full',
74 useDefaults: true,
75 errorDataPath: 'property'
76 });
77 const schema = {
78 type: 'object',
79 properties: options.schema
80 };
81 this._validator = ajv.compile(schema);
82 }
83
84 this.events = new EventEmitter();
85 this.encryptionKey = options.encryptionKey;
86 this.serialize = options.serialize;
87 this.deserialize = options.deserialize;
88
89 const fileExtension = options.fileExtension ? `.${options.fileExtension}` : '';
90 this.path = path.resolve(options.cwd, `${options.configName}${fileExtension}`);
91
92 const fileStore = this.store;
93 const store = Object.assign(plainObject(), options.defaults, fileStore);
94 this._validate(store);
95 try {
96 assert.deepEqual(fileStore, store);
97 } catch (_) {
98 this.store = store;
99 }
100 }
101
102 _validate(data) {
103 if (!this._validator) {
104 return;
105 }
106
107 const valid = this._validator(data);
108 if (!valid) {
109 const errors = this._validator.errors.reduce((error, {dataPath, message}) =>
110 error + ` \`${dataPath.slice(1)}\` ${message};`, '');
111 throw new Error('Config schema violation:' + errors.slice(0, -1));
112 }
113 }
114
115 get(key, defaultValue) {
116 if (this._options.accessPropertiesByDotNotation) {
117 return dotProp.get(this.store, key, defaultValue);
118 }
119
120 return key in this.store ? this.store[key] : defaultValue;
121 }
122
123 set(key, value) {
124 if (typeof key !== 'string' && typeof key !== 'object') {
125 throw new TypeError(`Expected \`key\` to be of type \`string\` or \`object\`, got ${typeof key}`);
126 }
127
128 if (typeof key !== 'object' && value === undefined) {
129 throw new TypeError('Use `delete()` to clear values');
130 }
131
132 const {store} = this;
133
134 const set = (key, value) => {
135 checkValueType(key, value);
136 if (this._options.accessPropertiesByDotNotation) {
137 dotProp.set(store, key, value);
138 } else {
139 store[key] = value;
140 }
141 };
142
143 if (typeof key === 'object') {
144 const object = key;
145 for (const [key, value] of Object.entries(object)) {
146 set(key, value);
147 }
148 } else {
149 set(key, value);
150 }
151
152 this.store = store;
153 }
154
155 has(key) {
156 if (this._options.accessPropertiesByDotNotation) {
157 return dotProp.has(this.store, key);
158 }
159
160 return key in this.store;
161 }
162
163 delete(key) {
164 const {store} = this;
165 if (this._options.accessPropertiesByDotNotation) {
166 dotProp.delete(store, key);
167 } else {
168 delete store[key];
169 }
170
171 this.store = store;
172 }
173
174 clear() {
175 this.store = plainObject();
176 }
177
178 onDidChange(key, callback) {
179 if (typeof key !== 'string') {
180 throw new TypeError(`Expected \`key\` to be of type \`string\`, got ${typeof key}`);
181 }
182
183 if (typeof callback !== 'function') {
184 throw new TypeError(`Expected \`callback\` to be of type \`function\`, got ${typeof callback}`);
185 }
186
187 const getter = () => this.get(key);
188
189 return this.handleChange(getter, callback);
190 }
191
192 onDidAnyChange(callback) {
193 if (typeof callback !== 'function') {
194 throw new TypeError(`Expected \`callback\` to be of type \`function\`, got ${typeof callback}`);
195 }
196
197 const getter = () => this.store;
198
199 return this.handleChange(getter, callback);
200 }
201
202 handleChange(getter, callback) {
203 let currentValue = getter();
204
205 const onChange = () => {
206 const oldValue = currentValue;
207 const newValue = getter();
208
209 try {
210 // TODO: Use `util.isDeepStrictEqual` when targeting Node.js 10
211 assert.deepEqual(newValue, oldValue);
212 } catch (_) {
213 currentValue = newValue;
214 callback.call(this, newValue, oldValue);
215 }
216 };
217
218 this.events.on('change', onChange);
219 return () => this.events.removeListener('change', onChange);
220 }
221
222 get size() {
223 return Object.keys(this.store).length;
224 }
225
226 get store() {
227 try {
228 let data = fs.readFileSync(this.path, this.encryptionKey ? null : 'utf8');
229
230 if (this.encryptionKey) {
231 try {
232 // Check if an initialization vector has been used to encrypt the data
233 if (data.slice(16, 17).toString() === ':') {
234 const initializationVector = data.slice(0, 16);
235 const password = crypto.pbkdf2Sync(this.encryptionKey, initializationVector.toString(), 10000, 32, 'sha512');
236 const decipher = crypto.createDecipheriv(encryptionAlgorithm, password, initializationVector);
237 data = Buffer.concat([decipher.update(data.slice(17)), decipher.final()]);
238 } else {
239 const decipher = crypto.createDecipher(encryptionAlgorithm, this.encryptionKey);
240 data = Buffer.concat([decipher.update(data), decipher.final()]);
241 }
242 } catch (_) {}
243 }
244
245 data = this.deserialize(data);
246 this._validate(data);
247 return Object.assign(plainObject(), data);
248 } catch (error) {
249 if (error.code === 'ENOENT') {
250 // TODO: Use `fs.mkdirSync` `recursive` option when targeting Node.js 12
251 makeDir.sync(path.dirname(this.path));
252 return plainObject();
253 }
254
255 if (this._options.clearInvalidConfig && error.name === 'SyntaxError') {
256 return plainObject();
257 }
258
259 throw error;
260 }
261 }
262
263 set store(value) {
264 // Ensure the directory exists as it could have been deleted in the meantime
265 makeDir.sync(path.dirname(this.path));
266
267 this._validate(value);
268 let data = this.serialize(value);
269
270 if (this.encryptionKey) {
271 const initializationVector = crypto.randomBytes(16);
272 const password = crypto.pbkdf2Sync(this.encryptionKey, initializationVector.toString(), 10000, 32, 'sha512');
273 const cipher = crypto.createCipheriv(encryptionAlgorithm, password, initializationVector);
274 data = Buffer.concat([initializationVector, Buffer.from(':'), cipher.update(Buffer.from(data)), cipher.final()]);
275 }
276
277 writeFileAtomic.sync(this.path, data);
278 this.events.emit('change');
279 }
280
281 * [Symbol.iterator]() {
282 for (const [key, value] of Object.entries(this.store)) {
283 yield [key, value];
284 }
285 }
286}
287
288module.exports = Conf;