Initial commit

This commit is contained in:
2015-07-06 00:39:38 +02:00
commit 3cd2541f7f
18 changed files with 1111 additions and 0 deletions

152
README.md Normal file
View File

@@ -0,0 +1,152 @@
# MODULAR
Path abstraction and mocks for NodeJS.
## Functionalities
* Automatically load javascript modules
* Abstract modules path. Access any module using namespace and module name
* Easy mock
* Live reload of changes
## Quickstart
```javascript
var Modular = require('modular.js');
var application = new Modular({
files : './app',
liveReload : true,
restartOnChanges : true,
}).on ('start', function(modular) {
modular('main').start();
}).on ('stop', function(modular) {
modular('main').stop();
}).on ('log', function (modular, level, message, error) {
console.log(error);
}).start();
```
## Settings
The modular constructor accept several settings :
```javascript
{
/* files can be a string for one file or folder */
files : 'app/module.js',
/* or an array to load several files or folders */
files : [ 'app/module.js', 'app/module2.js', 'app/subfolder' ],
/* an object allowing to setup the way the modules are loaded */
files : {
/* the path is either a string for one file or folder */
path : 'app/module.js',
/* or an array */
path : [ 'app/module.js', 'app/module2.js', 'app/subfolder' ]
override : true, /* modules override already loaded modules in case of collision
default value : false */
namespace : 'namespace', /* namespace is appended before the deduced namespace */
basePath : 'path/path2', /* the base path used to deduce the namespace of modules
default value : path value (when path is a directory) */
fullName : 'namespace.name',/* specify the full name of the module
Only when path is one file. Doesn't work with namespace setting */
liveReload : true, /* enable or disable liveReload on a file/folder basis
default value : false */
},
basePath : /* Path used to deduce the namespace
default value : path of the file that load Modular */
liveReload : true, /* enable live modification reload for whole application
default value : false */
restartOnChange : true, /* automaticaly restart application when a module is reloaded
default value : liveReload value */
exclude : ['app/module3.js'], /* those files won't be loaded */
default value : file that load Modular
logLevel : 0 /* level of logging. 0 : error, 1 : info, 2 : verbose
default value : 0 */
```
## Events
Modular communicate through messages. Those event aren't asynchronous.
This way, modular won't try to start application before it stopped.
If start or stop event return a Promise object, modular will wait for the Promise to be fulfilled to continue.
### start
Emitted when the application must be started.
* Parameter : modular
* Return : Promise (optional)
### stop
Emitted when the application must be stopped.
This event is optional
* Parameter : modular
* Return : Promise (optional)
### restart
Emitted when the application must be restarted.
This event is optional
If this event is not used, modular will emit the stop event followed by start
* Parameter : modular
* Return : Promise (optional)
### log
Emitted whenever log occurs.
This event is optional.
If this event is not defined, logs are written on stdout.
* Parameter : modular, message, error
* Return : nothing
### loadModule
Emitted whenever a module will be loaded.
This event is optional.
You can change the settings used to load the module.
* Parameter : modular, settings (module parameters)
* Return : false to cancel module loading
## Modules
Modular modules must look likes
```javascript
'use strict';
module.exports = function (modular) {
var mod = modular('namespace.module');
}
```
Or
```javascript
'use strict';
module.exports.onLoad = function (modular) {
var mod = modular('namespace.module');
}
```
Modular will simply call those functions each time the module is required by another.
Those function are never invoked using the new statement and are called even if the nodejs module is already cached.
This means that each module manage its instance (a module can act as a singleton for example, another can always return a new instance).
If the module does not directly exports a function or does not export onLoad function, the module is return as it is, but won't be able to load any modular module.
## Tips
### Exclude your main javascript file
Loading the main javascript file with Modular is useless, watching it for changes can lead to weird behavior.
If no file is excluded, Modular will exclude its parent by default.
You can still load the directory containing your main file by excluding it. The easiest way is to use the node __filename property :
```javascript
var Modular = require('modular.js');
var application = new Modular({
files : './',
exclude : [ __filename],
liveReload : true,
restartOnChange : true,
}).on ('start', function(modular) {
modular('main').start();
}).on ('stop', function(modular) {
modular('main').stop();
}).on ('log', function (modular, level, message, err) {
if (err) {
console.log(err);
}
}).start();
```
###

