Simple dual-module package setup for JavaScript library authors with esbuild
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
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.
{
"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.
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(),
]);
await Promise.all([
//... others
await esbuild.build({
entryPoints: ["./styles/awesome-theme.css"]
loader: {
".css": "copy"
},
outdir: "./styles"
});
]);
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(),
]);
import svgr from "esbuild-plugin-svgr";
const svgrConfig = {
plugins: ["@svgr/plugin-jsx"],
};
await Promise.all([
//...
await esbuild.build({
plugins: [
svgr(svgrConfig),
],
entryPoints: ["src/index.ts"],
//...
});
]);
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.