DEV Community

Cover image for Introducing esbuild To Your Monorepo
Matti Bar-Zeev
Matti Bar-Zeev

Posted on

Introducing esbuild To Your Monorepo

In this post I will introduce esbuild as the bundling solution for my monorepo. I will bundle 2 packages with the same bundling configuration and create a custom esbuild plugin to assist with that.

I have noticed that although the build process for packages under my “pedalboard” Monorepo creates different artifacts (CJS, ESM and types - you can read about it in more details in my “Hybrid NPM package through TypeScript Compiler (TSC)” post) it does not create a single bundle for the entire package.

If I take the components package for example, it has a dist/esm directory which has the ESM code in it, scattered in different files and not as a single minified bundle I can deploy somewhere and then fetch in a single request.

For that you need a JS bundler.

There are many bundlers out there, the most notorious one being Webpack, and I’m not willing to go into the rabbit hole of comparing them in this post (god knows the web is packed with these comparisons... web… packed… sorry 😬)
I want to go with a solution which seems to be a bit more efficient, fast and elegant IMO. I’m going with esbuild.

In this post I will attempt the following -

  • Get the components package to be bundled as part of its build process and create the bundle under dist/main/index.js
  • Share the esbuild configuration so that I can reuse it in another package under the monorepo - the “hooks” package
  • Give a 😉 esbuild custom plugins…

Let’s go!


The code I’m referring to can be found under my pedalboard monorepo on GitHub.

I’m going into the “components” package and installing esbuild as the first step -

yarn add -D esbuild

Now that I have it installed I will create a new NPM script called “bundle” and in that script I will call the esbuild CLI command with the relevant parameters to bundle my code.
But… where is the code I would like to bundle?

Well, the code I would like to bundle resides under the dist/esm directory which gets created and populated when I run the build script for this package. What creates it is TSC and you can read more about it here.

So, in the dist/esm I have the entry point file and the rest of the required modules along with some source map files. It looks like this:

Image description

Given the above my “bundle” script will look something like this:

"bundle": "esbuild dist/esm/index.js --bundle --minify --sourcemap --outfile=dist/main/index.js"
Enter fullscreen mode Exit fullscreen mode

I would like dist/esm/index.js to be the entry point, bundle it, minify it and have some source map file to it before putting it in dist/main/index.js.
Let’s run it and see what happens:

yarn bundle

Oops, I’m getting this error:

[ERROR] Could not resolve "./index.css"

    dist/esm/src/Pagination/index.js:4:7:
      4 │ import './index.css';
Enter fullscreen mode Exit fullscreen mode

And rightfully so.
My dist/esm directory does not hold the CSS files for the component.
TSC cannot come to the rescue here since its job is transpiling TS only, so the 2 remaining options are either we copy the files ourselves, in some script, which can be hard to maintain since we will need to resolve the paths, or… we can create a plugin for esbuild which detects any .css file and alters the path to be relative to the package’s src directory.

Hmm… sounds ambitious for a peaceful Friday but let’s try it and learn something about esbuild custom plugins on the way -

esbuild custom plugin

What I would like to do is create a plugin which filters any .css extension in imports and redirect its path to the src folder of the package.
In order to achieve that I can no longer use the CLI to execute the bundler, but rather use a node script which will trigger esbuild.
I created such a script called esbuild.config.js (just to be aligned with the rest of the config files, though it is more of a script than a configuration) and in it I define my plugin and trigger the esbuild.

My plugin implementation is really naive. The resolveDir is the absolute path of the imported file and I simply remove the dist/esm from it and it then redirects to the package src instead:

const path = require('path');

let cssPlugin = {
   name: 'css',
   setup(build) {
       // Redirect all paths starting with "images/" to "./public/images/"
       build.onResolve({filter: /.\.css$/}, (args) => {
           const path1 = args.resolveDir.replace('/dist/esm', '');
           return {path: path.join(path1, args.path)};
       });
   },
};

require('esbuild')
   .build({
       entryPoints: ['dist/esm/index.js'],
       bundle: true,
       minify: true,
       sourcemap: true,
       outfile: 'dist/main/index.js',
       plugins: [cssPlugin],
   })
   .catch(() => process.exit(1));
Enter fullscreen mode Exit fullscreen mode

And my “bundle” script look like this now:

"bundle": "node ./esbuild.config.js",
Enter fullscreen mode Exit fullscreen mode

Let’s run the bundler again… yes! We have 4 files under the dist/main directory:

Image description

So we have the package as a bundle. Now let’s make it run at the end of the “build” script (since it depends on the dist/esm files) -

"build": "tsc --project tsconfig.esm.json & tsc --project tsconfig.cjs.json && yarn bundle",
Enter fullscreen mode Exit fullscreen mode

While both TSC run in parallel, the last command awaits for them to complete (notice the “&&” there).
Nice :)

But I’m a greedy ba5tard...

This is all well, but I would like my “hooks” package to also enjoy this bundling, along with the plugin, but I have no intention of duplicating the code. Can I reuse it?

First of all I will bring my esbuild.config.js file to the project’s root. Running yarn bundle now obviously fails since it cannot find the file, so I’m changing the path -

"bundle": "node ../../esbuild.config.js",
Enter fullscreen mode Exit fullscreen mode

I can now add the same scripts to my “hooks” package and we;re done, but let’s refactor it a bit.
First let me extract the plugin to its own file called esbuild.css.plugin.js -

const path = require('path');

module.exports = {
   name: 'css',
   setup(build) {
       // Redirect all paths starting with "images/" to "./public/images/"
       build.onResolve({filter: /.\.css$/}, (args) => {
           const path1 = args.resolveDir.replace('/dist/esm', '');
           return {path: path.join(path1, args.path)};
       });
   },
};
Enter fullscreen mode Exit fullscreen mode

And now I will import it in my esbuild.config.js file, so it looks like this:

const cssPlugin = require('./esbuild.css.plugin');

require('esbuild')
   .build({
       entryPoints: ['dist/esm/index.js'],
       bundle: true,
       minify: true,
       sourcemap: true,
       outfile: 'dist/main/index.js',
       plugins: [cssPlugin],
   })
   .catch(() => process.exit(1));
Enter fullscreen mode Exit fullscreen mode

Wrapping Up

We can take it from here further and allow that each package will have its own esbuild.config.js where it will extend the root one and allow to override it, but you know what… that’s a nice challenge for you to try, right? ;)

As always, if you know of better means to achieve this or have questions, please leave your comments on the comments section below so we can all learn from it.

The code can be found on the pedalboard GitHub repo.

Hey! If you liked what you've just read check out @mattibarzeev on Twitter 🍻

Top comments (1)

Collapse
 
naucode profile image
Al - Naucode

That was a good read, thank you, followed and bookmarked!