blob: f2073aff828f42ee50795fc4876209edb3745550 [file] [log] [blame]
Timoney, Daniel (dt5972)324ee362017-02-15 10:37:53 -05001/**
2 * Copyright 2014 IBM Corp.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 **/
16
17var util = require("util");
18var when = require("when");
19var whenNode = require('when/node');
20var fs = require("fs");
21var path = require("path");
22var crypto = require("crypto");
23var UglifyJS = require("uglify-js");
24
25var events = require("../events");
26
27var Node;
28var settings;
29
30function filterNodeInfo(n) {
31 var r = {
32 id: n.id,
33 name: n.name,
34 types: n.types,
35 enabled: n.enabled
36 }
37 if (n.hasOwnProperty("loaded")) {
38 r.loaded = n.loaded;
39 }
40 if (n.hasOwnProperty("module")) {
41 r.module = n.module;
42 }
43 if (n.hasOwnProperty("err")) {
44 r.err = n.err.toString();
45 }
46 return r;
47}
48
49var registry = (function() {
50 var nodeConfigCache = null;
51 var nodeConfigs = {};
52 var nodeList = [];
53 var nodeConstructors = {};
54 var nodeTypeToId = {};
55 var nodeModules = {};
56
57 function saveNodeList() {
58 var nodeList = {};
59
60 for (var i in nodeConfigs) {
61 if (nodeConfigs.hasOwnProperty(i)) {
62 var nodeConfig = nodeConfigs[i];
63 var n = filterNodeInfo(nodeConfig);
64 n.file = nodeConfig.file;
65 delete n.loaded;
66 delete n.err;
67 delete n.file;
68 delete n.id;
69 nodeList[i] = n;
70 }
71 }
72 if (settings.available()) {
73 return settings.set("nodes",nodeList);
74 } else {
75 return when.reject("Settings unavailable");
76 }
77 }
78
79 return {
80 init: function() {
81 if (settings.available()) {
82 nodeConfigs = settings.get("nodes")||{};
83 // Restore the node id property to individual entries
84 for (var id in nodeConfigs) {
85 if (nodeConfigs.hasOwnProperty(id)) {
86 nodeConfigs[id].id = id;
87 }
88 }
89 } else {
90 nodeConfigs = {};
91 }
92 nodeModules = {};
93 nodeTypeToId = {};
94 nodeConstructors = {};
95 nodeList = [];
96 nodeConfigCache = null;
97 },
98
99 addNodeSet: function(id,set) {
100 if (!set.err) {
101 set.types.forEach(function(t) {
102 nodeTypeToId[t] = id;
103 });
104 }
105
106 if (set.module) {
107 nodeModules[set.module] = nodeModules[set.module]||{nodes:[]};
108 nodeModules[set.module].nodes.push(id);
109 }
110
111 nodeConfigs[id] = set;
112 nodeList.push(id);
113 nodeConfigCache = null;
114 },
115 removeNode: function(id) {
116 var config = nodeConfigs[id];
117 if (!config) {
118 throw new Error("Unrecognised id: "+id);
119 }
120 delete nodeConfigs[id];
121 var i = nodeList.indexOf(id);
122 if (i > -1) {
123 nodeList.splice(i,1);
124 }
125 config.types.forEach(function(t) {
126 delete nodeConstructors[t];
127 delete nodeTypeToId[t];
128 });
129 config.enabled = false;
130 config.loaded = false;
131 nodeConfigCache = null;
132 return filterNodeInfo(config);
133 },
134 removeModule: function(module) {
135 if (!settings.available()) {
136 throw new Error("Settings unavailable");
137 }
138 var nodes = nodeModules[module];
139 if (!nodes) {
140 throw new Error("Unrecognised module: "+module);
141 }
142 var infoList = [];
143 for (var i=0;i<nodes.nodes.length;i++) {
144 infoList.push(registry.removeNode(nodes.nodes[i]));
145 }
146 delete nodeModules[module];
147 saveNodeList();
148 return infoList;
149 },
150 getNodeInfo: function(typeOrId) {
151 if (nodeTypeToId[typeOrId]) {
152 return filterNodeInfo(nodeConfigs[nodeTypeToId[typeOrId]]);
153 } else if (nodeConfigs[typeOrId]) {
154 return filterNodeInfo(nodeConfigs[typeOrId]);
155 }
156 return null;
157 },
158 getNodeList: function() {
159 var list = [];
160 for (var id in nodeConfigs) {
161 if (nodeConfigs.hasOwnProperty(id)) {
162 list.push(filterNodeInfo(nodeConfigs[id]))
163 }
164 }
165 return list;
166 },
167 registerNodeConstructor: function(type,constructor) {
168 if (nodeConstructors[type]) {
169 throw new Error(type+" already registered");
170 }
171 //TODO: Ensure type is known - but doing so will break some tests
172 // that don't have a way to register a node template ahead
173 // of registering the constructor
174 util.inherits(constructor,Node);
175 nodeConstructors[type] = constructor;
176 events.emit("type-registered",type);
177 },
178
179
180 /**
181 * Gets all of the node template configs
182 * @return all of the node templates in a single string
183 */
184 getAllNodeConfigs: function() {
185 if (!nodeConfigCache) {
186 var result = "";
187 var script = "";
188 for (var i=0;i<nodeList.length;i++) {
189 var config = nodeConfigs[nodeList[i]];
190 if (config.enabled && !config.err) {
191 result += config.config;
192 script += config.script;
193 }
194 }
195 if (script.length > 0) {
196 result += '<script type="text/javascript">';
197 result += UglifyJS.minify(script, {fromString: true}).code;
198 result += '</script>';
199 }
200 nodeConfigCache = result;
201 }
202 return nodeConfigCache;
203 },
204
205 getNodeConfig: function(id) {
206 var config = nodeConfigs[id];
207 if (config) {
208 var result = config.config;
209 if (config.script) {
210 result += '<script type="text/javascript">'+config.script+'</script>';
211 }
212 return result;
213 } else {
214 return null;
215 }
216 },
217
218 getNodeConstructor: function(type) {
219 var config = nodeConfigs[nodeTypeToId[type]];
220 if (!config || (config.enabled && !config.err)) {
221 return nodeConstructors[type];
222 }
223 return null;
224 },
225
226 clear: function() {
227 nodeConfigCache = null;
228 nodeConfigs = {};
229 nodeList = [];
230 nodeConstructors = {};
231 nodeTypeToId = {};
232 },
233
234 getTypeId: function(type) {
235 return nodeTypeToId[type];
236 },
237
238 getModuleInfo: function(type) {
239 return nodeModules[type];
240 },
241
242 enableNodeSet: function(id) {
243 if (!settings.available()) {
244 throw new Error("Settings unavailable");
245 }
246 var config = nodeConfigs[id];
247 if (config) {
248 delete config.err;
249 config.enabled = true;
250 if (!config.loaded) {
251 // TODO: honour the promise this returns
252 loadNodeModule(config);
253 }
254 nodeConfigCache = null;
255 saveNodeList();
256 } else {
257 throw new Error("Unrecognised id: "+id);
258 }
259 return filterNodeInfo(config);
260 },
261
262 disableNodeSet: function(id) {
263 if (!settings.available()) {
264 throw new Error("Settings unavailable");
265 }
266 var config = nodeConfigs[id];
267 if (config) {
268 // TODO: persist setting
269 config.enabled = false;
270 nodeConfigCache = null;
271 saveNodeList();
272 } else {
273 throw new Error("Unrecognised id: "+id);
274 }
275 return filterNodeInfo(config);
276 },
277
278 saveNodeList: saveNodeList,
279
280 cleanNodeList: function() {
281 var removed = false;
282 for (var id in nodeConfigs) {
283 if (nodeConfigs.hasOwnProperty(id)) {
284 if (nodeConfigs[id].module && !nodeModules[nodeConfigs[id].module]) {
285 registry.removeNode(id);
286 removed = true;
287 }
288 }
289 }
290 if (removed) {
291 saveNodeList();
292 }
293 }
294 }
295})();
296
297
298
299function init(_settings) {
300 Node = require("./Node");
301 settings = _settings;
302 registry.init();
303}
304
305/**
306 * Synchronously walks the directory looking for node files.
307 * Emits 'node-icon-dir' events for an icon dirs found
308 * @param dir the directory to search
309 * @return an array of fully-qualified paths to .js files
310 */
311function getNodeFiles(dir) {
312 var result = [];
313 var files = [];
314 try {
315 files = fs.readdirSync(dir);
316 } catch(err) {
317 return result;
318 }
319 files.sort();
320 files.forEach(function(fn) {
321 var stats = fs.statSync(path.join(dir,fn));
322 if (stats.isFile()) {
323 if (/\.js$/.test(fn)) {
324 var valid = true;
325 if (settings.nodesExcludes) {
326 for (var i=0;i<settings.nodesExcludes.length;i++) {
327 if (settings.nodesExcludes[i] == fn) {
328 valid = false;
329 break;
330 }
331 }
332 }
333 valid = valid && fs.existsSync(path.join(dir,fn.replace(/\.js$/,".html")))
334
335 if (valid) {
336 result.push(path.join(dir,fn));
337 }
338 }
339 } else if (stats.isDirectory()) {
340 // Ignore /.dirs/, /lib/ /node_modules/
341 if (!/^(\..*|lib|icons|node_modules|test)$/.test(fn)) {
342 result = result.concat(getNodeFiles(path.join(dir,fn)));
343 } else if (fn === "icons") {
344 events.emit("node-icon-dir",path.join(dir,fn));
345 }
346 }
347 });
348 return result;
349}
350
351/**
352 * Scans the node_modules path for nodes
353 * @param moduleName the name of the module to be found
354 * @return a list of node modules: {dir,package}
355 */
356function scanTreeForNodesModules(moduleName) {
357 var dir = __dirname+"/../../nodes";
358 var results = [];
359 var up = path.resolve(path.join(dir,".."));
360 while (up !== dir) {
361 var pm = path.join(dir,"node_modules");
362 try {
363 var files = fs.readdirSync(pm);
364 for (var i=0;i<files.length;i++) {
365 var fn = files[i];
366 if (!registry.getModuleInfo(fn)) {
367 if (!moduleName || fn == moduleName) {
368 var pkgfn = path.join(pm,fn,"package.json");
369 try {
370 var pkg = require(pkgfn);
371 if (pkg['node-red']) {
372 var moduleDir = path.join(pm,fn);
373 results.push({dir:moduleDir,package:pkg});
374 }
375 } catch(err) {
376 if (err.code != "MODULE_NOT_FOUND") {
377 // TODO: handle unexpected error
378 }
379 }
380 if (fn == moduleName) {
381 break;
382 }
383 }
384 }
385 }
386 } catch(err) {
387 }
388
389 dir = up;
390 up = path.resolve(path.join(dir,".."));
391 }
392 return results;
393}
394
395/**
396 * Loads the nodes provided in an npm package.
397 * @param moduleDir the root directory of the package
398 * @param pkg the module's package.json object
399 */
400function loadNodesFromModule(moduleDir,pkg) {
401 var nodes = pkg['node-red'].nodes||{};
402 var results = [];
403 var iconDirs = [];
404 for (var n in nodes) {
405 if (nodes.hasOwnProperty(n)) {
406 var file = path.join(moduleDir,nodes[n]);
407 try {
408 results.push(loadNodeConfig(file,pkg.name,n));
409 } catch(err) {
410 }
411 var iconDir = path.join(moduleDir,path.dirname(nodes[n]),"icons");
412 if (iconDirs.indexOf(iconDir) == -1) {
413 if (fs.existsSync(iconDir)) {
414 events.emit("node-icon-dir",iconDir);
415 iconDirs.push(iconDir);
416 }
417 }
418 }
419 }
420 return results;
421}
422
423
424/**
425 * Loads a node's configuration
426 * @param file the fully qualified path of the node's .js file
427 * @param name the name of the node
428 * @return the node object
429 * {
430 * id: a unqiue id for the node file
431 * name: the name of the node file, or label from the npm module
432 * file: the fully qualified path to the node's .js file
433 * template: the fully qualified path to the node's .html file
434 * config: the non-script parts of the node's .html file
435 * script: the script part of the node's .html file
436 * types: an array of node type names in this file
437 * }
438 */
439function loadNodeConfig(file,module,name) {
440 var id = crypto.createHash('sha1').update(file).digest("hex");
441 if (module && name) {
442 var newid = crypto.createHash('sha1').update(module+":"+name).digest("hex");
443 var existingInfo = registry.getNodeInfo(id);
444 if (existingInfo) {
445 // For a brief period, id for modules were calculated incorrectly.
446 // To prevent false-duplicates, this removes the old id entry
447 registry.removeNode(id);
448 registry.saveNodeList();
449 }
450 id = newid;
451
452 }
453 var info = registry.getNodeInfo(id);
454
455 var isEnabled = true;
456
457 if (info) {
458 if (info.hasOwnProperty("loaded")) {
459 throw new Error(file+" already loaded");
460 }
461 isEnabled = info.enabled;
462 }
463
464 var node = {
465 id: id,
466 file: file,
467 template: file.replace(/\.js$/,".html"),
468 enabled: isEnabled,
469 loaded:false
470 }
471
472 if (module) {
473 node.name = module+":"+name;
474 node.module = module;
475 } else {
476 node.name = path.basename(file)
477 }
478 try {
479 var content = fs.readFileSync(node.template,'utf8');
480
481 var types = [];
482
483 var regExp = /<script ([^>]*)data-template-name=['"]([^'"]*)['"]/gi;
484 var match = null;
485
486 while((match = regExp.exec(content)) !== null) {
487 types.push(match[2]);
488 }
489 node.types = types;
490 node.config = content;
491
492 // TODO: parse out the javascript portion of the template
493 node.script = "";
494
495 for (var i=0;i<node.types.length;i++) {
496 if (registry.getTypeId(node.types[i])) {
497 node.err = node.types[i]+" already registered";
498 break;
499 }
500 }
501 } catch(err) {
502 node.types = [];
503 if (err.code === 'ENOENT') {
504 node.err = "Error: "+file+" does not exist";
505 } else {
506 node.err = err.toString();
507 }
508 }
509 registry.addNodeSet(id,node);
510 return node;
511}
512
513/**
514 * Loads all palette nodes
515 * @param defaultNodesDir optional parameter, when set, it overrides the default
516 * location of nodeFiles - used by the tests
517 * @return a promise that resolves on completion of loading
518 */
519function load(defaultNodesDir,disableNodePathScan) {
520 return when.promise(function(resolve,reject) {
521 // Find all of the nodes to load
522 var nodeFiles;
523 if(defaultNodesDir) {
524 nodeFiles = getNodeFiles(path.resolve(defaultNodesDir));
525 } else {
526 nodeFiles = getNodeFiles(__dirname+"/../../nodes");
527 }
528
529 if (settings.nodesDir) {
530 var dir = settings.nodesDir;
531 if (typeof settings.nodesDir == "string") {
532 dir = [dir];
533 }
534 for (var i=0;i<dir.length;i++) {
535 nodeFiles = nodeFiles.concat(getNodeFiles(dir[i]));
536 }
537 }
538 var nodes = [];
539 nodeFiles.forEach(function(file) {
540 try {
541 nodes.push(loadNodeConfig(file));
542 } catch(err) {
543 //
544 }
545 });
546
547 // TODO: disabling npm module loading if defaultNodesDir set
548 // This indicates a test is being run - don't want to pick up
549 // unexpected nodes.
550 // Urgh.
551 if (!disableNodePathScan) {
552 // Find all of the modules containing nodes
553 var moduleFiles = scanTreeForNodesModules();
554 moduleFiles.forEach(function(moduleFile) {
555 nodes = nodes.concat(loadNodesFromModule(moduleFile.dir,moduleFile.package));
556 });
557 }
558 var promises = [];
559 nodes.forEach(function(node) {
560 if (!node.err) {
561 promises.push(loadNodeModule(node));
562 }
563 });
564
565 //resolve([]);
566 when.settle(promises).then(function(results) {
567 // Trigger a load of the configs to get it precached
568 registry.getAllNodeConfigs();
569
570 if (settings.available()) {
571 resolve(registry.saveNodeList());
572 } else {
573 resolve();
574 }
575 });
576 });
577}
578
579/**
580 * Loads the specified node into the runtime
581 * @param node a node info object - see loadNodeConfig
582 * @return a promise that resolves to an update node info object. The object
583 * has the following properties added:
584 * err: any error encountered whilst loading the node
585 *
586 */
587function loadNodeModule(node) {
588 var nodeDir = path.dirname(node.file);
589 var nodeFn = path.basename(node.file);
590 if (!node.enabled) {
591 return when.resolve(node);
592 }
593 try {
594 var loadPromise = null;
595 var r = require(node.file);
596 if (typeof r === "function") {
597 var promise = r(require('../red'));
598 if (promise != null && typeof promise.then === "function") {
599 loadPromise = promise.then(function() {
600 node.enabled = true;
601 node.loaded = true;
602 return node;
603 }).otherwise(function(err) {
604 node.err = err;
605 return node;
606 });
607 }
608 }
609 if (loadPromise == null) {
610 node.enabled = true;
611 node.loaded = true;
612 loadPromise = when.resolve(node);
613 }
614 return loadPromise;
615 } catch(err) {
616 node.err = err;
617 return when.resolve(node);
618 }
619}
620
621function loadNodeList(nodes) {
622 var promises = [];
623 nodes.forEach(function(node) {
624 if (!node.err) {
625 promises.push(loadNodeModule(node));
626 } else {
627 promises.push(node);
628 }
629 });
630
631 return when.settle(promises).then(function(results) {
632 return registry.saveNodeList().then(function() {
633 var list = results.map(function(r) {
634 return filterNodeInfo(r.value);
635 });
636 return list;
637 });
638 });
639}
640
641function addNode(file) {
642 if (!settings.available()) {
643 throw new Error("Settings unavailable");
644 }
645 var nodes = [];
646 try {
647 nodes.push(loadNodeConfig(file));
648 } catch(err) {
649 return when.reject(err);
650 }
651 return loadNodeList(nodes);
652}
653
654function addModule(module) {
655 if (!settings.available()) {
656 throw new Error("Settings unavailable");
657 }
658 var nodes = [];
659 if (registry.getModuleInfo(module)) {
660 return when.reject(new Error("Module already loaded"));
661 }
662 var moduleFiles = scanTreeForNodesModules(module);
663 if (moduleFiles.length === 0) {
664 var err = new Error("Cannot find module '" + module + "'");
665 err.code = 'MODULE_NOT_FOUND';
666 return when.reject(err);
667 }
668 moduleFiles.forEach(function(moduleFile) {
669 nodes = nodes.concat(loadNodesFromModule(moduleFile.dir,moduleFile.package));
670 });
671 return loadNodeList(nodes);
672}
673
674module.exports = {
675 init:init,
676 load:load,
677 clear: registry.clear,
678 registerType: registry.registerNodeConstructor,
679 get: registry.getNodeConstructor,
680 getNodeInfo: registry.getNodeInfo,
681 getNodeModuleInfo: registry.getModuleInfo,
682 getNodeList: registry.getNodeList,
683 getNodeConfigs: registry.getAllNodeConfigs,
684 getNodeConfig: registry.getNodeConfig,
685 addNode: addNode,
686 removeNode: registry.removeNode,
687 enableNode: registry.enableNodeSet,
688 disableNode: registry.disableNodeSet,
689
690 addModule: addModule,
691 removeModule: registry.removeModule,
692 cleanNodeList: registry.cleanNodeList
693}