538
lib/modular.js Normal file
View File

@@ -0,0 +1,538 @@
'use strict';
var path = require('path');
var fs = require('fs');
var SyncEventEmitter = require(path.join(__dirname, 'synceventemitter.js'));
function Modular (options) {
SyncEventEmitter.call(this);
var settings;
var modules = new Map(); //Map fullName => Module
var instance = this;
var watchers;
var watchedFiles;
var autoloadModules = [];
process.chdir(path.dirname(module.parent.filename));
//Default options if not provided
settings = options || {};
watchedFiles = new Map();
watchers = new Map();
settings.restartOnChange = settings.restartOnChange === true;
if (settings.files === undefined) {
settings.files = [];
}
if (settings.exclude === undefined) {
settings.exclude = [module.parent.filename];
}
if (settings.basePath === undefined) {
settings.basePath = path.dirname(module.parent.filename);
}
if (settings.logLevel === undefined) {
settings.logLevel = 0;
}
/* process.on('uncaughtException', function (err) {
log ('error', 'Unhandled exception', err);
});
process.on('unhandledRejection', function(reason, p) {
log('error', 'Unhandled rejection at: Promise ', p, ' reason: ', reason);
});
*/
var applicationLoaded = false;
//Start the application
this.start = function () {
//Load application files
if (!applicationLoaded && settings.files && settings.files !== undefined) {
if (!Array.isArray(settings.files)) {
settings.files = [ settings.files ];
}
for (let file of settings.files) {
if (file instanceof Object) {
load (file);
}
else {
load ({ path : file });
}
}
applicationLoaded = true;
}
try {
//Load modules tagged as autoload
for (var mod of autoloadModules) {
loadModule(mod);
}
autoloadModules = [];
return instance.emit('start', get.bind(undefined, undefined));
}
catch (exception) {
log ('error', exception);
}
};
//Stop the application
this.stop = function () {
return instance.emit('stop', get.bind(undefined, undefined));
};
//Restart the application
this.restart = function () {
if (instance.hasListener('restart')) {
return instance.emit('restart', get.bind(undefined, undefined));
}
else if (instance.hasListener('stop')){
var promise = instance.stop();
if (promise && promise.then) {
promise.then(function() {
instance.start();
});
}
else {
instance.start();
}
}
};
//Log
function log (level, message) {
if (instance.hasListener('log')) {
instance.emit('log', get.bind(undefined, undefined), message);
}
else {
console.log('['+level+']', message);
if (level === 'error') {
if (message instanceof Error) {
throw message;
}
else if (message) {
throw new Error(message);
}
else {
throw new Error();
}
}
}
}
//Function passed to every modular module
function get (caller, name) {
var mod;
//Search for the module in caller namespace first
if (caller && modules.has (caller.namespace && caller.namespace.length > 0
? caller.namespace + '.' + name
: name)) {
mod = modules.get (
caller.namespace && caller.namespace.length > 0
? caller.namespace + '.' + name
: name);
}
else if (modules.has(name)) {
mod = modules.get(name);
}
//If the module as been found
if (mod) {
if (caller) {
if (mod.childrenOf === undefined) {
mod.childrenOf = [];
}
if (mod.childrenOf.indexOf(caller) < 0) {
mod.childrenOf.push(caller);
}
if (caller.parentOf === undefined) {
caller.parentOf = [];
}
if (caller.parentOf.indexOf(mod) < 0) {
caller.parentOf.push(mod);
}
}
return loadModule(mod);
}
else {
log ('error', 'Unresolved dependency ' + name);
}
}
function loadModule (mod) {
if (mod.module === undefined) {
mod.module = require(mod.fullPath);
}
if (mod.module instanceof Function) {
let instanceModule = mod.module(get.bind(undefined,mod));
return instanceModule ? instanceModule : mod.module;
}
else if (mod.module.onLoad instanceof Function){
let instanceModule = mod.module.onLoad(get.bind(undefined, mod));
return instanceModule ? instanceModule : mod.module;
}
else {
return mod.module;
}
}
//Register a new module
//file : path of the module
//name (optional) : override the full name of the module (namespace and name)
//override (optional) : if a module with the same full name is already registered, it's overriden
function register (options) {
var autoload = false;
var opts = options;
//Default options
if (opts.override === undefined) {
opts.override = false;
}
if (opts.liveReload === undefined) {
opts.liveReload = settings.liveReload;
}
opts.fullPath = path.isAbsolute(opts.path) ? opts.path : path.resolve(opts.path);
//If the file is excluded
if (settings.exclude.indexOf(opts.fullPath) >= 0) {
return;
}
if (opts.fullPath.endsWith('autoload.js')) {
autoload=true;
}
//Name parameter override module namespace and module name
if (opts.namespace === undefined && opts.name === undefined) {
//Every folder of the module path is a part of the namespace
opts.namespace = path.relative((opts.basePath || settings.basePath) , path.dirname(opts.fullPath))
.replace(/\./g,'')
.replace(/\//g,'.')
.replace(/^\.*/g,'');
//Name is the basename of the module
opts.name = path.basename(opts.fullPath, autoload ? '.autoload.js' : path.extname(opts.fullPath));
//Replace all . char in class name by _
if (opts.name.indexOf('.') >= 0) {
opts.name = opts.name.replace(/\./g,'_');
}
//Adding the base namespace
if (opts.baseNamespace !== undefined) {
opts.namespace = opts.baseNamespace.replace('/\./g','_') + '.' + opts.namespace;
}
}
//Calling the module loading hook
if (instance.hasListener('loadModule') && instance.emit('loadModule', opts) === false) {
return;
}
var fullName = opts.namespace && opts.namespace.length > 0 ? opts.namespace + '.' + opts.name : opts.name;
var alreadyLoaded = modules.has(fullName);
if (!alreadyLoaded || opts.override) {
if (alreadyLoaded && opts.fullPath !== modules.get(fullName).fullPath) {
//Delete overriden module cache
delete require.cache[modules.get(fullName).fullPath];
}
if (settings.logLevel > 0) {
log ('info', (fullName + ' loaded from ' + opts.fullPath));
}
opts.module = undefined;
//Clear the cache of the module if exists
if (require.cache[opts.fullPath]) {
delete require.cache[opts.fullPath];
}
//Autoload the module
if (autoload) {
autoloadModules.push(opts);
}
modules.set(fullName, opts);
if (settings.liveReload || opts.liveReload) {
watchedFiles.set(opts.fullPath, opts);
}
return true;
}
else if (settings.logLevel > 0) {
log ('info', fullName + ' skipped from ' + opts.fullPath);
}
return false;
}
//Load all the modules and submodules from a specified path
function load (options) {
if (!Array.isArray(options.path)) {
options.path = [options.path];
}
for (var file of options.path) {
var opts = clone(options);
var fullPath;
opts.path = file;
if (!path.isAbsolute(file)) {
fullPath = path.resolve(file);
}
else {
fullPath = file;
}
var stats = fs.statSync(fullPath);
if (stats.isFile()) {
if (settings.liveReload || options.liveReload) {
watch (fullPath);
}
register(opts);
}
else if (stats.isDirectory()) {
loadFolder(fullPath, opts);
}
}
}
//Load files from a path.
//Since it's only used before the application start, file operations are sync
function loadFolder (filePath, options) {
if (settings.liveReload || options.liveReload) {
watch(filePath);
}
var files = fs.readdirSync(filePath);
for (var file of files) {
var fullPath = path.join(filePath, file);
var stats = fs.statSync(fullPath);
if (stats.isFile && path.extname(fullPath) === '.js') {
let opts = clone(options);
opts.path = fullPath;
register(opts);
}
else if (stats.isDirectory()) {
loadFolder(fullPath, options);
}
}
}
//Watch a folder for changes
function watch(watchedPath) {
var reloadTimeout;
var modifiedFiles = [];
//Search for module already watched in this directory
var itWatch = watchers.keys();
var cur = itWatch.next();
var fileStats = fs.statSync(watchedPath);
var isFile = true;
if (watchers.has(watchedPath)) {
//Path is already watched
return;
}
else if (fileStats.isDirectory()) {
//Close all watchers aiming files in the directory
while (cur && cur.value) {
if (path.extname(cur.value) === '.js'
&& path.dirname(cur.value) === watchedPath) {
var watchedFile = watchers.get(cur.value);
watchedFile.close();
watchers.delete(cur.value);
}
cur = itWatch.next();
}
isFile = false;
}
else if (fileStats.isFile()){
//Target is a file in a directory already watched
var directory = path.dirname(watchedPath);
if (watchers.has(directory)) {
return;
}
}
//Watcher is used to detect file changes
var watcher = fs.watch(watchedPath, function (ev, fileName) {
var fullName = isFile ? watchedPath : path.join(watchedPath, fileName);
//Some editors like vim will actually rename the file, then modify it.
//If temporary file is used, the last event can be on a file that does not exists any more
//We listen for all the events until 1 second occurs without anything. Then we treat all modified files
if (modifiedFiles.indexOf(fullName) < 0) {
modifiedFiles.push(fullName);
}
if (reloadTimeout) {
clearTimeout(reloadTimeout);
}
reloadTimeout = setTimeout(function() {
reloadTimeout = undefined;
var stats;
for (let file of modifiedFiles) {
let exists = true;
//Check if the file exists
try {
stats = fs.statSync(file);
}
catch (err) {
exists = false;
}
var isModule = path.extname(file) === '.js';
var module = isModule ? watchedFiles.get(file) : null;
var fileWatcher = watchers.has(file) ? watchers.get(file) : null;
var needRestart = false;
var clearParentsCache = false;
if (exists) {
if (stats.isFile() && isModule) {
//This is a module
try {
if (module !== null) {
if (!module.override) {
module.override = true;
}
clearParentsCache = true;
}
register(module);
needRestart = settings.restartOnChange;
}
catch (exception) {
if (settings.logLevel >= 0) {
log ('error','Module '+file+ ' not reloaded : ' + exception );
}
}
}
else if (stats.isDirectory() && !watchers.has(file)) {
watch(file);
}
//Reset the watcher if needed
if (ev === 'rename' && fileWatcher !== null) {
fileWatcher.close();
watchers.delete(file);
watch(file);
}
}
else if (module !== null) {
unRegister(module);
if (settings.logLevel > 0) {
log ('info', module.fullName + ' unregistered');
}
clearParentsCache = true;
needRestart = settings.restartOnChange;
}
else if (fileWatcher !== null) {
fileWatcher.close();
watchers.delete(file);
}
if (clearParentsCache) {
if (module.childrenOf !== undefined) {
for (let parentMod of module.childrenOf) {
clearCache(parentMod);
}
}
}
if (needRestart) {
instance.restart();
}
}
modifiedFiles = [];
},1000);
});
watchers.set(watchedPath, watcher);
}
//Remove a module and clear its nodejs cache
function unRegister (module) {
if (modules.has(module.fullName)){
if (module.parentOf !== undefined) {
for (var children of module.parentOf) {
if (children.childrenOf !== undefined && children.childrenOf.length > 0) {
children.childrenOf.splice(children.childrenOf.indexOf(module), 1);
}
}
}
modules.delete(module.fullName);
delete require.cache[module.file];
}
}
//Clear module and all depedencies cache
function clearCache(module) {
if (module.childrenOf !== undefined) {
for (let parentMod of module.chidrenOf) {
clearCache(parentMod);
}
}
module.override = true;
register(module);
}
}
function clone(obj) {
var object = {};
for (let prop in obj) {
if (Array.isArray(obj[prop])) {
object[prop] = obj[prop].slice(0);
}
else {
object[prop] = obj[prop];
}
}
return object;
}
Modular.prototype = new SyncEventEmitter();
Modular.prototype.constructor = SyncEventEmitter;
module.exports = function (settings) {
return new Modular(settings);
};

39
lib/synceventemitter.js Normal file
View File

@@ -0,0 +1,39 @@
'use strict';
function SyncEventEmitter () {
this.listeners = new Map();
}
SyncEventEmitter.prototype.on = function (ev, callback) {
if (this.listeners.has(ev)) {
this.listeners.remove(ev);
}
this.listeners.set(ev, callback);
return this;
};
SyncEventEmitter.prototype.emit = function (ev) {
if (this.listeners.has(ev)) {
let params = [];
for (var i = 1 ; i < arguments.length ; ++i) {
params.push(arguments[i]);
}
return this.listeners.get(ev).apply(undefined, params);
}
};
SyncEventEmitter.prototype.removeListener = function (ev) {
if (this.listeners.has(ev)) {
this.listeners.remove(ev);
}
};
SyncEventEmitter.prototype.hasListener = function (ev) {
return this.listeners.has(ev);
};
module.exports = SyncEventEmitter;

24
package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "modular",
"version": "1.0.0",
"description": "Module path abstraction and mock framework",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "https://lab.cherboiche.org/boudin/modular.git"
},
"keywords": [
"module",
"mock",
"live",
"reload"
],
"author": "boudin",
"license": "GPL-3.0",
"devDependencies": {
"mocha": "^2.2.5"
}
}

