The Universal Makefile for JavaScript

8 minute read

TL;DR The world is moving from Gulp and Grunt towards npm scripts. This seems like a place we could stay for a long time. But there's one shortcoming with npm scripts, especially when compared to Makefile: it's a sub-optimal command-line experience. So I wrote Fakefile: a universal Makefile that you can save into any Node project to offer your npm scripts as Makefile targets. This makes operating npm scripts ten times faster, and offers a polite language agnostic way into your project to people coming from non-js backgrounds. Just type npm install fakefile and profit instantly.

With ES6, JSX, linting, bundling, asset building, testing and deploying, task automation has become an important part of any JavaScript project. It is, however, also a topic capable of causing headaches.

Just when you managed to codify all your projects' tasks into Grunt, you were tempted by Gulp. Then Broccoli.js came along to seduce you and now you are also considering Mimosa.

By picking one, you risk alienating contributors who picked another. It is a safe assumption that the investment of buying into Gulp or another JS task running ecosystem today, will vaporize in large part before the year is over, when the frontrunners of the community start settling on something shinier. Just like with the struggle of selecting the best bundler or framework, this can lead to choice anxiety and JavaScript fatigue.

I have invested in both Grunt and Gulp for a few projects and noticed that people coming from Gulp avoided touching Gruntfiles. I would assume Broccoli folks will tend to avoid dealing with Gulpfiles.

Another thing I noticed happening was that complexity would creep up, resulting in these files quickly turning into over-engineered and untestable behemoths, overly susceptible to bit rot.

I started to long for something simpler, something not so easily affected by the ever changing winds of JavaScript fashion. Before JavaScript task runners, I primarily used Makefiles for task automation and I considered revisiting that solution.

Longing for Makefile

Makefiles are primarily used for building software on unix, but they are also great task runners. They aren't tied to any particular language and offer developers coming from a wide variety of languages a polite way into your project. Makefile snippets can be shared across repositories in Bash, JavaScript, Ruby and so forth. Visit a project, type make start, make build, make test or make deploy and the expected thing will happen, no matter what language the project was written in.

Autocompletion offered by Makefiles is instant. Press <TAB> and you'll quickly learn about the less obvious command-line features that the project offers. Discovering new projects and revisiting old ones becomes much more enjoyable when you don't have to brush up on the docs or rely on your meat-brain's fading memory. Autocomplete is like a torch, lighting your way as you're venturing deeper into the crypts of a project. A minimalistic form of documentation that is less likely to be outdated than the actual one and it's right there to assist you when you need it without context switching.

Makefiles do have their downsides though. For one, the learning curve is steep. Even the finest of tutorials can take an entire rainy Sunday afternoon to digest. From recently starting a new open source project, Uppy, I learned that especially front-enders, as well as developers coming from non-unix backgrounds, actually regard the choice for Makefiles to be impolite. They may not have spent that rainy Sunday afternoon. They may not even have make installed and it's a drag to get that to happen on Windows.

It made me wonder if I really wanted to introduce a barrier of entry like that.

We tried npm scripts

We had been reading about people successfully using npm scripts for task automation and decided to kick the tires on that. There turned out to be many advantages.

It is safe to assume that npm scripts will be around for as long as Node.js will be and that your npm scripts will be able to run on any platform that Node.js runs on.

It's convenient that if you type eslint in an npm script, npm will automatically look whether you have this package as a local dependency in ./node_modules and run that before checking any global installs. Not having to require or type the paths to your modules keep the tasks themselves compact.

Your tasks, as well as anything they rely on, are codified in a single package.json. This makes locking down dependencies as convenient as it is potent, increasing the likelihood of your tasks still working correctly years from now. More and more packages allow defining their configuration inside package.json as well, as opposed to inside their own rc files. This can further help to reduce the number of moving parts.

For bigger task, we can either write separate Node modules, or shell out to ./scripts. I have found that carefully crafting your tasks and utilizing the vast ecosystem that is npm, helps to avoid this becoming a common thing. Another advantage of plugging in directly to npm's ecosystem is that we don't have to wait on a gulp-postcss plugin to become available before we can try postcss.

For instance, the things I thought I would miss are compensated by Node modules like nodemon for file watching and parallelshell for parallelizing tasks without blindly sending them to the background.

Task runners should support interdependent tasks to avoid duplication. In npm you can have scripts call one another via a simple npm run <the other script>. To tweak their behavior you can use environment variables, preferably via cross-env. For example:

