If you’ve decided to stick around, let’s start exploring creating our very own task runner.

Motivation

There are various options when it comes to running tasks. Use task runner X or Y, use npm run-scripts, use Make etc.

To be honest, after trying out different things, I thought “why not explore creating my own CLI task runner with node?”

How hard can it really be to create a node CLI tool that will run node scripts for me? Can it be simple enough to do that whilst giving me some nice to haves?

Desired features

Based on the experience of using other solutions, I came up with a list of features I’d like for my task runner to have and things I’d like my task runner to be;

It just does what you tell it. Nothing extra.

Is it even a task runner? It’s more like a node scripts runner. I don’t want it to do anymore than it has to. Take care of watching and writing to the filesystem within tasks.

A specific file for defining things like package/plugin options and source paths, a runtime configuration file.

A specific folder for storing task files.

Pre and post hooks for tasks that work recursively.

Use npm modules directly so you never have the issue of being tied to a task runner specific ecosystem.

Ability to run tasks in sequence or concurrently.

Ability to pass runtime variables to script runner.

Task profiling so I can see how long things are taking.

Self documented tasks so that I don’t have to crawl through task code to work out what’s going on in certain tasks. I should be able to see what tasks I have and what understand what they do without opening any task source.

Design

The important part about creating our own task runner is making it easy to use. Design is crucial to how it works. There’s room for improvement, but this was my first take at the design.

CLI

The CLI is going to be pretty simple. We want to be able to do things like;

$ task-runner task:a

$ task-runner task:a task:b

$ task-runner task:a --env dist

In order;

Run “task:a”. Run “task:a” and “task:b” concurrently. Run “task:a” with environment variable as “dist”.

But how about self documentation? Some popular task runners have a default task feature. For the first iteration, I’m avoiding the default task. My default task will be to show info about the available tasks;

$ task-runner

Could give me the following output;

Available tasks to run: task:a - does some magical stuff to get the project running

task:b - does some other stuff, not so magical I'm afraid

Maybe a different approach could be to actually implement the default task as people are familiar with that and use an option to show available tasks like Gulp does with “-T”. Maybe something like;

$ task-runner --info

Tasks

Tasks are going to be node scripts that will be consumed by our CLI tool. I want to keep things minimal. It’s important to make it easy to migrate from using any current node scripts and vice-versa if we decide the script runner isn’t for us. Based on this and our desired features list, my first idea was to expose objects or an array of objects from task files. This is so that we can have “one to many” tasks defined per file and keep a good separation of concerns. The initial design for tasks is as follows;

module.exports = {

name: 'task:a',

doc : 'some task that does something',

pre : 'task:b',

post: 'task:b',

deps: [

'fs',

'stylus'

],

func: (fs, stylus, runner) => {

/* Do stuff and resolve/reject a promise */

}

};

Most of the object keys/properties are pretty self-explanatory. “name” and “doc” name our task and give it a description respectively. “pre/post” defines a pre/post hook where a defined task will be ran either before or after the task we are looking to run.

The “deps” key is where it gets a little different. One factor in the design was to try and reduce the need to have to write the following multiple times;

const somePackage = require('somePackage');

My initial idea was to define an array of dependencies that are then passed to the task “func” function. Maybe a better solution could be something like Gulp’s “gulp-load-plugins” that exposes an object for us to access each module we desire? This can be simply changed. The only issue is whereby something like “gulp-load-plugins” looks for Gulp ecosystem packages, how do we filter out the things we actually need? Maybe define it in our runtime configuration file?

Lastly, let’s take a closer look at the “func” property. This defines a function for our task logic.

func: (fs, stylus, runner) => {}

There’s an extra parameter! “runner” isn’t one of our dependencies. That’s on purpose. This is a special object that exposes elements of our task runner to our task logic. Why? The idea behind the design is to rely quite heavily on Promises so that we can have tighter control over when things finish and when other things start. When we have a Promise, we need to able to resolve or reject it and this needs to happen within our task logic;

func: (fs, package, runner) => {

var result = package.compile('src/scripts/**/*.*');

if (!result) runner.reject('Something went horribly wrong');

runner.resolve();

}

What else can we pass in this object?

How about the content of our runtime configuration file? In here we can store things like;

module.exports = {

sources: {

scripts: 'src/scripts/**/*.*

}

};

Instead of repeatedly writing our source path strings we access them with the “runner” param like so;

func: (fs, package, runner) => {

var result = package.compile(runner.config.sources.scripts);

if (!result) runner.reject('Something went horribly wrong');

runner.resolve();

}

And logging. It felt right to also expose the runners logging instance to be used within tasks;

func: (fs, package, runner) => {

var result = package.compile('src/scripts/**/*.*');

if (!result) runner.reject('Something went horribly wrong');

runner.log.success('Finished');

runner.resolve();

}

We can also pass the any environment variables that have been set.

Lastly and importantly, we need to pass the ability to tell our task runner instance to run a task. This is particularly useful when doing things like watching for file changes and triggering tasks. We can do this by passing a bound instance of our task runner functionality(examples can be seen later on)