JavaScript
Article

Replace Make with Jake

By Florian Rappl

The root of all innovation is laziness. This is especially true for the IT field where we are driven by process automation. A process that is particularly annoying, so it needs to be automated, is deployment. Deployment also includes the critical step of building a software, i.e. compiling and modifying the sources to have as a result a running application. At the beginning, people used a set of scripts to perform the same building process. Once the same set of scripts had to be copied and used again, it was obvious that a common system had to be created.

The software Make has been proven to be a very good solution for the problem. It’s flexible and follows a clear concept, but this flexibility comes at a price. Many of the great software innovations we are building cannot work with Make. We don’t have any extension or package, and an extensible configuration isn’t possible. To avoid these issues, the patterns of generating Makefiles, using external scripts, or having multiple Makefiles are quite common.

We should not have to fall back to an ancient tool-chain, just to have a working build system. We should embrace modern technology and a software stack that we know very well. In this article, I’ll introduce you to Jake. It combines the concept and the advantages of Make with a Node.js environment. This means we can use any module we like and that scripting is not only possible, but encouraged.

Specialized Task Runners vs Generic Build Tools

The idea of using a JavaScript environment for creating a build tool isn’t new. Every front-end developer today knows Grunt or Gulp. And in many scenarios, these tools should still be the primary choice. So the question is: Where should I use what tool?

For web-related tasks, such as minifying JavaScript files, prefixing CSS, or optimizing images, task runners are to be preferred. But even in such cases, Jake could be considered as an option because it’s a superset of the mentioned tools. It’s much less specialized, and there is nothing against using it in that context.

With this in mind, Jake is a better fit either if you want to replace another build tool such as Make or if you have another build process that follows the classic dependency-rule approach, an approach where we have a rule that specifies one to many dependencies. The beauty of a generic build tool is that it can be used in many contexts.

Before we discuss the advantages of Jake in details, it’s worth taking a look at Make and its brilliant concept.

A Look at Make

Every build system needs three things:

  1. Tools (either software or functions) to do the work
  2. Rules to specify what kind of work to do
  3. Dependencies to specify what kind of rules to apply

The work is usually a transformation of a source file into another file. Basically, all the operations in such build system are immutable, which gives us maximum agility and predictability.

Jake

The Node.js ecosystem features many great modules that enhance the user’s terminal experience. This is especially handy for a build tool. Due to legacy (and simple) DOM operations, JavaScript is a very string-focused language. This plays really well together with the Unix command line philosophy. But there is another reason why Jake is better than its competitors: special functions for testing and watching file changes are already integrated.

Jake wraps the rule-dependency approach in a hierarchy called tasks. These tasks can run in parallel and will invoke events that can be used to control the flow despite of concurrency. The tasks can be clustered in groups like rule, file, directory, package, publish, test, and watch. These are more than enough options to create truly useful build processes that are highly flexible and do exactly what we want. Most notably, watch tasks give us the ability to invoke some actions such as running the build process once certain files or directories have changed.

Like other build tools, Jake is using a special kind of file to describe the build process. This file is called Jakefile and use Jakefile.js as its default name. However, a short list of other names, such as Jakefile, can be also used and they are automatically recognized. It’s also possible to use custom file names, but in this case you have to specify the name of the file used explicitly.

A Jakefile is a file that includes required modules, defines all tasks, and sets up some rules. To apply some structure to our tasks we may also use a special construct called namespace. We won’t go into namespaces in this article, but the concept itself may be useful to reduce the potential chaos for larger Jakefiles.

A Sample Jakefile to Compile an Application

Before we start with a sample Jakefile, we must have Jake installed. The installation is straightforward if you use npm as you only need to enter the command:

npm install -g jake

The example I’m going to explain is a little bit long, but it’s close to a real-world code and illustrates several important concepts. We’ll go over all the lines by taking a glimpse at each block. We’ll pretend to compile some C++ application, but the example does not require any knowledge about C++.

The first line of the file is:

var chalk = require('chalk');

Here we are including a Node.js module called “chalk”. chalk is a very useful tool for coloring the terminal output and it should definitely be a part of most Jakefiles.

As already mentioned we can make full use of the Node.js ecosystem. So, in the next section we specify some constants that are important to have more flexibility. If we use JavaScript, we have to use it correctly.

var sourceDirectory = 'src';
var outputDirectory = 'bin';
var objectDirectory = 'obj';
var includeDirectory = 'include';
var applicationName = 'example';
var isAsync = { async: true };

The next lines also define some constants but this time we also allow external arguments to override our own definitions. We don’t want to rewrite the build process just to try out another compiler, or to specify different flags. Using these arguments is possible via the process.env object as shown below:

