This days I’m working on some web apps that are expected to evolve over time. Where new modules (or functionality) must be easily added without impact on existing code, also users must be able to select and customize the functionality they want. Think in popular CMS like Drupal, Liferay and so on and you’ll get an idea.

I’ve found a lot of examples of evolvable architectures in AngularJS and the module system allows me to create isolated components easily. But in all examples you need code changes to include new components or functionality and new deployments of the entire application to your production server.

What I’m going to outline here is an initial idea of how a plugin based application can be developed in pure HTML5 and javascript. In the hope somebody finds it interesting or have some comments to share on the idea.

This have several advantages, apart from the obvious gains, development is greatly simplified because you can work on every component in isolation and integration with the rest of the application is automatic, allowing you to adopt a continuous integration approach to web development easily. Also with this architecture is very easy to generate new applications by setting different sets of plugins inside the same architecture blueprint.

Let’s begin:

Application is extended by adding “plugins” a plugin is a set of HTML and javascript files that define an angular module and provide some new functionality, for example in a dashboard app a plugin can add new kind of visualizations.

Creating this plugins is easy if you use the angular module API, in fact I believe most of non trivial angular apps use this approach. Also creating adaptive UI elements that can vary based on the modules loaded (for example a top level navbar to access all visualizations) is very easy. Here’s an example of creating one of this plugins and a directive to define a navbar whose items are dependent of the loaded modules.

angular.module('DashboardAppUIModule',['ui.bootstrap']) .directive("extNavBar", function () { return { restrict: "E", templateUrl: "./extensionModules/ui/directives/ExtensibleNavBarTemplate.html", replace: true, transclude: false } }); <nav class="navbar"> <div class="navbar-inner"> <ul class="nav nav-list"> <li><a href="#/home"> <i class="icon-home"></i></a></li> </ul> <ul class='nav nav-list' ng-repeat='module in modules'> <li ><a href='#/{{module.moduleNamespace}}/main'> {{module.moduleLabel}}</a></li> </ul> <form class="navbar-search pull-right"> <input type="text" class="search-query" placeholder="Search"> </form> </div> </nav>

Nothing special. There is only one interesting part, the modules property that we use to generate the menu items. We’re going to see later how is defined. For the moment just knowing that it holds the list of plugins available for the application is enough.

So far so good, but now we have two problems to use our angular modules as plugins. First, an angular application must be bootstrapped knowing which modules to load. That is, there’s no possibility to load any module after the application is bootstrapped and so we need to know the list of modules (or plugins) in advance. The second is more subtle, having several plugins with several js files defining and configuring angular modules sounds good but load order is very important. We can’t add a directive to a non existing module so we need a way to specify load order of the plugins js files or in other words, we need a dependency management system.

The first problem is easily solved with some metadata, we can use a json configuration file, and load it from a web service (or from whatever you want) on application start up. (Personally I plan to use the user login service to also retrieve this info)

[ { "moduleName": "DashboardAppUIModule", "moduleLabel": "ui", "moduleNamespace": "ui", "moduleStates": [ { "stateName": "ui", "abstract": true, "url": "/ui", "templateUrl": "./extensionModules/ui/views/main.html" }, { "stateName": "ui.main", "abstract": false, "url": "/main", "templateUrl": "./extensionModules/ui/views/ui.main.html" }, { "stateName": "ui.title", "abstract": false, "url": "/title", "templateUrl": "./extensionModules/ui/views/ui.title.html" } ], "moduleDependencies": null, "moduleExports": ["ui/module", "ui/directives/ExtensibleNavBarDirective"], "scriptDependencies": null }, { "moduleName": "DashboardAppWidgetModule", "moduleLabel": "widgets", "moduleNamespace": "widgets", "moduleStates": [ { "stateName": "widgets", "abstract": true, "url": "/widgets", "templateUrl": "./extensionModules/widgets/views/main.html" }, { "stateName": "widgets.main", "abstract": false, "url": "/main", "templateUrl": "./extensionModules/widgets/views/widgets.main.html", "controller": "WidgetCtrl" } ], "moduleDependencies": null, "moduleExports": ["widgets/module", "widgets/controllers/WidgetsController"], "scriptDependencies": null } ]

The file format is pretty simple (and subject to change, comments and suggestions):

moduleName If this plugin defines an angular module this value holds the module name

moduleLabel Similar to the toString method this is a string used in the UI to reference this plugin, for example in a navbar

moduleNamespace The plugin namespace, and also the phisiycal location of the plugin files inside the extensionModules directory of the application

moduleStates The list of ui-router states this plugin contributes to the application.

moduleDependencies The list of angular modules this plugin depends on

moduleExports The list of js files (defining angular modules and components) that this plugin contributes to the application

scriptDependencies The list of js files this module depends on

Now to load this configuration and access relevant properties we can use jQuery :

var modules = []; var statesToConfigure = []; var modulesToLoad = []; var scriptsToLoad = []; //Generates a string array with all the needed modules to load function generateModulesToLoad(globalDependencies) { for (var i = 0; i < modules.length; i++) { var moduleDependencies = modules[i].moduleDependencies; var moduleName = modules[i].moduleName; if (moduleName !== null) { globalDependencies.push(moduleName); } if (moduleDependencies !== null) { globalDependencies = _.union(globalDependencies, moduleDependencies); } } return globalDependencies; }; var init = function () { jQuery.ajax({ url: "/modules.json", dataType: 'json', async: false, success: function (data) { modules = data; modulesToLoad = generateModulesToLoad(['ui.bootstrap', 'ui.compat', 'ui']); statesToConfigure = generateStatesToConfigure(); scriptsToLoad = generateScriptsToLoad(); }, error: function (xhr, ajaxOptions, thrownError) { alert(xhr.status); alert(thrownError); } }); } function generateStatesToConfigure() { .... } function generateScriptsToLoad() { .... } init(); return { scriptsToLoad: scriptsToLoad, statesToConfigure: statesToConfigure, modulesToLoad: modulesToLoad, modules: modules }

