Build a library with esbuild (vol. 2)

A few new tips and tricks to build a library with esbuild

Jul 5, 2022

#javascript #programming #webdev #showdev

Wake Skate

Photo by Joseph Greve on Unsplash


A year ago I shared a post that explains how to build a library with esbuild. While it remains a valid solution, I developed some improvements in my tooling since its publication.

Here are these few add-ons that I hope, will be useful for your projects too.


Source and output

It can be sometimes useful to define more than one entry point for the library - i.e. not just use a unique index.ts file as entry but multiple sources that provides logically-independent groups of code. esbuild supports such option through the parameter entryPoints.

For example, in my projects, I often list all the TypeScript files present in my src folder and use these as separate entries.

import { readdirSync, statSync } from "fs";
import { join } from "path";

// Select all typescript files of src directory as entry points
const entryPoints = readdirSync(join(process.cwd(), "src"))
    .filter((file) => file.endsWith(".ts") && statSync(join(process.cwd(), "src", file)).isFile())
    .map((file) => `src/${file}`);

As the output folder before each build might have been deleted, I also like to ensure it exists by creating it before proceeding.

import { existsSync, mkdirSync } from "fs";
import { join } from "path";

// Create dist before build if not exist
const dist = join(process.cwd(), "dist");

if (!existsSync(dist)) {
    mkdirSync(dist);
}

// Select entryPoints and build

Global is not defined

Your library might use some dependency that leads to a build error "Uncaught ReferenceError: global is not defined" when building ESM target. Root cause being the dependency expecting a global object (as in NodeJS) while you would need window for the browser.

To overcome the issue, esbuild has a define option that can be use to replace global identifiers with constant expression.

import esbuild from "esbuild";

esbuild
    .build({
        entryPoints,
        outdir: "dist/esm",
        bundle: true,
        sourcemap: true,
        minify: true,
        splitting: true,
        format: "esm",
        define: { global: "window" },
        target: ["esnext"]
    })
    .catch(() => process.exit(1));

Both esm and cjs

To ship a library that supports both CommonJS (cjs) and ECMAScript module (esm), I output the bundles in two sub-folders of the distribution directory - e.g. dist/cjs and dist/esm. With esbuild, this can be achieved by specifying the options outdir or outfile to these relative paths.

import esbuild from "esbuild";
import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "fs";
import { join } from "path";

const dist = join(process.cwd(), "dist");

if (!existsSync(dist)) {
    mkdirSync(dist);
}

const entryPoints = readdirSync(join(process.cwd(), "src"))
    .filter((file) => !file.endsWith(".ts") && statSync(join(process.cwd(), "src", file)).isFile())
    .map((file) => `src/${file}`);

// esm output bundles with code splitting
esbuild
    .build({
        entryPoints,
        outdir: "dist/esm",
        bundle: true,
        sourcemap: true,
        minify: true,
        splitting: true,
        format: "esm",
        define: { global: "window" },
        target: ["esnext"]
    })
    .catch(() => process.exit(1));

// cjs output bundle
esbuild
    .build({
        entryPoints: ["src/index.ts"],
        outfile: "dist/cjs/index.cjs.js",
        bundle: true,
        sourcemap: true,
        minify: true,
        platform: "node",
        target: ["node16"]
    })
    .catch(() => process.exit(1));

// an entry file for cjs at the root of the bundle
writeFileSync(join(dist, "index.js"), "export * from './esm/index.js';");

// an entry file for esm at the root of the bundle
writeFileSync(join(dist, "index.cjs.js"), "module.exports = require('./cjs/index.cjs.js');");

As distributing two distinct folders leads to having no more entry files in the dist path of the library, I also like to add two files that re-export the code. It can be useful when importing the library in a project.

In addition, the package.json entries should be updated accordingly as well.

{
    "name": "mylibary",
    "version": "0.0.1",
    "main": "dist/cjs/index.cjs.js",
    "module": "dist/esm/index.js",
    "types": "dist/types/index.d.ts"
}

CSS and SASS

Did you know that esbuild can bundle CSS files too? There is even a SASS plugin that makes it easy to build .scss files 😃.

npm i -D esbuild-sass-plugin postcss autoprefixer postcss-preset-env

In following example, I bundle two different SASS files - src/index.scss and src/doc/index.scss. I use the plugin to transform the code - i.e. to prefix the CSS - and I also use the option metafile which tells esbuild to produce some metadata about the build in JSON format.

Using it, I can retrieve the paths and names of the generated CSS files to e.g. include these in my HTML files later on.

import esbuild from "esbuild";

import { sassPlugin } from "esbuild-sass-plugin";
import postcss from "postcss";
import autoprefixer from "autoprefixer";
import postcssPresetEnv from "postcss-preset-env";

const buildCSS = async () => {
    const { metafile } = await esbuild.build({
        entryPoints: ["src/index.scss", "src/doc/index.scss"],
        bundle: true,
        minify: true,
        format: "esm",
        target: ["esnext"],
        outdir: "dist/build",
        metafile: true,
        plugins: [
            sassPlugin({
                async transform(source, resolveDir) {
                    const { css } = await postcss([autoprefixer, postcssPresetEnv()]).process(source, {
                        from: undefined
                    });
                    return css;
                }
            })
        ]
    });

    const { outputs } = metafile;
    return Object.keys(outputs);
};

Conclusion

esbuild is still slick!

To infinity and beyond
David