Creating an NPM package compatible with multiple module systems

August 07, 2022

Whatever npm package you are trying to create, be it a components library or a utility-based library, good fundamentals are needed to test the package easily, build it locally with the speed of light, link it to various projects, and run it to different environments.

Javascript Module System

In the beginning, Javascript was not as vastly used as it is now. It was mostly used for creating small interactions on websites, and large scripts weren’t generally needed. Today, we have large applications that run entirely on Javascript (ex: Single Page Applications). Moreover, its implementation didn’t stop with web applications but extended to servers, mobile applications, desktop applications, and even telescopes.

It has therefore made sense in recent years to come up with mechanisms for splitting Javascript scripts up into separate modules that can be imported when needed. Node.js has had this ability for a long time, and there are a number of JavaScript libraries and frameworks that enable module usage (for example, other CommonJS and AMD-based module systems like RequireJS, and more recently Webpack and Babel.)

The good news is that modern browsers have started to support module functionality natively. Unfortunately, there are still cases when ESM (ES Modules) are not supported completely and as the developers of a library, we can cover these use cases.

Additionally, note that as of now, ESM is still a experimental feature for Jest which means that if someone is writing a test and importing something from your library, there is a high chance that the test is going to fail.

Choosing the right bundler

There are numerous bundlers out there, here are a few of them:

And here is the same list of bundlers on the npm trends chart

bundlers on the npm trends chart

Choosing the right bundler is one of the most important decisions you’ll have to make when building the library. The following questions can help you in this regard:

How much time does it take for the bundler to build a medium to a large library?

I have tried to configure the build system to work with several bundlers and esbuild was by far the fastest one.

Here is the benchmark displayed on their website:

bundlers on the npm trends chart

Does the bundler support tree shaking?

Tree shaking refers to the process of eliminating the dead code, a common compiler optimization that gets rid of unreachable code. This is especially important for the apps that are importing your library since not removing unreachable code can generate a bigger bundle.

Does the bundler support code splitting?

Code splitting refers to splitting the bundle into smaller chunks that can be combined together and loaded on demand. Moreover, reusable code is loaded once and used whenever it is needed.

Usually, most folders have an index.{js,ts} file that reexports all of the folders’ content and this practice is for avoiding importing specific filenames. Unfortunately, when importing a function from an index file, you’re actually importing all of them and using only the one you need. Because of that, having stateful and stateless functions in the same folder causes problems like the lack of provider initialization. From what I’ve seen so far, most bundlers are trying to generate one single output file that contains all the bundled code, but importing something from it is going to cause a crash.

Can the bundler generate ESM and CJS outputs?

Make sure the bundler is able to generate compiled files for ESM and CJS.

Can the bundler compile my assets? (svgs, sass, etc)

Most of the projects include asset files, be they sass styles or images. Double-check if the bundler can understand those types of assets and is not going to crash during the build time.

Can the bundler compile typescript files?

The bundler should be able to know how to understand typescript files and all its specifics, such as: interfaces, types, definition types, etc.

Setting things up

Based on the above questions and the support needed for different environments, I have decided to use esbuild. It is blazingly fast, has a rich plugin system, and did I mention that it is blazingly fast?

I’m going to shortly describe the boilerplate I set up on github

Firstly, it is a fairly simple library with a few dumb components inside. When building, esbuild will generate a folder with a structure similar to this one:

dist
├── cjs
│   ├── components
│   │   ├── Box
│   │   ├── Flex
│   │   └── Text
│   ├── containers
│   │   └── ThemeProvider
│   └── utils
├── esm
│   ├── __chunks__
│   ├── components
│   │   ├── Box
│   │   ├── Flex
│   │   └── Text
│   ├── containers
│   │   └── ThemeProvider
│   └── utils
└── types
    ├── components
    │   ├── Box
    │   ├── Flex
    │   └── Text
    ├── containers
    │   └── ThemeProvider
    └── utils

which includes the build folder for ESM, the one for CJS, and all the definition types inside the types folder.

The esbuild build script looks as follows:

const esbuild = require("esbuild");
const glob = require("glob");
const { nodeExternalsPlugin } = require("esbuild-node-externals");
const { dtsPlugin } = require("esbuild-plugin-d.ts");

const buildTypes = {
  cjs: {
    splitting: false,
    format: "cjs",
  },
  esm: {
    splitting: true,
    format: "esm",
  },
};

module.exports = function (buildType = "esm") {
  const { format, splitting } = buildTypes[buildType];

  return function (customOptions = {}) {
    const files = glob.sync("{./src/**/!(*.test).ts, ./src/**/!(*.test).tsx}");

    esbuild
      .build({
        entryPoints: files,
        splitting,
        format,
        outdir: `dist/${format}`,
        treeShaking: true,
        minify: true,
        bundle: true,
        sourcemap: true,
        chunkNames: "__chunks__/[name]-[hash]",
        target: "es6",
        tsconfig: "./tsconfig.json",
        plugins: [
          nodeExternalsPlugin(),
          dtsPlugin({
            outDir: "./dist/types",
          }),
        ],
        ...customOptions,
      })
      .then(() => {
        console.log(
          "\x1b[36m%s\x1b[0m",
          `[${new Date().toLocaleTimeString()}] build succeeded for ${format} types`
        );
      })
      .catch(() => process.exit(1));
  };
};

Currently, esbuild doesn’t fully support code splitting when building for CommonJS, however, it is in their roadmap so I expect it to be ready soon.

Most of the attributes are self-explanatory. I would like to add extra details to a few of them:

  1. const files = glob.sync("{./src/**/!(*.test).ts, ./src/**/!(*.test).tsx}"); gets the paths to all the files in the project that respect the format and tries to build them. This is done in order to achieve the code splitting per file as mentioned before.
  2. nodeExternalsPlugin() plugin is inspired from webpack-node-externals and its goal is to exclude the npm packages from the bundling process
  3. dtsPlugin() is the alternative to running tsc for generating definition types but runs much faster

Dynamically loading ESM and CJS

To dynamically load the right folder for the right module system, a few properties inside package.json are needed, specifically:

"main": "./esm",
"module": "./cjs",
"browser": "./esm"

These properties specify the folder needed to be imported from for each individual module system.

Peer Dependencies

Depending on the library you are building, it might not be necessary to install all the packages as dependencies. For instance, if I’m building a react-based components library, I can assume that the host application has already installed a version of react which means that I don’t have to install it again, but instead, use the existing one. This approach reduces the bundle size drastically because fewer libraries are being compiled.

To specify that the package is peer dependency, first install it as a devDependencies, because it has to be available during development. Next, include it inside the peerDependencies attribute in package.json as follows:

"peerDependencies": {
    "react": ">= 17",
    "react-dom": ">= 17"
}

Conclusion

Setting up a library is not the easiest thing to do, it requires planning and an understanding of the environments where the library will be used. Not creating bundles for different module systems puts the effort on the developers who integrate the library, forcing them to transpile it. The good thing is that changing the bundler doesn’t have to be a critical change that would cause major refactors because, ultimately, the structure of the imports is going to be the same and most changes are happening under the hood of the library.


Profile picture

Welcome to My Frontend Ledger — a blog in which I share ideas about frontend development and the latest news in this space.