View File

@@ -0,0 +1,4 @@
'use strict';
module.exports = function() {
throw new Error('autoloaded');
};

View File

@@ -0,0 +1,9 @@
'use strict';
module.exports = function (modular) {
return {
run : function () {
return 4;
}
};
};

View File

@@ -0,0 +1,9 @@
'use strict';
module.exports = function (modular) {
return {
run : function () {
return 5;
}
};
};

9
test/constructor/one.js Normal file
View File

@@ -0,0 +1,9 @@
'use strict';
module.exports = function (modular) {
return {
run : function () {
return 1;
}
};
};

9
test/constructor/two.js Normal file
View File

@@ -0,0 +1,9 @@
'use strict';
module.exports = function (modular) {
return {
run : function () {
return 2;
}
};
};

9
test/loading/module.js Normal file
View File

@@ -0,0 +1,9 @@
'use strict';
module.exports = function (modular) {
return {
run: function () {
return modular('module2').run();
}
};
};

9
test/loading/module2.js Normal file
View File

@@ -0,0 +1,9 @@
'use strict';
module.exports = function() {
return {
run : function () {
return 1;
}
};
};

9
test/loading/module3.js Normal file
View File

@@ -0,0 +1,9 @@
'use strict';
module.exports = function (modular) {
return {
run: function () {
return modular('nested.module4').run();
}
};
};

