Photo by Colin Watts on Unsplash
I recently developed plugins and, migrated all the utilities of DeckDeckGo to build these with esbuild.
If you are looking to do the same, hope this tutorial helps you get started!
Introduction
esbuild is “an extremely fast JavaScript bundler” made by Evan Wallace. It is its tagline and, according my tests, the least we can say is that it is true. It is blazing fast ⚡️.
Sometimes while migrating my libraries, I even found myself waiting for the end of a build because I did not notice that it was already finished. I assumed it would still need some more time, old habits die hard I guess 😅.
In addition, other things which make me really like this new bundler are its clean, flexible API and, its documentation. It is easy to follow and, clear.
Setup
To get started, let’s create a new empty project.
mkdir mylib && cd mylib && npm init --yes
You can use esbuild to bundle libraries from vanilla JavaScript source files but, at least in this tutorial, we are going to use TypeScript too. That’s why, in addition to the bundler, we also install it and rimraf, to remove the output folder before any new build.
npm i esbuild typescript rimraf --save-dev
At the root of our project, we create a ./tsconfig.json
file to indicates that the directory is the root of a TypeScript project.
{
"compilerOptions": {
"declaration": true,
"target": "esnext",
"lib": ["esnext", "dom"],
"strict": true,
"noImplicitAny": false,
"esModuleInterop": true,
"moduleResolution": "node",
"outDir": "lib"
}
}
In the above configuration, I set esnext
to target the most recent ES standard and, also set declaration
to true
. esbuild not generating the declarations, we are going to generate these using the tsc
command.
We update our ./package.json
with a script to build
our library and, we define a new types
entry which should point to the types declarations.
{
"name": "mylib",
"version": "1.0.0",
"description": "",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"scripts": {
"ts-types": " tsc --emitDeclarationOnly --outDir lib",
"build": "rimraf lib && node ./esbuild.js && npm run ts-types"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"esbuild": "^0.12.1",
"typescript": "^4.2.4"
}
}
The configuration and, build using esbuild is going to be developed in following chapters in a dedicated file, ./esbuild.js
. That’s why, at this point, we leave it empty.
touch esbuild.js
It is worth to notice that the esbuild commands can be inlined inside the scripts
tag of the package.json
but, I personally like to handle it separately.
That’s useful when the scripts evolve or, when multiple miscellaneous builds and, steps are performed.
Finally, we add some source code to be compiled, such as following sample function, in a main new entry point file ./src/index.ts
.
export const add = (a: number, b: number): number => a + b;
ECMAScript module
The esm
format stands for "ECMAScript module". It assumes the environment supports import
and export
syntax (documentation).
To bundle such a modern library, we can add the following configuration to our ./esbuild.js
:
const esbuild = require("esbuild");
esbuild
.build({
entryPoints: ["src/index.ts"],
outdir: "lib",
bundle: true,
sourcemap: true,
minify: true,
splitting: true,
format: "esm",
target: ["esnext"]
})
.catch(() => process.exit(1));
That’s already it 🥳.
If we run npm run build
in our project, the library is bundled to esm
, a source map is generated and, the TypeScript declarations as well.
“But, what’s happening here” you may ask yourself? Therefore, here some context:
In the script we first require esbuild
and, with the help of the method .build
, we run an async
compilation. It is also possible to perform this step synchronously and, get the results (I will develop this in a following chapter).
To perform the operation, we set following options:
entryPoints
andourdir
defines which files need to be bundled to which outputbundle
means to inline any imported dependencies into the file itself. This process is recursive so dependencies of dependencies (and so on) will also be inlined (documentation). In other words, if you have gotimport
in yourentryPoints
, bundle will resolve these to add their code in the results.sourcemap
if set totrue
, generates source map files next to your JavaScript outcomeminify
makes the code smaller (documentation)splitting
is a work in progress (at the time I write these lines) which improves the code sharing between multiple endpoints (see documentation)format
is set toesm
as it is the goal in this chapter (documentation)target
defines which types of JavaScript we want to output. In our case, only the most recent version (documentation)
Module Field
In above chapter we are generating an esm
library. If you aim to use this tutorial to create an effective bundle, I suggest adding a module
entry in our package.json
. Even though not officially documented, this entry is useful for such types of library (see Stackoverflow).
"main": "lib/index.js",
"module": "lib/index.js",
"types": "lib/index.d.ts",
IIFE
The iife
format stands for "immediately-invoked function expression" and is intended to be run in the browser (documentation).
If you rather like or, are in need to create library which is immediately available, it can be done by removing from the previous configuration both format
and splitting
.
The iife
format is the default format unless we set platform
to node (as in next chapter). splitting
is only available for esm
modules.
const esbuild = require("esbuild");
esbuild
.build({
entryPoints: ["src/index.ts"],
outdir: "lib",
bundle: true,
sourcemap: true,
minify: true,
target: ["esnext"]
})
.catch(() => process.exit(1));
CommonJS — Node
The cjs
format stands for "CommonJS" and is intended to be run in node (documentation).
If your library aims to be used in a Node or, in a non-browser environment, it can be bundled for such purpose with a related platform
option.
const esbuild = require("esbuild");
esbuild
.build({
entryPoints: ["src/index.ts"],
outdir: "lib",
bundle: true,
sourcemap: true,
minify: true,
platform: "node",
target: ["node10.4"]
})
.catch(() => process.exit(1));
In this configuration we define node
as platform
and, set target
to Node version 10 (documentation).
Synchronous Build
Above builds are asynchronous. You might want to run a synchronous builds to either get information on the process or, do something with the output without writing it to the filesystem.
This can be achieved by replacing the method .build
with .buildSync
.
Information
To get to know if there were errors, or warnings, we can call buildSync
. It will return an object which contains such information.
const esbuild = require("esbuild");
const results = esbuild.buildSync({
entryPoints: ["src/index.ts"],
outdir: "lib",
bundle: true,
sourcemap: true,
minify: true,
splitting: true,
format: "esm",
target: ["esnext"]
});
console.log(results);
// Output:
// { errors: [], warnings: [] }
In Memory Results
To get the files that would have been written as in-memory buffers, we can leverage the option write
(documentation). For each entryPoints
, esbuild will answer with a related outputFiles
entry in an array of results.
For example, if we would like to inline our script in an HTML file, we would be able to get these results and, parse it manually to the output of our choice.
const esbuild = require("esbuild");
const { readFile, writeFile, mkdir } = require("fs").promises;
(async () => {
await mkdir("./lib");
const script = esbuild.buildSync({
entryPoints: ["src/index.ts"],
bundle: true,
minify: true,
format: "esm",
target: ["esnext"],
write: false
});
const html = await readFile("src/index.html", "utf8");
await writeFile(
"lib/index.html",
`<script type="module">${script.outputFiles[0].text}</script>${html}`
);
})();
A bit out of the scope of this article but, to demonstrate how flexible esbuild is, we would be able to install a html minifier to get even more fancier.
npm i html-minifier-terser --save-dev
Once added to our project, we would be able to minify the resulting HTML and, are almost already ready to turn this small library in a build pipeline for modern application 😱.
const esbuild = require('esbuild');
const {readFile, writeFile, mkdir} = require('fs').promises;
const minify = require('html-minifier-terser').minify;
(async () => {
await mkdir('./lib');
const script = esbuild
.buildSync({
entryPoints: ['src/index.ts'],
bundle: true,
minify: true,
format: 'esm',
target: ['esnext'],
write: false
});
const html = await readFile('src/index.html', 'utf8');¨
const minifyOptions = {
collapseWhitespace: true,
keepClosingSlash: true,
removeComments: true,
removeRedundantAttributes: true,
removeScriptTypeAttributes: true,
removeStyleLinkTypeAttributes: true,
useShortDoctype: true,
minifyCSS: true
};
await writeFile(
'lib/index.html',
`<script>${script.outputFiles[0].text}</script>${await minify(html, minifyOptions)}`
);
})();
Summary
esbuild is slick 🤙.
To infinity and beyond!
David