Now bootstrap our application:

function initializeApp(angular) { var application = angular.module('DashboardApp', definitionsLoader.modulesToLoad) .config(['$stateProvider', '$urlRouterProvider', function ($stateProvider, $urlRouterProvider) { //When no route redirect to home $urlRouterProvider.when('', '/home'); // if the path doesn't match any of the urls you configured // otherwise will take care of routing the user to the specified url $urlRouterProvider.otherwise('/home'); $stateProvider.state('home', { url: '/home', templateUrl: './views/main.html', controller: ['$scope', function ($scope) { .... }] }); var states = definitionsLoader.statesToConfigure; for (var i = 0; i < states.length; i++) { var state = states[i]; $stateProvider.state(state.stateName, {url: state.url, abstract: state.abstract, templateUrl: state.templateUrl, controller: state.controller}); } }]).run(['$rootScope', function ($rootScope) { $rootScope.modules = definitionsLoader.modules; }]); //Now bootstrap application angular.bootstrap(document, ['DashboardApp']); };

Pretty simple code, nothing special, we simply iterate over the list of configured plugins and set up modules and states in a config block , also I set the list of modules in the rootScope to make it accessible to application logic (like directives or controllers).

Now the only remaining problem is to make sure that scripts are loaded in the correct order and dependency management in general, for that I can use RequireJS.

First step, define a Requirejs configuration for our common libraries and set the baseUrl to the plugin folder of our application (named extensionModules in my case):

require.config({ baseUrl: 'extensionModules', paths: { "jQuery": "/bower_components/jquery/jquery.min", "jQueryUI": "/bower_components/jquery-ui/ui/minified/jquery-ui.min", "underscore": "/bower_components/underscore/underscore-min", "angular": "/bower_components/angular/angular.min", "angular.bootstrap": "/bower_components/angular-bootstrap/ui-bootstrap-tpls.min", "angular.ui": "/bower_components/angular-ui/build/angular-ui.min", "ui.router": "/bower_components/ui-router/release/angular-ui-router.min" }, shim: { "jQuery": { exports: "jQuery" }, "jQueryUI": { deps: ["jQuery"] }, 'underscore': { exports: '_' }, 'angular': { deps: ['jQuery', 'jQueryUI'], exports: 'angular' }, 'ui.router': { deps: ['angular'] }, "angular.bootstrap": { deps: ['angular'] }, "angular.ui": { deps: ['angular', 'jQueryUI'] } } }); require(['/scripts/appLoader.js']);

Now transform the code to load the configuration file and boostrap the application to be requirejs compatible:

definitionsLoader.js:

define(['jQuery', 'underscore'], function (jQuery, _) { var modules = []; var statesToConfigure = []; var modulesToLoad = []; var scriptsToLoad = []; //Generates a string array with all the needed modules to load function generateModulesToLoad(globalDependencies) { .... }; var init = function () { jQuery.ajax({ url: "/modules.json", dataType: 'json', async: false, success: function (data) { modules = data; modulesToLoad = generateModulesToLoad(['ui.bootstrap', 'ui.compat', 'ui']); statesToConfigure = generateStatesToConfigure(); scriptsToLoad = generateScriptsToLoad(); }, error: function (xhr, ajaxOptions, thrownError) { alert(xhr.status); alert(thrownError); } }); } function generateStatesToConfigure() { ... } function generateScriptsToLoad() { .... } init(); return { scriptsToLoad: scriptsToLoad, statesToConfigure: statesToConfigure, modulesToLoad: modulesToLoad, modules: modules } })

appLoader.js

define(['require','jQuery', 'jQueryUI', 'underscore', '/scripts/definitionsLoader.js', 'angular', 'angular.bootstrap', 'ui.router','angular.ui','highcharts.more'], function (require,jQuery,jQueryUI,_,definitionsLoader,angular) { require(definitionsLoader.scriptsToLoad,function(){ initializeApp(angular); }); function initializeApp(angular) { ... }; })

Not much to see we simply wait to boostrap the application till all general libs and plugin modules are loaded, we need a nested require call because we can’t access plugin metadata until definitionsLoader.js is loaded.

We’re practically done, now we only have to transform our plugin js files to requirejs modules, this ensures that the plugins files are loaded in the proper order,

define(['angular'],function(angular){ return angular.module('DashboardAppUIModule',['ui.bootstrap']); }); define(['angular','ui/module'],function(angular){ angular.module('DashboardAppUIModule').directive("extNavBar", function () { return { restrict: "E", // directive is an Element (not Attribute) templateUrl: "./extensionModules/ui/directives/ExtensibleNavBarTemplate.html", replace: true, // replace original markup with template transclude: false // do not copy original HTML content}); } }); })

And that’s all, I’m aware that I’m loosing lazy loading of js files (all modules are loaded at startup), but is something I can’t avoid with the current angular capabilities (until lazy loading of modules is implemented).

Adding new functionality is as simple as updating the configuration file. Given the fact that I plan to integrate the metadata retrieval with the user login backend service I believe it’s pretty simple to create a plugin to interact with that service to allow application’s users to configure plugins in a simple way. Also keep in mind that new plugins can be easily deployed, even to a production app, because it implies only copy new files into a new folder.

If you think this is interesting, or have a comment (even bad ones) please, please please tell me.

If you want some code please check my bitbucket repository. Try to add new plugins or modify existing ones, and modify the modules.json file to see how plugins are loaded (or not).