11
test/loading/module5.js Normal file
View File

@@ -0,0 +1,11 @@
'use strict';
var mod;
module.exports.onLoad = function (modular) {
mod = modular('module6');
};
module.exports.run = function () {
return mod.run();
};

9
test/loading/module6.js Normal file
View File

@@ -0,0 +1,9 @@
'use strict';
module.exports = function() {
return {
run : function () {
return 1;
}
};
};

View File

@@ -0,0 +1,9 @@
'use strict';
module.exports = function() {
return {
run : function () {
return 1;
}
};
};

View File

@@ -0,0 +1,9 @@
'use strict';
module.exports = function (modular) {
return {
run : function () {
return 3;
}
};
};

View File

@@ -0,0 +1,9 @@
'use strict';
module.exports = function (modular) {
return {
run : function () {
return 4;
}
};
};

244
test/test.js Normal file
View File

@@ -0,0 +1,244 @@
/*
* Unit tests for modular
*/
'use strict';
var modular = require('../lib/modular.js');
var assert = require('assert');
//Load a file without any options
describe('Modular', function() {
describe('Settings', function() {
it('load a single module', function() {
modular({
files : './constructor/one.js',
}).on('start', function(modular) {
assert.equal (1, modular('constructor.one').run());
}).start();
});
it('load a single module with settings', function() {
modular({
files : {
path: './constructor/one.js',
}
}).on('start', function(modular) {
assert.equal (1, modular('constructor.one').run());
}).start();
});
it('load two modules', function() {
modular({
files : [ './constructor/one.js', './constructor/two.js' ],
}).on('start', function(modular) {
assert.equal (1, modular('constructor.one').run());
assert.equal (2, modular('constructor.two').run());
}).start();
});
it('load two modules with settings', function() {
modular({
files : [ {
path: './constructor/one.js'
}, {
path: './constructor/two.js'
}],
}).on('start', function(modular) {
assert.equal (1, modular('constructor.one').run());
assert.equal (2, modular('constructor.two').run());
}).start();
});
it('load a directory', function() {
modular({
files : './constructor',
}).on('start', function(modular) {
assert.equal (1, modular('constructor.one').run());
assert.equal (2, modular('constructor.two').run());
}).start();
});
it('load a directory with settings', function() {
modular({
files : {
path: './constructor'
}
}).on('start', function(modular) {
assert.equal (1, modular('constructor.one').run());
assert.equal (2, modular('constructor.two').run());
}).start();
});
it('load a single module with namespace and name', function() {
modular({
files : {
path: './constructor/one.js',
namespace: 'namespace',
name: 'class'
}
}).on('start', function(modular) {
assert.equal (1, modular('namespace.class').run());
}).start();
});
it('load a directories with override', function() {
modular({
files : ['./constructor', {
path: './override',
basePath: './override',
override: true
}
]
}).on('start', function(modular) {
assert.equal (3, modular('constructor.one').run());
assert.equal (4, modular('constructor.two').run());
}).start();
});
it('load a directories with no override', function() {
modular({
files : ['./constructor', {
path: './override',
override: false
}
]
}).on('start', function(modular) {
assert.equal (1, modular('constructor.one').run());
assert.equal (2, modular('constructor.two').run());
}).start();
});
it('load nested directories', function() {
modular({
files : './constructor'
}).on('start', function(modular) {
assert.equal (4, modular('constructor.nested.four').run());
assert.equal (5, modular('constructor.nested.last.five').run());
}).start();
});
it('load directory with global base path', function() {
modular({
files : './constructor',
basePath : './constructor'
}).on('start', function(modular) {
assert.equal (1, modular('one').run());
assert.equal (2, modular('two').run());
}).start();
});
it('load directory with per directory base path', function() {
modular({
files : [{
path: './constructor',
basePath : './constructor'
},{
path: './override'
}]
}).on('start', function(modular) {
assert.equal (1, modular('one').run());
assert.equal (2, modular('two').run());
assert.equal (3, modular('override.constructor.one').run());
}).start();
});
it('load file with global base path', function() {
modular({
files : './constructor/one.js',
basePath : './constructor'
}).on('start', function(modular) {
assert.equal (1, modular('one').run());
}).start();
});
it('load file with per file base path', function() {
modular({
files : {
path: './constructor/one.js',
basePath : './constructor'
}
}).on('start', function(modular) {
assert.equal (1, modular('one').run());
}).start();
});
it('load file with base namespace', function() {
modular({
files : {
path: './constructor/one.js',
baseNamespace: 'namespace'
}
}).on('start', function(modular) {
assert.equal (1, modular('namespace.constructor.one').run());
}).start();
});
it('load directory with base namespace', function() {
modular({
files : {
path: './constructor',
baseNamespace: 'namespace'
}
}).on('start', function(modular) {
assert.equal (1, modular('namespace.constructor.one').run());
assert.equal (2, modular('namespace.constructor.two').run());
}).start();
});
it('autoload modules', function() {
assert.throws(function(){
modular({
files : {
path: './autoload',
}
}).start();
},
/autoloaded/);
});
it('exclude module', function() {
assert.throws(function(){
modular({
files : './constructor',
exclude: ['./constructor/two.js']
}).on('start', function(modular) {
modular('two').run();
}).start();
},
/Unresolved dependency two/);
});
});
describe ('Modules', function() {
it('load modules in the same namespace', function() {
modular({
files: './loading'
}).on('start', function (modular) {
assert.equal (1, modular('loading.module').run());
}).start();
});
it('load modules in sub namespace', function() {
modular({
files: './loading'
}).on('start', function (modular) {
assert.equal (1, modular('loading.module3').run());
}).start();
});
it('access modules through onLoad function', function() {
modular({
files: ['./loading/module5.js',
'./loading/module6.js' ]
}).on('start', function (modular) {
assert.equal (1, modular('loading.module5').run());
}).start();
});
});
});
/* A tester
* Live reload
* Autorestart
* Log
*/