Aug 3, 2023

Simple dual-module package setup for JavaScript library authors with esbuild

Article splash image

How to navigate the complexity to develop, type check, debug and build a typescript-powered library for both ESM Modules and CommonJS

Introduction

There’s a common misconception, that fundamental things should be achieved with a single command. Maybe with thoughtful defaults and some clever detection, combined with just a couple of flags, it will get the job done.

This mistake originates from another mistake: thinking that life is easy. Life is not easy. And, a life with JavaScript is not easyplus.

But we like nightmares, to feel the relief when we wake up.

To spread some solace to you anonymous reader, this article shows a simple setup I use when I develop a JavaScript package.

Everything can be better. But this is how I found my own peace.

For now.

Requirements

When I develop a JavaScript library, here’s are a list in random order of what I use and what I look for:

  • node 18+
  • vscode
  • esnext + esmodule
  • TypeScript on strictmax: zap me even if I think to do bad things
  • project-wide live feedback
  • in a monorepo, if a develop something on top, changes are propagated immediately
  • natural debugging experience
  • optimized build with type definitions

Some points maybe not applicable in your context, or you may have a different taste. Just to say, in this article, they are what I go for.

Setup

Let’s follow a folder-tree structure for a supposed library in a supposed monorepo for a supposed project making supposed people happy.

I use two scripts, one for the dev phase, one for the build. And, a bunch of tsconfig files.

/
β”œβ”€ vscode/
β”‚  └─ tasks.json
β”œβ”€ packages/
β”‚   β”œβ”€ my-library
β”‚   β”‚  β”œβ”€ src/
β”‚   β”‚  β”œβ”€ tasks/
β”‚   β”‚  β”‚  β”œβ”€ dev.js
β”‚   β”‚  β”‚  └─ build.js
β”‚   β”‚  β”œβ”€ package.json
β”‚   β”‚  β”œβ”€ tsconfig.json
β”‚   β”‚  β”œβ”€ tsconfig.dev.json
β”‚   β”‚  └─ tsconfig.build.json
β”‚   └─ other-package
β”‚      └─ src/
└─ tsconfig.base.json

I manage monorepos with pnpm.

package.json

First of all, the setup of the package exports and scripts.

{
    "name": "my-library",
    "type": "module",
    "types": "./dist/index.d.ts",
    "exports": {
        ".": {
            "types": "./dist/index.d.ts",
            "import": "./dist/index.js",
            "require": "./dist/index.cjs"
        }
    },
    "scripts": {
        "dev": "node ./tasks/dev.js",
        "build": "node ./tasks/build.js"
    }
}

Package exports define both ESModule and CommonJS entry-points, with a shared typing definition.

After that, the scripts for both dev and build.

Dev

While developing the library, I run in the background the dev script. It does just two things:

  • transpiling TypeScript to JavaScript with esbuild
  • typechecking and generating type definitions incrementally
dev.js
import esbuild from "esbuild";
import { run } from "./utils.js";

await Promise.all([
    run("pnpm tsc -p tsconfig.dev.json --watch --incremental"),

    (await esbuild.context({
        entryPoints: ["src/index.ts"],
        format: "esm",
        outdir: "./dist/",
        sourcemap: true,
    })).watch(),
]);

While in dev, I don’t transpile to CommonJS as I’m dealing mainly with ESM.

The tsconfig.dev.json is a little file with some flag enabled:

{
    "extends": "./tsconfig.json",
    "compilerOptions": {
        "noEmit": false,        
        "declaration": true,
        "declarationMap": true,
        "emitDeclarationOnly": true
    }
}

I use a dedicated .dev.json file, instead of tsc cli flags, because I find easier to add additional flags.

And, since I always develop in vscode, I setup a task to automatically run when I open the workspace.

