logologo

Publish ESM and CJS in a single package

Nov 29, 2021 · 15min

ESM & CJS

In the past decade, due to the lack of a standard module system of JavaScript, CommonJS (a.k.a the require('xxx') and module.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.

// CJS
const circle = require('./circle.js')
console.log(`The area of a circle of radius 4 is ${circle.area(4)}`)
// CJS
const circle = require('./circle.js')
console.log(`The area of a circle of radius 4 is ${circle.area(4)}`)
// ESM
import { area } from './circle.mjs'
console.log(`The area of a circle of radius 4 is ${area(4)}`)
// ESM
import { 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 like Nuxt 3 and SvelteKit 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 that you can’t use ESM packages in CJS.

If you do:

// in CJS
const pkg = require('esm-only-package')
// in CJS
const pkg = require('esm-only-package')

you will receive the following error

Error [ERR_REQUIRE_ESM]: require() of ES Module esm-only-package not supported.
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 that require is for. This commonly means if 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 using dynamic import():

// in CJS
const { default: pkg } = await import('esm-only-package')
// in CJS
const { 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 call Red 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 as import supports both ESM and CJS.

// in ESM
import { named } from 'esm-package'
import cjs from 'cjs-package' 
// in ESM
import { named } from 'esm-package'
import cjs from 'cjs-package' 

Some packages now ship pure-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 new exports field in package.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
    }
  }
}
{
  "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
$ tsup src/index.ts

And then you will have dist/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
$ tsup src/index.ts --format cjs,esm

Two files dist/index.js and dist/index.mjs will be generated with it and you are good to go. Powered by esbuild, 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 of package.json using tsup:

{
  "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"
  }
}
{
  "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 say tsup 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 create build.config.ts file in the root

// build.config.ts
import { defineBuildConfig } from 'unbuild'

export default defineBuildConfig({
  entries: [
    './src/index'
  ],
  declaration: true, // generate .d.ts files
})
// build.config.ts
import { defineBuildConfig } from 'unbuild'

export default defineBuildConfig({
  entries: [
    './src/index'
  ],
  declaration: true, // generate .d.ts files
})

and run the unbuild command:

$ unbuild
$ 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 into Nuxt 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 in unbuild (so call Passive watcher) does not require you are have another process for that at all. By calling the following command only once:

$ unbuild --stub
$ 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.mjs
import jiti from 'jiti'
export default jiti(null, { interopDefault: true })('/Users/antfu/unbuild-test/src/index')
// dist/index.mjs
import jiti from 'jiti'
export default jiti(null, { interopDefault: true })('/Users/antfu/unbuild-test/src/index')
// dist/index.cjs
module.exports = require('jiti')(null, { interopDefault: true })('/Users/antfu/unbuild-test/src/index')
// dist/index.cjs
module.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 of jiti - 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 by mkdist - 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 in unbuild will look like:

// build.config.ts
import { defineBuildConfig } from 'unbuild'

export default defineBuildConfig({
  entries: [
    // bundling
    'src/index',
    // bundleless, or just copy assets
    { input: 'src/components/', outDir: 'dist/components' },
  ],
  declaration: true,
})
// build.config.ts
import { 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 component MyComponent.vue under src/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>
<!-- 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 a MyComponent.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 -->
<template>
  <div>{{ count }}</div>
</template>

<script>
const count = 0
export default {
  data: () => ({ count })
}
</script>
// dist/components/MyComponent.vue.d.ts
declare const _default: {
  data: () => {
    count: number | string
  }
}
export default _default
// dist/components/MyComponent.vue.d.ts
declare 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 in package.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 use import.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 about Isomorphic __dirname:

import { dirname } from 'path'
import { fileURLToPath } from 'url'

const _dirname = typeof __dirname !== 'undefined'
  ? __dirname
  : dirname(fileURLToPath(import.meta.url))
import { dirname } from 'path'
import { fileURLToPath } from 'url'

const _dirname = typeof __dirname !== 'undefined'
  ? __dirname
  : dirname(fileURLToPath(import.meta.url))

For require and require.resolve, you can use

import { createRequire } from 'module'

const require = createRequire(import.meta.url)
import { createRequire } from 'module'

const require = createRequire(import.meta.url)

Some good news, if you are using unbuild, you can turn on the cjsBridge flag and unbuild will shims those CJS context in ESM automatically for you!.

import { defineBuildConfig } from 'unbuild'

export default defineBuildConfig({
  cjsBridge: true, // <--
})
import { defineBuildConfig } from 'unbuild'

export default defineBuildConfig({
  cjsBridge: true, // <--
})

On the other hand, if you are using tsup, it will shims ESM’s import.meta.url for you in CJS instead.

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!

CC BY-NC-SA 4.0 2021-PRESENT © Anthony Fu