actionhero javascript node.js
2016-07-25T16:33:41.348Z
↞ See all posts
Over the past few months, I’ve been working on projects which grew to become ActionHero Plugins. ActionHero is a Node.js framework for making API servers. ActionHero features a rich plugin system which allows developers to include pre-built tools and packages to extend their servers. Plugins can provide all of the functionality a top-level project can, including actions, background tasks, initializers, and even static files to be served via HTTP.
You can learn more about plugins from the ActionHero documentation.
Two of the more complex plugins I’ve built recently:
This post is to share some of the patterns I’ve come to use when developing and testing these plugins.
An ActionHero plugin is really just a collection of normal ActionHero components (tasks, actions, etc) which are injected into the top-level project at runtime.
To this end, it’s important to remember that this injection can be destructive, and you should namespace *everything* to avoid collisions. For example, if I want to have a status action in my plugin to report on something specific to the plugin, I really should name that action plugin:status. This way, I’ll avoid clobbering something at the top level of the project. This similarly applies to tasks, and even static files.
The way actionhero serves static files is that it first looks for the asset, say "/resque/index.html" in the public folders defined in your project directly (via api.config.general.paths.public) Then, if it doesn’t find that file, it starts looking in your linked plugins. Here again, I’ve namespaced the assets needed by this plugin via a route prefix to avoid clobbering anything with the top level project. You can see a good example of this in the public folder of ah-resque-ui.
Finally, plugins can have config files. When you run actionhero link to link a new plugin to your project, any files in the plugin’s config directory are copied to your top-level project. In this way you can have defaults for the plugin (whatever settings are in the config file to begin with), and the developer including your plugin can modify these easily. Here again, be sure to use a unique namespace as as part of the api.config object.
Just because an ActionHero plugin is a collection of normal ActionHero components, that doesn’t mean that is *all* it has to be. Take a look ah-elasticsearch-orm. At the end of the day the plugins main job is to expose api.elasticsearch to your project, but to do so, we have a robust *lib* directory to build up many parts of what that initializer will do.
1// From ah-elasitcsearch-orm/initializers/ah-elasticsearch-orm.js 2 3module.exports = { 4 loadPriority: 100, 5 startPriority: 100, 6 stopPriority: 999, 7 8 initialize: function(api, next){ 9 var client = require(__dirname + '/../lib/client.js')(api); 10 var search = require(__dirname + '/../lib/aggregate/search.js')(api); 11 var mget = require(__dirname + '/../lib/aggregate/mget.js')(api); 12 var count = require(__dirname + '/../lib/aggregate/count.js')(api); 13 var scroll = require(__dirname + '/../lib/aggregate/scroll.js')(api); 14// ...
Since node makes it easy for us to reference local files (via __dirname), we can consider files in this lib to "private" to the plugin, and only what we expose to the api object will be "public".
You note that every sub-file within the lib directory is loaded as part of the initialize step of the initializer. This is so the API object will be passed in, and we can then subsequently pass it to our sub-files. The module.exports for each file in the library exposes a single loader function with accepts the API object to bring it in scope, for example:
1// from ah-elasitcsearch-orm/lib/client.js 2var elasticsearch = require("elasticsearch"); 3 4module.exports = function (api) { 5 return function () { 6 return new elasticsearch.Client({ 7 hosts: api.config.elasticsearch.urls, 8 log: api.config.elasticsearch.log, 9 }); 10 }; 11};
In this was, the elasticsearch package itself is private to this file, we can expose only a constructed client, and still read various configuration details from the normal API object.
There is only one special api method ActionHero exposes for use with plugins, and that is api.routes.registerRoute(). This method allows for route injection of actions you have defined in your middleware.
Configuring routes is the job of the top-level ActionHero project, but if your plugin defines many actions, it would be a pain to require the developer using your plugin to add all of your actions to the proper routing table. api.routes.registerRoute allows you do this programatically. Again, be sure to namespace your routes!
1api.routes.registerRoute("get", "/resque/locks", "resque:locks");
You can see a good example of this in ah-resque-ui’s initializer.
One interesting challenge when building plugins is dealing with middleware. For example, ah-resque-ui creates some fairly sensitive actions (delete all enqueued tasks, for example). We know that the top-level project should secure these actions, but we have no idea how. Do they have a user + session system? Will they limit access to only a certain IP address?
We can assume that they will be using an action middleware to enable the protection they need… and we can proxy that in our plugin!
1// from ah-resque-ui/initializers/ah-resque-ui.js 2var middleware = { 3 "ah-resque-ui-proxy-middleware": { 4 name: "ah-resque-ui-proxy-middleware", 5 global: false, 6 preProcessor: function (data, callback) { 7 return callback(); 8 }, 9 }, 10}; 11 12if (api.config["ah-resque-ui"].middleware) { 13 var sourceMiddleware = 14 api.actions.middleware[api.config["ah-resque-ui"].middleware]; 15 middleware["ah-resque-ui-proxy-middleware"].preProcessor = 16 sourceMiddleware.preProcessor; 17 middleware["ah-resque-ui-proxy-middleware"].postProcessor = 18 sourceMiddleware.postProcessor; 19} 20 21api.actions.addMiddleware(middleware["ah-resque-ui-proxy-middleware"]);
Here you can see that we build up a new middleware here, but it contains a no-op preProcessor. In the config file generated for this project, we ask for the string name of another middleware (api.config[‘ah-resque-ui’].middleware), and if it is defined, we then reference it’s already defined pre and posProcessors.
The only trick here is that our load priority must be high enough to ensure that the top-level project’s initilizers have already fired so the original middleware will be in scope.
All good software needs tests, and ActionHero plugins are no exception. However… how do you test something that needs to be required within a larger project to run? Well, we can to just that in our test suite… it’s not that hard!
Please look at the specHelper from ah-elasticsearch-orm to see how this is done.
To Build a testing server:
When using Mocha to run your tests, you can build a specHelper file which knows how to prepare your test suite, and export it. Then, every subsequent test requires the spec helper meaning that the helper methods you just defined are in scope:
1var async = require("async"); 2var should = require("should"); 3var specHelper = require(__dirname + "/specHelper.js").specHelper; 4var api; 5describe("ah-elasticsearch-orm", function () { 6 describe("framework", function () { 7 before(function () { 8 api = specHelper.api; 9 }); 10 it("server booted and normal actions work", function (done) { 11 api.specHelper.runAction("status", function (response) { 12 response.serverInformation.serverName.should.equal( 13 "my_actionhero_project", 14 ); 15 done(); 16 }); 17 }); 18 it("has loaded cluster info", function (done) { 19 should.exist(api.elasticsearch.info.name); 20 var semverParts = api.elasticsearch.info.version.number.split("."); 21 semverParts[0].should.be.aboveOrEqual(2); 22 done(); 23 }); 24 }); 25});
This means you can write simple tests like the above, use ActionHero’s built in specHelper to run tasks and actions inline… and generally have a good testing experience.
As building the temporary project might be slow, you can also add an environment variable to skip that part if you’ve done it once already, IE: SKIP_BUILD=true npm test.
Note: You don’t need to require ActionHero as a devDependancy in your package.json.
And that is how I build & test ActionHero Plugins!
I write about Technology, Software, and Startups. I use my Product Management, Software Engineering, and Leadership skills to build teams that create world-class digital products.
Get in touch