Logo

dev-resources.site

for different kinds of informations.

Building NPM packages for CommonJS with ESM dependencies

Published at
7/16/2024
Categories
javascript
typescript
npm
esm
Author
ryan_pate_f494c0931176673
Categories
4 categories in total
javascript
open
typescript
open
npm
open
esm
open
Author
25 person written this
ryan_pate_f494c0931176673
open
Building NPM packages for CommonJS with ESM dependencies

TLDR

You have to use a bundler such as esbuild which will compile your project and bundle all of it's dependencies along with it so they aren't imported. This bypasses the ESM/CommonJS incompatibility issue.

If you're impatient, you can go straight to the code with this example implementation.

Context

While preparing to release my new project Token.js over the weekend, I ran into a quite frustrating problem. I wanted my package to support CommonJS in addition to ESM, but I had pure ESM dependencies. The pure ESM crusaders out there might be quite unhappy about me saying it, but if you are building an NPM package and want it to be widely used you still need to support CommonJS in 2024.

Token.js is a simple TypeScript SDK that allows you to integrate 60+ LLMs from 9 different providers (OpenAI, Anthropic, Cohere, etc). Shameless plug, check it out and let me know what you think if you're into generative ai.

Now there are a number of resources online discussing how to build Javascript projects for ESM, CommonJS, or both. However, I specifically had trouble dealing with the fact that I had dependencies that were pure ESM. I found this quite difficult to deal with because I'm not familiar with bundlers (I've mostly worked on webapp backends), and was not able to find a good guide on the topic.

So if anyone else is running into this issue, here's the solution.

Guide

Install esbuild

We'll be using esbuild for the bundler.

yarn add esbuild --save-dev
Enter fullscreen mode Exit fullscreen mode

Create a build script

We'll need a simple build script to run esbuild and output the results.

import esbuild from 'esbuild'

const entrypoint = "<your entrypoint here>"
const tsconfig = "<your tsconfig path here>"

const build = async () => {
  await Promise.all([
    // bundle for commonjs
    esbuild.build({
      entryPoints: [entrypoint],
      bundle: true,
      minify: true,
      format: 'cjs',
      outfile: `./dist/index.cjs`,
      platform: 'node',
      treeShaking: true,
      tsconfig,
    }),
  ])
}

build()
Enter fullscreen mode Exit fullscreen mode

Add a build script to your package.json

Run with your preferred runtime.

"scripts": {
  "build": "vite-node ./scripts/build.ts",
}
Enter fullscreen mode Exit fullscreen mode

I personally love vite-node. So if you want to follow along exactly, you'll need to install that:

yarn add vite-node --save-dev
Enter fullscreen mode Exit fullscreen mode

Build your project

yarn build
Enter fullscreen mode Exit fullscreen mode

This will cause build your project with esbuild and you'll see a new file, dist/index.cjs, which is the CommonJS build of your package.

Configure entrypoint

Update your package.json to point to your CommonJS entrypoint.

"main": "dist/index.cjs",
Enter fullscreen mode Exit fullscreen mode

Bam! There you go, you've now built your package for CommonJS. This will work even if you have ESM dependencies because the dependencies will be bundled
along with your package.

The dependencies are included in the output because of the field bundle: true when esbuild is called.

TypeScript declarations

Though technically not required, you will very likely also want TypeScript declarations which esbuild unfortunately does not output at this time. So to generate
those, you'll want to use normal tsc.

Update your tsconfig.json

Adding these options to your tsconfig.json file will cause only the TypeScript declarations to be output. This is exactly what we want since the rest of the package
is being built with esbuild.

"declaration": true,
"declarationDir": "./dist",
"emitDeclarationOnly": true
Enter fullscreen mode Exit fullscreen mode

Update your build script

"scripts": {
  "build:tsc": "yarn tsc -p tsconfig.json",
  "build": "vite-node ./scripts/build.ts && yarn build:tsc",
}
Enter fullscreen mode Exit fullscreen mode

Dual Entrypoints

This guide recommends only outputting a single CommonJS entrypoint for your package. Personally, I think this is the best option for two reasons:

However, this is not the only option. You could also publish your package with dual entrypoints for CommonJS and ESM.

Update your build script to include an ESM build

import esbuild from 'esbuild'

const entrypoint = "<your entrypoint here>"
const tsconfig = "<your tsconfig path here>"

const build = async () => {
  await Promise.all([
    // bundle for commonjs
    esbuild.build({
      entryPoints: [entrypoint],
      bundle: true,
      minify: true,
      format: 'cjs',
      outfile: `./dist/index.cjs`,
      platform: 'node',
      treeShaking: true,
      tsconfig,
    }),
    // bundle for ESM
    esbuild.build({
      entryPoints: [entrypoint],
      bundle: true,
      minify: true,
      format: 'esm',
      outfile: `./dist/index.js`,
      platform: 'node',
      treeShaking: true,
      tsconfig,
    }),
  ])
}

build()
Enter fullscreen mode Exit fullscreen mode

Update your package.json file to include dual entrypoints

"main": "dist/index.cjs",
"module": "dist/index.js",
"type": "module",
"exports": {
  ".": {
    "import": "./dist/index.js",
    "require": "./dist/index.cjs",
    "types": "./dist/index.d.ts"
  }
},
Enter fullscreen mode Exit fullscreen mode

Source code

esm Article's
30 articles in total
Favicon
Bundling without a bundler with esm.sh
Favicon
Building NPM packages for CommonJS with ESM dependencies
Favicon
Web Development Without (Build) Tooling
Favicon
Dual Node TypeScript Packages - The Easy Way
Favicon
Oh CommonJS! Why are you mESMing with me?! Reasons to ditch CommonJS
Favicon
The Ongoing War Between CJS & ESM: A Tale of Two Module Systems
Favicon
How I optimized Carousel for EditorJS 2x in size.
Favicon
Transitioning from CommonJS to ESM
Favicon
Node.js, TypeScript and ESM: it doesn't have to be painful
Favicon
Set up Hot Reload for Typescript ESM projects
Favicon
Set up a Node.js project + TypeScript + Jest using ES Modules
Favicon
ESM & CJS: The subtle shift in bundlejs' behaviour
Favicon
Mastering the Art of ESM and CJS Package Handling
Favicon
Modules & Modules & Modules, Oh My!
Favicon
How to build TypeScript to ESM and CommonJS
Favicon
ES Modules & Import Maps: Back to the Future
Favicon
How to use ESM on the web and in Node.js
Favicon
Custom ESM loaders: Who, what, when, where, why, how
Favicon
Fix NX Node executor ERR_REQUIRE_ESM Error
Favicon
Creating a Node.js module for both CommonJS & ESM consumption
Favicon
STOP using require() in node backend
Favicon
JavaScript Module Ecosystem
Favicon
Declarative database modelling
Favicon
Expressjs: Javascript written in ECMAScript 2015 (ES6)
Favicon
How to use ES Modules with Node.js
Favicon
What does it take to support Node.js ESM?
Favicon
Build modular app with Alpine.js
Favicon
TS and ts-jest meet β€œtype”: β€œmodule”
Favicon
ESM doesn't need to break the ecosystem
Favicon
constructor() dynamic import()

Featured ones: