Ship ESM & CJS in one Package
Nov 29, 2021· 15min
ESM & CJS
- ESM -ECMAScript modules
- CJS -CommonJS
In the past decade, due to the lack of a standard module system of JavaScript, CommonJS (a.k.a therequire('xxx')
andmodule.exports
syntax) has been the way how Node.js and NPM packages work. Until 2015, when ECMAScript modules finally show up as the standard solution, the community start migrating to native ESM gradually.
// CJSconst circle = require('./circle.js')console.log(`The area of a circle of radius 4 is${circle.area(4)}`)
// ESMimport { area } from './circle.mjs'console.log(`The area of a circle of radius 4 is${area(4)}`)
ESM enables named exports, better static analysis, tree-shaking, browsers native support, and most importantly, as a standard, it’s basically the future of JavaScript.
Experimental support of native ESM is introduced in Node.js v12, and stabilized in v12.22.0 and v14.17.0. As the end of 2021, many packages now ship in pure-ESM format, or CJS and ESM dual formats; meta-frameworks likeNuxt 3 andSvelteKit are now recommending users to use ESM-first environment.
The overall migration of the ecosystem is still in progress, for most library authors, shipping dual formats is a safer and smoother way to have the goods from both worlds. In the rest of this blog post, I will show you why and how.
Compatibility
If ESM is the better and the future, why don’t we all move to ESM then? Even though Node.js is smart enough to allow CJS and ESM packages to work together, the main blocker is thatyou can’t use ESM packages in CJS.
If you do:
// in CJSconstpkg = require('esm-only-package')
you will receive the following error
Error [ERR_REQUIRE_ESM]:require() of ES Module esm-only-package not supported.
The root cause is that ESM is asynchronous by nature, meaning you can’t import an async module in synchronous context thatrequire
is for. This commonly meansif you want to use ESM packages, you have to use ESM as well. Only one exception is that you can use ESM package in CJS usingdynamicimport()
:
// in CJSconst{ default: pkg } = await import('esm-only-package')
Since dynamic import will return a Promise, meaning all the sub-sequential callee need to be async as well (so callRed Functions, or I prefer call it Async Infection). In some case it might work, but generally I won’t think this to be an easy approachable solution for users.
On the other hand, if you are able to go with ESM directly, it would be much easier asimport
supports both ESM and CJS.
import cjs from 'cjs-package'// in ESMimport { named } from 'esm-package'
Some packages now shippure-ESM packages advocating the ecosystem to move from CJS to ESM. While this might be the "right thing to do", however, giving the fact that that majority of the ecosystem are still on CJS and the migration is not that easy, I found myself more lean to ship both CJS and ESM formats to make the transition smoother.
package.json
Luckily, Node allows you to have those two formats in a single package at the same time. With the newexports
field inpackage.json
, you can now specify multiple entries to provide those formats conditionally. Node will resolve to the version based on user’s or downstream packages environment.
{ "name": "my-cool-package", "exports": { ".": { "require": "./index.cjs", // CJS "import": "./index.mjs" // ESM } }}
Bundling
So now we have two copies of code with slightly different module syntax to maintain, duplicating them is of course not an ideal solution. At this point you might need to consider introducing some build tools or bundling process to build your code into multiple formats. This might remind you the nightmare of configuring complex Webpack or Rollup, well don’t worry, my mission today is to introduce you two awesome tools that make your life so much easier.
tsup
tsup
by@egoist is one of my most used tools. The features zero-config building for TypeScript project. The usage is like:
$ tsup src/index.ts
And then you will havedist/index.js
file ready for you to publish.
To support dual formats, it’s just a flag away:
$ tsup src/index.ts --format cjs,esm
Two filesdist/index.js
anddist/index.mjs
will be generated with it and you are good to go. Powered byesbuild
,tsup
is not only super easy to use but also incredible fast. I highly recommend to give it a try.
Here is my go-to template ofpackage.json
usingtsup
:
{ "name": "my-cool-package", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "exports": { ".": { "require": "./dist/index.js", "import": "./dist/index.mjs", "types": "./dist/index.d.ts" } }, "scripts": { "build": "tsup src/index.ts --format cjs,esm --dts --clean", "watch": "npm run build -- --watch src", "prepublishOnly": "npm run build" }}
unbuild
If we saytsup
is a minimal bundler for TypeScript,unbuild
by the@unjs org is a more generalized, customizable and yet powerful.unbuild
is being used to bundle Nuxt 3 and it’s sub packages.
To use it, we createbuild.config.ts
file in the root
// build.config.tsimport { defineBuildConfig } from 'unbuild'export default defineBuildConfig({ entries: [ './src/index' ], declaration:true,// generate .d.ts files})
and run theunbuild
command:
$ unbuild
unbuild
will generate both ESM and CJS for you by default!
Stubbing
This is one of the most incredible things that I have found when I first looked intoNuxt 3’s codebase.unbuild
introduced a new idea called Stubbing. Instead of firing up a watcher to re-trigger the bundling every time you made changes to the source code, the stubbing inunbuild
(so call Passive watcher) does not require you are have another process for that at all. By calling the following commandonly once:
$ unbuild --stub
You are able to play and test out with your library with the up-to-date code!
Want to know the magic? After running the stubbing command, you can check out the generated distribution files:
// dist/index.mjsimport jiti from 'jiti'export default jiti(null, {interopDefault:true })('/Users/antfu/unbuild-test/src/index')
// dist/index.cjsmodule.exports = require('jiti')(null, {interopDefault:true })('/Users/antfu/unbuild-test/src/index')
Instead of the distribution of your code bundle, the dist files are now redirecting to your source code with a wrap ofjiti
- another treasure hidden in the@unjs org.jiti
provides the runtime support of TypeScript, ESM for Node by transpiling the modules on the fly. Since it directly goes to your source files, there won’t be a misalignment between your source code and bundle dist - thus there is no watcher process needed! This is a huge DX bump for library authors, if you still not getting it, you shall definitely grab it down and play with it yourself.
Bundleless Build
Powered bymkdist
- another@unjs package -unbuild
also handles static assets and file-to-file transpiling. Bundleless build allows you to keep the structure of your source code, made easy for importing submodules on-demand to optimizing performance and more.
Config inunbuild
will look like:
// build.config.tsimport { defineBuildConfig } from 'unbuild'export default defineBuildConfig({ entries: [ // bundling 'src/index', // bundleless, or just copy assets {input:'src/components/',outDir:'dist/components' }, ], declaration:true,})
One of the coolest features on this is that it handles.vue
file out-of-box. For example, if I have a componentMyComponent.vue
undersrc/components
with following content:
<!-- src/components/MyComponent.vue --><template> <div>{{ count }}</div></template><script lang="ts"> const count:number |string = 0 export default { data: () => ({ count }), }</script>
Notice that we are using TypeScript in the Vue file, when we do the build, the component will be copied over but with the TypeScript annotation removed along with aMyComponent.vue.d.ts
generated.
<!-- dist/components/MyComponent.vue --><template> <div>{{ count }}</div></template><script> const count = 0 export default { data: () => ({ count }), }</script>
// dist/components/MyComponent.vue.d.tsdeclare const_default: { data: () => { count:number |string }}export default _default
This way this allows you to use TypeScript in development while not requiring consumers to also have TypeScript in their setup.
P.S.unbuild
is working on providing better out-of-box experience by auto infering the entries inpackage.json
,learn more.
Context Misalignment
With either of the tools mentioned above, now we are able to write TypeScript as the single source of truth and made the overall codebase easier to maintain. However, there are still some caveats that you will need to keep an eye on it.
In ESM, there is NO__dirname
,__filename
,require
,require.resolve
. Instead, you will need to useimport.meta.url
and also do some convertion to get the file path string.
So since our code will be compiled to both CJS and ESM, it’s better to avoiding using those environment specific context whenever possible. If you do need them, you can refer to my note aboutIsomorphic__dirname
:
import { dirname } from 'node:path'import { fileURLToPath } from 'node:url'const_dirname = typeof__dirname !=='undefined' ? __dirname : dirname(fileURLToPath(import.meta.url))
Forrequire
andrequire.resolve
, you can use
import { createRequire } from 'node:module'constrequire = createRequire(import.meta.url)
Some good news, if you are usingunbuild
, you can turn on thecjsBridge
flag andunbuild
will shims those CJS context in ESM automatically for you!.
import { defineBuildConfig } from 'unbuild'export default defineBuildConfig({ cjsBridge:true,// <--})
On the other hand, if you are usingtsup
, it will shims ESM’simport.meta.url
for you in CJS instead.
Verify your Packages
Once your published your package, you can verify if it follows the best practices usingpublint.dev made by@bluwy. It will also give you suggestions of how to improve them further.
Final words
This blog post showcased you only a few features of both tools. Do check their docs for more details. And hope you find these setups useful for building your own libraries. If you have any comments or suggestions, ping me on Twitter@antfu7. Happy hacking!