Introducing esbuild To Your Monorepo
Tags: monorepo, esbuild, webdev, tutorial
Published: 2022-09-09 10:31:08

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](https://github.com/mbarzeev/pedalboard) creates different artifacts (CJS, ESM and types - you can read about it in more details in my “[Hybrid NPM package through TypeScript Compiler (TSC)](https://dev.to/mbarzeev/hybrid-npm-package-through-typescript-compiler-tsc-150c)” 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 :grimacing:) 
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 :wink: esbuild custom plugins…

Let’s go!

***

The code I’m referring to can be found under my [pedalboard monorepo](https://github.com/mbarzeev/pedalboard) 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](https://dev.to/mbarzeev/hybrid-npm-package-through-typescript-compiler-tsc-150c).

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/282xkzpkx8zsvwjdu8c5.png)

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

```json
"bundle": "esbuild dist/esm/index.js --bundle --minify --sourcemap --outfile=dist/main/index.js"
```

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:

```bash
✘ [ERROR] Could not resolve "./index.css"

    dist/esm/src/Pagination/index.js:4:7:
      4 │ import './index.css';
```

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: 

```javascript
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));
``` 

And my “bundle” script look like this now:

```json
"bundle": "node ./esbuild.config.js",
```

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

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hysr51lfzxaw1kvcugdz.png)

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) - 

```json
"build": "tsc --project tsconfig.esm.json & tsc --project tsconfig.cjs.json && yarn bundle",
```

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 -

```json
"bundle": "node ../../esbuild.config.js",
```

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` - 

```javascript
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)};
       });
   },
};
```

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

```javascript
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));
``` 

## 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](https://github.com/mbarzeev/pedalboard).

*Hey! If you liked what you've just read check out <a href="https://twitter.com/mattibarzeev?ref_src=twsrc%5Etfw" class="twitter-follow-button" data-show-count="false">@mattibarzeev</a><script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> on Twitter* :beers:

<small><small><small></small></small></small>