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:
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"
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';
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));
And my “bundle” script look like this now:
"bundle": "node ./esbuild.config.js",
Let’s run the bundler again… yes! We have 4 files under the dist/main
directory:
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",
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",
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)};
});
},
};
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));
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)
That was a good read, thank you, followed and bookmarked!