Creating an NPM Package Compatible with Multiple Module Systems

August 07, 2022

As a developer, you want your npm package to be easy to test, build, link, and run in different environments. Whether you’re creating a components library or a utility-based library, having strong foundations is essential. In this article, we’ll discuss the different module systems available in JavaScript and how to choose the right bundler to build and deploy your npm package.

Introduction to JavaScript Module Systems

JavaScript has come a long way since it was first used to create small interactions on websites. Today, it powers large applications such as single page applications, servers, mobile apps, desktop apps, and even telescopes. As the use of JavaScript has grown, so too has the need for mechanisms to split up large scripts into smaller modules that can be imported when needed.

Node.js has long had the ability to use modules, and there are several libraries and frameworks that enable module usage, such as CommonJS and AMD-based systems like RequireJS. In recent years, new module systems such as Webpack and Babel have also emerged.

One advantage of modern browsers is that they now support module functionality natively through the use of ES Modules (ESM). However, not all browsers fully support ESM, so as the developer of a library, it’s important to consider these cases. Additionally, it’s worth noting that ESM is still an experimental feature in Jest, which means that tests that import from your library may fail.

Choosing the Right Bundler

There are many bundlers to choose from, including esbuild, webpack, rollup, parcel, vite, swc and snowpack. The npm trends chart below shows the popularity of these options:

bundlers on the npm trends chart

When selecting a bundler, it’s important to consider the following questions:

How Much Time Does it Take to Build a Library?

In my experience, esbuild has been the fastest bundler to build a medium to large library. Its official website even includes a benchmark that demonstrates its speed:

bundlers on the npm trends chart

Does the Bundler Support Tree Shaking?

Tree shaking is a compiler optimization that removes unreachable code, also known as “dead code.” This is important for apps that import your library, as including unreachable code can result in larger bundle sizes.

Does the Bundler Support Code Splitting?

Code splitting involves dividing the bundle into smaller chunks that can be combined and loaded on demand. Reusable code is loaded once and used as needed.

Many libraries have an index.{js,ts} file that reexports all of the folder’s content to avoid having to import specific filenames. However, importing a function from an index file actually imports all of them, which can be inefficient if you only need one function. This can also cause issues with stateful and stateless functions in the same folder, such as the lack of provider initialization. Most bundlers aim to generate a single output file that contains all of the bundled code, but some, like Rollup, allow you to split the code into multiple outputs.

Is the Bundler Easy to Configure?

Some bundlers, such as Parcel, have minimal configuration requirements, while others, like Webpack, can be more complex to set up. Consider your own expertise and the needs of your project when deciding on a bundler.

Does the Bundler Offer Good Documentation and Support?

When evaluating different bundlers, it’s important to consider the quality of their documentation and support. Poor documentation can make it difficult to get started with a new tool, while a lack of support can leave you stuck when you encounter issues. Look for bundlers that have detailed documentation, active communities, and responsive maintainers.

Can the Bundler Compile My Assets?

In addition to JavaScript files, many projects also include other types of assets such as SVG files, SASS files, and more. It’s important to ensure that your bundler is able to compile these assets as well. Most bundlers offer support for a wide range of asset types, but it’s always a good idea to double check and make sure that your specific needs are covered.

Can the Bbundler Compile TypeScript Files?

In addition to understanding the basic syntax and features of TypeScript, it is also important for the bundler to be able to properly handle more advanced elements such as interfaces, types, and definition types. These features can add an extra level of type safety and code organization to your project, and it is essential that the bundler is able to properly incorporate them into the build process. Without this capability, your project may suffer from errors or inconsistencies that could impact its overall quality and stability.

Setting things up

After carefully evaluating the options and considering the needs of different environments, I have decided to use esbuild for my project. esbuild is renowned for its extremely fast speed and has a comprehensive plugin system.

I have created a boilerplate on GitHub that demonstrates how I have configured esbuild for a simple library containing a few basic components. When building the library, esbuild generates a folder with a structure like the one shown below, including separate build folders for ESM and CJS, as well as a types folder for definition types.

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

The esbuild build script includes the following code:

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));
  };
};

It’s worth noting that esbuild does not currently fully support code splitting when building for CommonJS, but this feature is in their roadmap and is expected to be available soon.

Here are some additional details about specific aspects of the build script:

  1. The line const files = glob.sync("{./src/**/!(*.test).ts, ./src/**/!(*.test).tsx}"); obtains the paths to all files in the project that match the specified format and tries to build them. This is done to achieve code splitting per file, as mentioned earlier.
  2. The nodeExternalsPlugin() plugin is based on webpack-node-externals and its purpose is to exclude npm packages from the bundling process.
  3. The dtsPlugin() is an alternative to running tsc to generate definition types, and it runs much faster

Dynamically loading ESM and CJS

When creating an npm package that is compatible with multiple module systems, it is important to be able to dynamically load the correct folder for each system. To do this, you will need to include certain properties in your package.json file.

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

These properties specify the folder that should be imported for each module system. The “main” property is used by Node.js, while the “module” property is used by modern JavaScript bundlers such as Webpack and Rollup.

By including these properties in your package.json file, you can ensure that your package can be easily used by a wide range of applications and environments. It’s worth noting that the “module” property is the recommended way to specify the entry point for modern JavaScript bundlers, as it allows for faster and more efficient builds. However, including the “main” property as well can provide backwards compatibility for older systems and help ensure that your package is widely compatible.”

Peer Dependencies

Depending on the library you are building, it may not be necessary to include all required packages as dependencies. For example, if you are building a library that relies on React, you can assume that the host application has already installed React, so you don’t need to install it again. This approach can significantly reduce the bundle size of your library, as fewer packages need to be bundled.

To specify that a package is a peer dependency, you should first install it as a devDependency, as it will be needed during development. Then, you can include it in the “peerDependencies” attribute in your package.json file, as shown below:

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

This will inform users of your library that they need to install the specified packages in order to use your library. It is important to carefully consider which packages should be specified as peer dependencies, as they will not be automatically installed when your library is installed.”

Conclusion

In conclusion, creating an npm package that is compatible with multiple module systems requires careful planning and consideration. By choosing the right bundler, ensuring that your package can be dynamically loaded by different module systems, and properly specifying dependencies and peer dependencies, you can ensure that your package can be easily used by a wide range of applications and environments. By following these best practices, you can create an npm package that is flexible, efficient, and easy to use for developers.


Profile picture

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