This repository has been archived by the owner on Jul 11, 2018. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathindex.js
344 lines (298 loc) · 10.2 KB
/
index.js
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
'use strict';
// Native
const EventEmitter = require('events').EventEmitter;
const fs = require('fs.extra');
const path = require('path');
// Packages
const chokidar = require('chokidar');
const parseBundle = require('nodecg-bundle-parser');
const Promise = require('bluebird');
const semver = require('semver');
// Start up the watcher, but don't watch any files yet.
// We'll add the files we want to watch later, in the init() method.
const watcher = chokidar.watch([
'!**/*___jb_*___', // Ignore temp files created by JetBrains IDEs
'!**/node_modules/**', // Ignore node_modules folders
'!**/bower_components/**' // Ignore bower_components folders
], {
ignored: /[/\\]\./,
persistent: true,
ignoreInitial: true,
followSymlinks: true
});
const emitter = new EventEmitter();
const bundles = [];
let bundlesPath;
let log;
let root;
let backoffTimer = null;
let hasChanged = {};
let initialized = false;
module.exports = emitter;
/**
* Constructs a bundle-manager.
* @param rootPath {String} - The directory where NodeCG's "bundles" and "cfg" folders can be found.
* @param nodecgVersion {String} - The value of "version" in NodeCG's package.json.
* @param nodecgConfig {Object} - The global NodeCG config.
* @param Logger {Function} - A preconfigured @nodecg/logger constructor.
* @return {Object} - A bundle-manager instance.
*/
module.exports.init = function (rootPath, nodecgVersion, nodecgConfig, Logger) {
if (initialized) {
throw new Error('Cannot initialize when already initialized');
}
initialized = true;
root = rootPath;
log = new Logger('nodecg/lib/bundles');
log.trace('Loading bundles');
const installNpmDeps = require('./lib/npm_installer')(nodecgConfig, Logger);
const installBowerDeps = require('./lib/bower_installer')(nodecgConfig, Logger);
bundlesPath = path.join(rootPath, '/bundles');
// Create the "bundles" dir if it does not exist.
/* istanbul ignore if: We know this code works and testing it is tedious, so we don't bother to test it. */
if (!fs.existsSync(bundlesPath)) {
fs.mkdirpSync(bundlesPath);
}
/* istanbul ignore next */
watcher.on('add', filePath => {
const bundleName = extractBundleName(filePath);
// In theory, the bundle parser would have thrown an error long before this block would execute,
// because in order for us to be adding a panel HTML file, that means that the file would have been missing,
// which the parser does not allow and would throw an error for.
// Just in case though, its here.
if (isPanelHTMLFile(bundleName, filePath)) {
handleChange(bundleName);
}
});
watcher.on('change', filePath => {
const bundleName = extractBundleName(filePath);
if (isManifest(bundleName, filePath)) {
handleChange(bundleName);
} else if (isPanelHTMLFile(bundleName, filePath)) {
handleChange(bundleName);
}
});
watcher.on('unlink', filePath => {
const bundleName = extractBundleName(filePath);
if (isPanelHTMLFile(bundleName, filePath)) {
// This will cause NodeCG to crash, because the parser will throw an error due to
// a panel's HTML file no longer being present.
handleChange(bundleName);
} else if (isManifest(bundleName, filePath)) {
log.debug('Processing removed event for', bundleName);
log.info('%s\'s package.json can no longer be found on disk, ' +
'assuming the bundle has been deleted or moved', bundleName);
module.exports.remove(bundleName);
emitter.emit('bundleRemoved', bundleName);
}
});
/* istanbul ignore next */
watcher.on('error', error => {
log.error(error.stack);
});
// Do an initial load of each bundle in the "bundles" folder.
// During runtime, any changes to a bundle's "dashboard" folder will trigger a re-load of that bundle,
// as will changes to its `package.json`.
const bowerPromises = [];
fs.readdirSync(bundlesPath).forEach(bundleFolderName => {
const bundlePath = path.join(bundlesPath, bundleFolderName);
if (!fs.statSync(bundlePath).isDirectory()) {
return;
}
if (nodecgConfig && nodecgConfig.bundles && nodecgConfig.bundles.disabled &&
nodecgConfig.bundles.disabled.indexOf(bundleFolderName) > -1) {
log.debug('Not loading bundle ' + bundleFolderName + ' as it is disabled in config');
return;
}
if (nodecgConfig && nodecgConfig.bundles && nodecgConfig.bundles.enabled &&
nodecgConfig.bundles.enabled.indexOf(bundleFolderName) < 0) {
log.debug('Not loading bundle ' + bundleFolderName + ' as it is not enabled in config');
return;
}
// Parse each bundle and push the result onto the bundles array
let bundle;
const bundleCfgPath = path.join(rootPath, '/cfg/', bundleFolderName + '.json');
if (fs.existsSync(bundleCfgPath)) {
bundle = parseBundle(bundlePath, bundleCfgPath);
} else {
bundle = parseBundle(bundlePath);
}
// Check if the bundle is compatible with this version of NodeCG
if (!semver.satisfies(nodecgVersion, bundle.compatibleRange)) {
log.error('%s requires NodeCG version %s, current version is %s',
bundle.name, bundle.compatibleRange, nodecgVersion);
return;
}
// This block can probably be removed in 0.8, but let's leave it for 0.7 just in case.
/* istanbul ignore next: Given how strict nodecg-bundle-parser is,
it should not be possible for "bundle" to be undefined. */
if (typeof bundle === 'undefined') {
log.error('Could not load bundle in directory', bundleFolderName);
return;
}
bundles.push(bundle);
if (bundle.dependencies && {}.hasOwnProperty.call(nodecgConfig, 'autodeps') ? nodecgConfig.autodeps.npm : true) {
installNpmDeps(bundle);
}
const bowerPromise = installBowerDeps(bundle);
bowerPromises.push(bowerPromise);
});
// Once all the bowerPromises have been resolved, start up the bundle watcher and emit "allLoaded"
return Promise.all(bowerPromises).then(() => {
// Workaround for https://github.com/paulmillr/chokidar/issues/419
// This workaround is necessary to fully support symlinks.
fs.readdirSync(bundlesPath)
.map(name => path.join(bundlesPath, name))
.filter(source => fs.statSync(source).isDirectory())
.forEach(bundlePath => {
watcher.add([
path.join(bundlePath, 'dashboard'), // Watch dashboard folders
path.join(bundlePath, '/package.json') // Watch bundle package.json files
]);
});
}).catch(
/* istanbul ignore next */
err => {
log.error(err.stack);
}
);
};
/**
* Returns a shallow-cloned array of all currently active bundles.
* @returns {Array.<Object>}
*/
module.exports.all = function () {
return bundles.slice(0);
};
/**
* Returns the bundle with the given name. undefined if not found.
* @param name {String} - The name of the bundle to find.
* @returns {Object|undefined}
*/
module.exports.find = function (name) {
const len = bundles.length;
for (let i = 0; i < len; i++) {
if (bundles[i].name === name) {
return bundles[i];
}
}
};
/**
* Adds a bundle to the internal list, replacing any existing bundle with the same name.
* @param bundle {Object}
*/
module.exports.add = function (bundle) {
/* istanbul ignore if: Again, it shouldn't be possible for "bundle" to be undefined, but just in case... */
if (!bundle) {
return;
}
// Remove any existing bundles with this name
if (module.exports.find(bundle.name)) {
module.exports.remove(bundle.name);
}
bundles.push(bundle);
};
/**
* Removes a bundle with the given name from the internal list. Does nothing if no match found.
* @param bundleName {String}
*/
module.exports.remove = function (bundleName) {
const len = bundles.length;
for (let i = 0; i < len; i++) {
// TODO: this check shouldn't have to happen, idk why things in this array can sometimes be undefined
if (!bundles[i]) {
continue;
}
if (bundles[i].name === bundleName) {
bundles.splice(i, 1);
}
}
};
/**
* Only used by tests.
*/
module.exports._stopWatching = function () {
watcher.close();
};
/**
* Emits a `bundleChanged` event for the given bundle.
* @param bundleName {String}
*/
function handleChange(bundleName) {
const bundle = module.exports.find(bundleName);
/* istanbul ignore if: It's rare for `bundle` to be undefined here, but it can happen when using black/whitelisting. */
if (!bundle) {
return;
}
if (backoffTimer) {
log.debug('Backoff active, delaying processing of change detected in', bundleName);
hasChanged[bundleName] = true;
resetBackoffTimer();
} else {
log.debug('Processing change event for', bundleName);
resetBackoffTimer();
let reparsedBundle;
const bundleCfgPath = path.join(root, '/cfg/', bundleName + '.json');
if (fs.existsSync(bundleCfgPath)) {
reparsedBundle = parseBundle(bundle.dir, bundleCfgPath);
} else {
reparsedBundle = parseBundle(bundle.dir);
}
module.exports.add(reparsedBundle);
emitter.emit('bundleChanged', reparsedBundle);
}
}
/**
* Resets the backoff timer used to avoid event thrashing when many files change rapidly.
*/
function resetBackoffTimer() {
clearTimeout(backoffTimer);
backoffTimer = setTimeout(() => {
backoffTimer = null;
for (const bundleName in hasChanged) {
/* istanbul ignore if: Standard hasOwnProperty check, doesn't need to be tested */
if (!{}.hasOwnProperty.call(hasChanged, bundleName)) {
continue;
}
log.debug('Backoff finished, emitting change event for', bundleName);
handleChange(bundleName);
}
hasChanged = {};
}, 500);
}
/**
* Returns the name of a bundle that owns a given path.
* @param filePath {String} - The path of the file to extract a bundle name from.
* @returns {String} - The name of the bundle that owns this path.
* @private
*/
function extractBundleName(filePath) {
const parts = filePath.replace(bundlesPath, '').split(path.sep);
return parts[1];
}
/**
* Checks if a given path is a panel HTML file of a given bundle.
* @param bundleName {String}
* @param filePath {String}
* @returns {Boolean}
* @private
*/
function isPanelHTMLFile(bundleName, filePath) {
const bundle = module.exports.find(bundleName);
if (bundle) {
return bundle.dashboard.panels.some(panel => {
return panel.path.endsWith(filePath);
});
}
return false;
}
/**
* Checks if a given path is the manifest file for a given bundle.
* @param bundleName {String}
* @param filePath {String}
* @returns {Boolean}
* @private
*/
function isManifest(bundleName, filePath) {
return path.dirname(filePath).endsWith(bundleName) && path.basename(filePath) === 'package.json';
}