"build:plugins": "cross-env BUNDLE=plugins npm run minify",
"minify": "uglify --output=${BUNDLE:-app}.min.js ${BUNDLE:-app}.js",

The :- lets us specify a default value, so that if I run npm run minify on the command-line without setting any environment, app is chosen as a default minify output. Running npm run build:plugins, overrides this defaults and minifies the plugins bundle for me.

For a larger example, check out Google's Addy Osmani's Gist that demonstrates his real world task runner in npm scripts.

The Good

I ported a few of our larger Makefiles and Gulpfiles over to npm scripts.

Compared to the other JS based task runners I tried, npm scripts has many advantages. Not only are the tasks codified terser, but we can also profit from all of npm's ecosystem, and it seems a safe bet that our runner will be supported for as long as Node.js is.

Compared to Makefiles, I'm happy that we won't be scaring away any collaborators using Windows. Since it's all just JSON and JavaScript, we can expect people to start contributing to our tasks as well as our code, whereas a Makefile, in JS projects, would probably be left for the original author to patch up.

The Bad

You miss out on a bit of power that Makefiles or JS task runners can offer, but that seems to be an acceptable trade-off so far.

Where npm scripts did fell short for me, was in command-line operating. While I like that we're no longer alienating Windows or front-end developers, I also don't want to turn my back on unix developers slightly lower on JavaScript-fu, to many of whom make test will still be more intuitive than npm run test. They'll surely figure it out if they badly want to contribute, but I set out to lower barriers and optimize for developer happiness.

Autocomplete poses another command-line related grudge. npm scripts' autocomplete is very inefficient compared to Makefile's. If you are used to mak<TAB>t<TAB> to run the test suite, then spelling out npm run test seems to take forever. You can try tabbing your way through it, but the computer will cowardly throw false positives at you through npm and take ages to enumerate run and test for you, as it boots npm and all of its dependencies with every keystroke.

If you're often working with tasks and you know the computer could have understood you seconds ago, but you're still typing - that starts to become a little annoying.

Okay, that's an understatement. It feels like you're a butcher who can only use a spoon. I guess it ultimately gets the job done, but at the expense of reduced productivity and satisfaction.

The (Slightly Ugly) Killer Solution

Introducing Fakefile: A Universal Makefile for JavaScript that proxies to your npm scripts.

Type mak<TAB><TAB> for autocompletion and Fakefile quickly enumerates any npm scripts in your package.json and presents these as ways into the project. It's instant. Autocomplete to make test and the command is passed onto npm run test for the lower-level plumbing. Any npm script you have will automatically be available under make at runtime, so it won't need any maintenance as your project changes its npm scripts.

Makefiles can't handle : characters well, so it will offer npm run build:production to you as make build-production.

This gets us the best of both worlds. Codify your tasks in a system that won't be obsolete within the year, that is straightforward to people on Windows (they can ignore the Makefile and use npm run) and welcoming unix folks to use their trusted fillet blade. In any repo I maintain, no matter the language, make <something> gets me what I want without thinking twice.

So how does the Fakefile look? This is the gist of it:

# Copyright (2016) by Kevin van Zonneveld https://twitter.com/kvz
# Licensed under MIT
define npm_script_targets
TARGETS := $(shell node -e 'for (var k in require("./package.json").scripts) {console.log(k.replace(/:/g, "-"));}')
$$(TARGETS):
	npm run $(subst -,:,$(MAKECMDGOALS))

.PHONY: $$(TARGETS)
endef

$(eval $(call npm_script_targets))

An up to date version can be found here. Save this snippet into your project root as a Makefile and that's it!

Hm. Can't we make that any easier? And support upgrades and version pinning and all that jazz?

Sure, installing a Fakefile can also be done via npm:

npm install --save --exact fakefile

This will save a Makefile into your project root.

If the installer detects a Makefile that it does not recognize by its SHA-1 hash, it will warn you instead of overwriting it. This gives you a chance to port any existing Makefile logic to npm scripts, after which you can safely remove your original Makefile and rerun the installation, this time successfully installing Fakefile. The installer is happy to overwrite known SHA-1s, so we can easily upgrade Fakefile, should the need arise.

We're currently using npm scripts with a Fakefile in front of it for three different projects and we couldn't be happier with the results. Give it a try and let me know.

Leave a Comment Right Here