Bundling CommonJS into an ES Module

August 19, 2020 • 4 minute read

Photo by Kelli McClintock on Unsplash

Recently, I’ve made some changes to the rxjs-spy distribution — so that it’s consumable as an ES module — and I’m documenting the process as a blog post in case I need to do something like this again.

The problem surfaced with the release of Angular 10. When a non-ES module dependency is consumed, the Angular CLI effects the following warning:

WARNING in some-app depends on 'rxjs-spy/operators'.
CommonJS or AMD dependencies can cause optimization bailouts.
For more info see:

To prevent this, the rxjs-spy distribution needs to include a module entry in its package.json — referencing an ES module export.

Generating ES module output from the TypeScript source is straightforward and most of my libraries are distributed with CommonJS and ES module exports. However, there was a problem: rxjs-spy depends upon several packages that do not provide ES module exports.

I wanted to spend as little time as possible solving the problem — as rxjs-spy is soon going to be replaced with RxJS Tools — so I decided to use Rollup to generate two ES module bundles: one for the spy infrastructure and one for the operators.

This bundled approach means that the CommonJS modules depended upon by rxjs-spy are included in the ES module bundles in their entirety. It’s relatively simple, but it has a downside: if the application itself consumes the CommonJS modules bundled within rxjs-spy it will contain duplicated code — as the modules won’t be shared.

However, it’s not really an issue here, as the CommonJS modules are only used in the spy bundle — not the operator bundle — and the spy bundle essentially development-build-only.

The Rollup configuration file for the spy bundle looks like this:

import babel from "@rollup/plugin-babel";
import commonjs from "@rollup/plugin-commonjs";
import json from "@rollup/plugin-json";
import replace from "@rollup/plugin-replace";
import resolve from "@rollup/plugin-node-resolve";
import pack from "./package.json";

const extensions = [".js", ".ts"];

export default {
  external: ["rxjs", "rxjs/operators"],
  input: "source/index.ts",
  output: [
      file: "dist/esm/index.js",
      format: "esm",
      sourcemap: true
  plugins: [
    resolve({ extensions }),
    babel({ babelHelpers: "bundled", extensions }),
      __RX_SPY_VERSION__: `"${pack.version}"`

The configuration is relatively straightforward:

  • @rollup/plugin-json enables the consumption of JSON files as modules.
  • @rollup/plugin-node-resolve enables node_modules consumption.
  • @rollup/plugin-commonjs enables the consumption of CommonJS modules.
  • @rollup/plugin-babel compiles the TypeScript files to JavaScript.
  • @rollup/plugin-replace performs a string replacement of a version placeholder.
  • The externals setting ensures that RxJS imports are left intact, so that RxJS is modules are not included in the ES bundle.

The configuration for the operators bundle is similar. The only real difference is that it doesn’t require the string replacement.

In the distribution, the spy bundle is located at esm/index.js, so the package.json contains a module setting:

  "module": "esm/index.js"

The operators bundle is located at esm/operators/index.js and to enable imports like this:

import { tag } from "rxjs-spy/operators";

there is a operators/package.json with this content:

  "main": "../cjs/operators/index.js",
  "module": "../esm/operators/index.js",
  "types": "../cjs/operators/index.d.js"

When a bundler sees rxjs-spy/operators in an import statement it will read the package.json and be redirected to either the CommonJS module, the ES module or the TypeScript type definitions, depending upon what it’s looking for.

Lastly, in order to avoid breaking changes, the distribution needs to support imports like this:

import { tag } from "rxjs-spy/operators/tag";

It manages this by including sub-directories for each operator, with those sub-directories each containing a package.json file to redirect bundlers to the appropriate file.

For example, the operators/tag/package.json file has this content:

  "main": "../../cjs/operators/tag.js",
  "module": "../esm.js",
  "types": "../../cjs/operators/tag.d.js"

And the operators/tag/esm.js file — to which the bundler is redirected — has this content:

export { tag } from "..";

Which imports the tag operator from the ES bundle in the parent directory and then re-exports it.

This package.json shenanigans will get a little simpler when the exports property is more widely supported. If you’re interested, you can read more about it here.

Nicholas Jamieson’s personal blog.
Mostly articles about RxJS, TypeScript and React.

© 2022 Nicholas Jamieson All Rights ReservedRSS