icon

Imperative JS builds: es-build and estrella

I recently dug into a new tool that I wanted to explore called es-build. It’s a super-fast typescript compiler and JS bundler that competes with babel, rollup and webpack in certain ways.

You point it at an entry-point file and tell it to bundle it up for the browser (or node) and it does the rest. It converts typescript out of the box, without relying on tsc. The developer’s benchmarks show es-build is many times faster compiling most projects.

I put es-build into a react webpack project with esbuild-loader and it was almost too easy. But it doesn’t support hot module replacement and it doesn’t appear to be on the roadmap.

I like webpack, I think it has a lot of powerful features and is the most robust tool to reach for when you’re building a large project. But, I enjoy playing around with other tools when I can. And I’ve been enjoying the process of building my own framework and toolchain to really understand what’s possible with frontend tools. I like the approach of imperative over declarative syntaxes. And webpack is very declarative most of the time. The mixing of declarative and imperative code in webpack configs is the reason it’s so powerful but it also confuses beginners.

So I set out to write a live dev server and production build script using typescript and no webpack. I still pulled some tools off the shelf and might switch some of them out as I continue to learn.

Just show me the code.

The first tool I found while researching was Estrella built by Rasmus Andersson – who’s kinda brilliant. It encourages a simple approach, it’s API is clear, and it reminds me of Gulp. If you want to understand what really happens when you run npm run dev then this approach will help with that. It takes away some of the magic. Of course abstractions are still useful, we don’t have to examine every part of the stack. But code is better for configuration on something that is so critical to getting work done and so often fails and stumbles in real-world scenarios. In my experience local dev environment setup is one of the biggest sources of confusion and frustration when new developers join the team. Too much magic gets in the way of learning and fixing your own code.

So when a user run’s npm run dev first we use es-build to build the dev.ts file, then we run it with a command line option of -watch.

1
2
3
4
5
"scripts": {
"build:scripts": "esbuild scripts/dev.ts scripts/helpers.ts --platform=node --format=cjs --outdir=build/_scripts/",
"dev": "npm run build:scripts && node build/_scripts/dev.js -watch",
"build": "npm run build:scripts && node build/_scripts/dev.js -production",
}

I’m not bundling this build script because live-server is not happy with bundling, so we just set the format to CommonJS and let it run like a plain old node app. I’ll probably find an alternative to live-server eventually.

What’s first in the dev server? The same thing you would have in any script, a bunch of constants for configuration!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// for configuring this script's command line arguments, see also
// https://github.com/rsms/estrella#your-build-script-becomes-a-cli-program
const [opts] = cliopts.parse(
["p, production", "Creates a production build."],
["o, outdir", "Output directory, defaults to `build/`"],
["s, sourcedir", "Output directory, defaults to `src/`"],
["t, staticdir", "Static file directory, defaults to `static/`"]
);

// set up all the directories we want to work in
const src = subdir(opts.sourcedir || "src/");
const output = subdir(opts.outdir || "build/");
const staticDir = subdir(opts.staticdir || "static/");
const cssFilter = /\.css$/i;

// the es-build options we will apply to your typescript
const buildOpts: BuildConfig = {
entry: src + "index.ts",
outfile: output + "index.js",
bundle: true,
...(opts.production
? { debug: false, sourcemap: false, minify: true }
: { debug: true, sourcemap: true, minify: false }),
};

I know this is a little silly, because I was just complaining about configuration, but some of this stuff is already properly abstracted, and the variable name’s meaning should be clear to developers.

I create a few helper functions then I start the actual build process.

1
2
3
4
5
6
7
8
9
10
11
/**
* ==========================================
* Start running custom build steps here.
* ==========================================
*/

// this can run in parallel with the rest of the build
scandir(staticDir).then((files) => {
console.log("🎸 Copy Static files");
files.map(copyToOutputFrom(staticDir));
});

Copying static files to the build directory is called out first. I make heavy use of promises because most javascript developers need to know how that works anyway, and it’s useful for this type of project.

After the static files I start dealing with CSS files. In my project I need to process my CSS files before I run my Typescript Build because otherwise type checking will fail. Normally es-build doesn’t even do type-checking but estrella starts up a tsc process to do that for us in parallel with the build process so we get feedback about type errors on the command line while the dev server is running.

The postcss pipeline is abstracted away and configured in it’s own file postcss.config[.prod].js and postcss uses the tailwind plugin which consumes tailwind.config.js. It’s a little frustrating to still have so many config files, but those tools normally configured in this way and all the documentation around tailwind will point developers towards those files anyway so I didn’t want to break existing mental models for those tools.

When I call the postcss command inside my helpers I pass a different set of plugins and options if it’s in production mode to purge the CSS files and minify them with cssnano.

After the CSS is ready we will run esbuild, start our live development server, and start watching for any file changes we care about.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/**
* Build and Transpile Sources
* Order of operations:
* 1. Process CSS,
* 2. In dev mode: start the server
* 3. In dev mode: start watching for CSS file changes to rebuild them
* 4. Build JS,
*/
scandir(src, cssFilter)
.then((files) => {
console.log(
"🎸 Process CSS with Postcss and Frets Styles Generator",
files
);
return Promise.all(files.map((file) => processStylesheet(src + file)));
})
.finally(() => {
// Run es-build on our typescript source, watch mode is built in
if (cliopts.watch) {
console.log("🎸 Starting dev server.");
liveServer.start({
port: process.env.PORT || 8181,
root: output,
});
console.log("🎸 Watching for file changes.");
watch(src, { filter: cssFilter }, (changes) => {
console.log("🎸 CSS File modified");
changes.map((c) => {
if (c.type === "add" || c.type === "change") {
processStylesheet(c.name);
}
});
});
}
console.log("🎸 Build the JS");
return build(buildOpts);
});

What does this get us? A nice utility script inside the repo, that handles both dev and production build workflows in a way that is easy to modify and update for any developer that can ready JS already. It has far less magic than a webpack config, at the expense of being more verbose.

Where to go from here

I have considered putting all of the build and developer tool configuration in one big file with lots of comments… it would be an interesting experiment. We could bring everything to imperative run our whole developer tool-chain:

  • postcss and plugins like tailwind
  • es-lint
  • prettier
  • mocha or jest