var cc = process.env.cc || 'g++';
var cflags = process.env.cflags || '-std=c++11';
var options = process.env.options || '-Wall';
var libs = process.env.libs || '-lm';
var defines = process.env.defines || '';

Now the real deal starts. We use the jake.FileList constructor function to create a new file list, which includes all files having .cpp as their extension in the directory of all source files. This list is then used to create a similar file list with all object files. These files might not exist at that point, but this is no much of a problem. In fact, we don’t use the file retrieval for specifying the list of object files but some JavaScript mapping from the existing file list represented as an array. The code implementing this description is shown below:

var files = new jake.FileList();
files.include(sourceDirectory + '/*.cpp');
var target = outputDirectory + '/' + applicationName;
var objects = files.toArray().map(function(fileName) {
  return fileName
           .replace(sourceDirectory, objectDirectory)
           .replace('.cpp', '.o');
});

Then, a few handy utilities come into play. We define functions for the output, such as plain information or warnings:

var info = function(sender, message) {
  jake.logger.log(['[', chalk.green(sender), '] ', chalk.gray(message)].toMessage());
};

var warn = function(sender, message) {
  jake.logger.log(['[', chalk.red(sender), '] ', chalk.gray(message)].toMessage());
};

Once done, we set up a regular expression for consuming all object files. Later, we will use this as a condition for our rule to create an object file from a source file. We also define a function that will be used to convert the proper object file name back to its corresponding source file name:

var condition = new RegExp('/' + objectDirectory + '/.+' + '\\.o$');
var sourceFileName = function(fileName) {
   var index = fileName.lastIndexOf('/');
   return sourceDirectory + fileName.substr(index).replace('.o', '.cpp');
};

We are already down in the rabbit hole. Now we need to define two functions that serve as the access points for doing some real work:

  • Linking existing object files together. They form an executable in the given scenario
  • Compiling a source file to an object file

These two functions use the provided callback. The callback will be passed to the jake.exec function which is responsible for running system commands:

var link = function(target, objs, callback) {
   var cmd = [cc, cflags, '-o', target, objs, options, libs].join(' ');
   jake.exec(cmd, callback);
};

var compile = function(name, source, callback) {
   var cmd = [cc, cflags, '-c', '-I', includeDirectory, '-o',
              name, source, options, '-O2', defines].join(' ');
   jake.exec(cmd, callback);
};

In the next snippet, two crucial parts of a Jakefile are revealed:

  1. We set up a transformation rule to create object files from source files. We use the previously defined regular expression and function to get all requested object files with their corresponding source files. Besides, we annotate this to be able to run asynchronously. So, we can run multiple source-to-object file creations in parallel. In the callback we close the rule by calling the built-in complete method
  2. We define a file rule that creates a single target from multiple dependencies. Once again, the function is marked as being able to run asynchronously. Using the jake.mkdirP method we make sure that the directory for storing the output does exist, otherwise it is created.

With these two kinds of rules we are able to set up some tasks. Tasks are rules that can be accessed from the build tool via the command line.

rule(condition, sourceFileName, isAsync, function() {
   jake.mkdirP(objectDirectory);
   var name = this.name;
   var source = this.source;
   compile(name, source, function() {
      info(cc, 'Compiled ' + chalk.magenta(source) + ' to ' +
           chalk.magenta(name) + '.');
      complete();
   });
});

file(target, objects, isAsync, function() {
   jake.mkdirP(outputDirectory);
   link(target, objects, function() {
      info(cc, 'Linked ' + chalk.magenta(target) + '.');
      complete();
   });
});

Finally, we set up three tasks. One to create the documentation, another to compile the application, and a default task that is executed when jake is invoked on the command line without any argument. The default task has the special name default and it relies on the other two defined tasks. The documentation task is empty on purpose. It only exists to illustrate the concept of multiple tasks.

desc('Creates the documentation');
task('doc', [], isAsync, function() {
   info('doc', 'Finished with nothing');
});

desc('Compiles the application');
task('compile', [target], isAsync, function() {
   info('compile', 'Finished with compilation');
});

desc('Compiles the application and creates the documentation');
task('default', ['compile', 'doc'], function() {
   info('default', 'Everything done!');
});

Running a special task like compile is possible by running jake compile on the terminal. All the defined tasks and their respective descriptions are shown by running the command jake -ls.

Conclusion

Jake is a powerful build tool that should be installed on every computer equipped with Node.js. We can leverage our existing JavaScript skills to create seamless build scripts in an efficient and lightweight manner. Jake is platform-independent and uses the best features from a long list of possible build tools. Besides, we have access to any Node.js module or other software. This includes specialized task runners that solve the issue of creating front-end build processes.

Recommended
Sponsors
Because We Like You
Free Ebooks!

Grab SitePoint's top 10 web dev and design ebooks, completely free!

Get the latest in JavaScript, once a week, for free.