.vscode/tasks.json
{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "my-library dev",
            "type": "npm",
            "script": "dev",
            "group": "build",
            "path": "${workspaceFolder}/packages/my-library",
            "options": { "cwd": "${workspaceFolder}/packages/my-library" },
            "problemMatcher": {
                "base": "$tsc-watch",
                "fileLocation": [
                    "relative",
                    "${workspaceFolder}/packages/my-library"
                ]
            },            
            "isBackground": true,
            "presentation": { "reveal": "never" },
            "runOptions": { "runOn": "folderOpen" }
        }
    ]
}

The my-library dev task runs when I open vscode. I don’t have to run it manually every time. Which, since I switch among many projects all day, it could have been kind of annoying.

And finally, the cherry on top the cake, the problemMatcher reports compilation errors to vscode problem panel.

With a single background script and some configuration, while developing, I get:

  • project-wide live feedback (file-based via editor is not enough for me)
  • fast typechecking as it’s incremental
  • fast transpiling thanks to esbuild
  • sourcemaps for debugging
  • declarations maps for F12 jumps to reference

Build

Building is a one time task, just before publishing. The moment when I burn people devices because they run my code, and I hope the fire doesn’t spread to the entire house.

The build script transforms the library to produce the entry-points needed for a dual-package usage.

build.js
await fs.rm("./dist", { force: true, recursive: true });

await Promise.all([
    // declaration only typescript build
    run("pnpm tsc -p tsconfig.build.json"),

    // bundle for esm
    esbuild.build({
        entryPoints: ["src/index.ts"],
        bundle: true,
        minify: true,
        format: "esm",
        outfile: `./dist/index.js`,
    }),

    // bundle for commonjs
    esbuild.build({
        entryPoints: ["src/index.ts"],
        bundle: true,
        minify: true,
        format: "cjs",
        outfile: `./dist/index.cjs`,
    }),
]);

Bundle and minify depends on the library. For a frontend thing, usually I enable both. For a server package, they keep the value of the file I copy-pasted.

Similar to the dev counterpart, the tsconfig.build.json sets some flag:

{
    "extends": "./tsconfig.json",
    "compilerOptions": {
        "noEmit": false,        
        "declaration": true,
        "emitDeclarationOnly": true
    }
}

Usually, the CI run the build script. But sometimes I run it locally, hence the removing of the dist dir.

And, after a build run, finally you have a dual-package with both CommonJS and ESM support, typechecked, optimized and ready to be published. To make the world a better place.

Bonus content

Shared config

If you share many parameters, between the two module formats, you can use a common config. For example, you can mark React as external, if you’re creating a library for it.

const config = {
    entryPoints: ["src/index.ts"],
    minify: true,
    bundle: true,
    external: ["react", "react-dom"],
};

await Promise.all([
    //...

    esbuild.build({
        ...config,
        format: "esm",
        outfile: `./dist/index.js`,
    }),
    esbuild.build({
        ...config,
        format: "cjs",
        outfile: `./dist/index.cjs`,
    }),
])

Copy and export assets

Having explicit dev/build scripts you control gives you the flexibility to adapt to various scenarios.

For example, you library need to export some assets like a CSS stylesheet.

await Promise.all([
    //... others

    (await esbuild.context({
        entryPoints: ["./styles/awesome-theme.css"]
        loader: {
            ".css": "copy"
        },
        outdir: "./styles"
    })).watch(),
]);

Extra features with plugins

Again, you can customize the scripts including esbuild plugins.

For example, you want to embed some SVGs as JSX Element:

import svgr from "esbuild-plugin-svgr";

const svgrConfig = {
    plugins: ["@svgr/plugin-jsx"],
};

await Promise.all([
    //...

    (await esbuild.context({
        plugins: [
            svgr(svgrConfig),
        ],        
        entryPoints: ["src/index.ts"],
        //...
    })).watch(),
]);

Conclusion

There’s some pain the world. It renovates itself constantly, facing brave people attempting to put it down. A like-minded united effort sometimes makes it wane a little bit. But the flesh is weak, and it β€” the pain β€” finds ways to pierce through juts, cracks and shallows.

But JS devs understand this. We’re in this together.