Rolling (up) a multi module system (esm, cjs...) compatible npm library with TypeScript and Babel

remshams

Mathias Remshardt

Posted on February 7, 2021

Rolling (up) a multi module system (esm, cjs...) compatible npm library with TypeScript and Babel

In this article we will delve into the build chain and build steps necessary to create the artifacts required to publish a library on npm. Our goal will be to provide our library consumers with a versatile package supporting (modern/legacy) JavaScript/TypeScript as well as the most common module systems.
What has been written is based on my learnings and research when creating packages and is also meant to be documentation for myself. The process is still in flux, so every feedback (ideas for improvements, critics...) is, as always, very welcome.

Overview

The first section lists and explains the requirements for the build process as well as the artifacts it produces. Related to this, we will also answer the question if a bundled version is required for each of the supported module systems.

With the requirements ready, the build chain and, most important, the steps for creating the necessary library artifacts will be laid out.

As demonstration defeats discussion, we will look at the implementation of the sketched out build chain with help of an example "library". In the end there will be a deployment ready package, hopefully fulfilling all listed requirements.

As our focus lies on packing itself, the "features" of the example library are irrelevant and are therefore kept extremely simple.

The explanations provided are based on my current understanding of the topics and may be opinionated or incomplete (hopefully not wrong). In addition, every package is unique and therefor its/your requirements and the resulting process can differ from what has been written here. However, I have tried to keep the information as overall applicable as possible. As mentioned in the beginning, feed back is very welcome.

That being said, let's start with the requirements for our build artifacts.

Requirements

JavaScript/TypeScript

To me, one important goal was to make the modernly written, not transpilled library code available for further processing. This helps e.g. to decrease bundle sizes, as downstream consumers can base their build chain on the most current/common JavaScript version and only transpile the code to the language level required by their browser- or node version needs.

However, for consumers not able to leverage modern JavaScript, an ES5 based version sacrificing the latest features must be provided.

In case TypeScript is used, a transpilled JavaScript version should be supplied as well, so we do not enforce unnecessary restrictions to consumers by our language choice. "Types" will be provided as separate type definition files.

Module system

Next to modern JavaScript, the library must support all current/common module systems. At the time of writing these are "ECMAScript Modul" (esm), "CommonJs" (cjs) and "Asynchronous Module Definition" (AMD).

Especially supporting esm is important to allow tree shaking support for consumers using bundlers like Rollup or webpack. So even when transpilled to legacy JavaScript, leveraging esm is still beneficial (as described here).

To bundle or not to bundle...

Bundling is usually applied when writing JavaScript for the client (e.g. Single Page Applications) as it avoids too many round trips to the server (especially before HTTP/2 arrived) by delivering everything in a single file. However, with multiplexing and server side push now being available in HTTP/2, the questions is a bit more controversial today.

If we take into account that downstream build systems further process and bundle the library code, the npm package should contain an unbundled artifact for all supported module systems with the most modern JavaScript version possible. This gives our consumers the flexibility to shape the library code based on their needs (e.g. supported browser versions) helping them to reduce the amount of shipped code by avoiding e.g. unnecessary transpilling.

So if the library code is further processed by downstream consumers, one may ask the question if we need to create a bundled version at all? I sifted through different (popular and not so popular) npm packages and some of these are bundling, while others are not. Also reading blog posts and tutorials did not give a unambiguous answer, leaving me more confused then before.

Therefor I decided to look at each module system individually combined with whether it is used on the client or server. My hope was that I'd find some enlightenment when narrowing done the question...
Next you find the reasoning I finally came up with.

ECMAScript Modules

Browser

When esm based library artifacts are consumed by e.g. SPAs something like webpack or Rollup should be in place. Further processing, like tree-shaking, bundling, minifying..., is therefor better left to the downstream build process.

So I originally decided not to include a bundled esm version. But, when reading about the reasoning for providing a bundled umd artifact (described in the section below) I thought about doing the same for esm. It does sound counterintuitive at first, I mean what benefit do we get from a modern module system when everything is bundled to a single file. What we do get however, is all the modern JavaScript available for library code written in ES6+ syntax. This means modern browser can choose the bundled esm version instead of umd for direct import, avoiding all the additional code created to make our library code compatible with previous JavaScript versions. One could argue that in such a case the unbundled artifact could be imported. However, there still could be use cases for the bundled alternative e.g. in case HTTP/2 is not available and therefor loading a lots of files is not a performant option.

Node

In case the server application uses a current node version, same reasoning as for the browser applies.

However, the server can directly load the files from disk which should have almost no performance impact compared to the http request the browser has to perform. So I don't see any reason for using the bundled version here, even if no additional build process is in place.

CommonJs

Browser

Same arguments as for esm: Bundling should not be required as the imported library is always further processed by downstream build systems.
The only reason why client applications could/should use the cjs instead of the esm version is in case of an older bundler which does not understand the latter. In all other cases esm is the preferred option as the tree shaking support is superior to cjs.

Node

Again no difference to esm. However, by including a cjs version we ensure older node versions are also supported, so no additional/extra transpilling step is required for library consumers.

UMD

We will discuss the bundling question for umd instead of amd, as the latter supports both amd and cjs in a single artifact.

Browser

For me, the bundling question was a bit harder to answer for umd, as I have most often worked in environments (usually SPAs) where either cjs and/or esm has been used in combination with a dedicated bundler.

The reason for including a bundled umd version is to support direct usage (with no further processing) in (older) browsers e.g. from something like unpkg. Modern browser, as described above, can use the bundled esm version.

However, when a bundling step is performed downstream, it should always either use esm or cjs making an unbundled version superfluous.

Node

Node can always use either esm or cjs. So in case these are included in the npm package there seems to be no reason to provide a special, unbundled umd version for node. It provides no benefit over the bundled variant already considered required in order to cover all use cases.

My final impression regarding umd and server applications is, that it makes sense if one wants to include only a single version of the library. However, since npm packages and bundlers (now) support including multiple versions and creating these is not much effort, there seems to be no reason for restricting library consumers to just umd.

Conclusion

This brings us the conclusion that a bundled version is only required for esm and umd. For all other module system bundling is not a necessity, which finally leads to the following list of library artifacts:

  • an unbundled esm version
  • an bundled esm version
  • an unbundled cjs version
  • a bundled umd version

These four variants should cover most of our consumers use cases without restricting their build processes and, most importantly, not forcing them to ship unnecessary JavaScript code.

Having the bundle/not bundle question out of the way, we will next define the build chain and its steps to create the listed artifacts.

Build chain

The diagram below gives an overview of the steps required to go from our written source code (TypeScript for the example library) to the artifacts described in the previous section. The image also shows how the created results are referenced in the package.json. This is important as it makes downstream bundlers "aware" of the available versions allowing them to choose the most appropriate one (e.g. esm over cjs for better tree shaking support).

Build chain overview

Diagrams often read kind of abstract before knowing the details and this one is no exception. Therefor, when next going through the process and its artifacts, excerpts from the example library (e.g. configuration files) are referenced to provide additional details.

One note regarding the employed build tools mentioned in the diagram: I tried to use the most common ones for this/my build chain fulfilling the requirements listed earlier. These can of course be replaced by your own choice e.g. tsc instead of babel when compiling TypeScript.

Building the library artifacts

The build steps next described need to get us from our source to the four target build artifacts defined in the previous section. For the example application this means going from TypeScript to esm (bundled and unbundled), cjs (unbundled) and umd (bundled).

The two main steps required are transpilling and bundling. The latter is of course only needed when the final build artifact is a bundle.

Transpilling

With the example application written in TypeScript, our first step is to go to the target JavaScript versions. Usually this can either be done by using tsc or, as of late, babel (with help of the @babel/typescript plugin).

I opted for the latter as it, in my opinion, provides more flexibility compared to tsc when configuring the transpilation/compilation step (e.g. tsc requires a specific target JavaScript version where as in babel it can be defined based on browsers market share, versions and the like). In addition, with the support of TypeScript in Babel, we can now use almost the same build chain for JavaScript or TypeScript projects helping to unify/simplify the process.

The exact Babel configuration is somehow specific for each individual library/project and/or requirements. For the example library we only require two babel plugins:

  • @babel/typescript: To go from TypeScript to JavaScript
  • @babel/env: To get down to the JavaScript version fulfilling the configuration we opted for (e.g. supported browsers and node versions)

A description of the two plugins and the available configurations is out of scope of the article. Therefor, I only quickly note why a property has been set like that and the reasoning behind it.

Especially the @babel/env plugin provides a lot of flexibility, so in case you are interested in more details the two provided links should make for a good starting point.

Having that said, the configuration for the example library looks like the following:

const sharedPresets = ['@babel/typescript'];
const shared = {
  ignore: ['src/**/*.spec.ts'],
  presets: sharedPresets
}

module.exports = {
  env: {
    esmUnbundled: shared,
    esmBundled: {
      ...shared,
      presets: [['@babel/env', {
        targets: "> 0.25%, not dead"
      }], ...sharedPresets],
    },
    cjs: {
      ...shared,
      presets: [['@babel/env', {
        modules: 'commonjs'
      }], ...sharedPresets],
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

We are using three Babel environments here:

  • esmUnbundled: The environment only goes from TypeScript to JavaScript and keeps the rest of the code in place. This is on purpose as it makes the most modern version of the library available to our consumers for further processing.
  • esmBundled: In addition to what is done in unbundled, the bundled environment transpiles to JavaScript supported by the majority of browsers/node versions. I opted against transpilling completely down to ES2015 as older browser can use the umd alternative when directly importing the library.
  • cjs: Again, the environment is similar to es-unbundled, with the only difference that esm is replaced by commonjs with the help of @babel/env

To execute the Babel transpilation, two scripts have been defined in the package.json:

{
  ...
  "build:esm": "cross-env BABEL_ENV=esmUnbundled babel src --extensions '.ts' --out-dir 'lib/esm' --source-maps",
  "build:cjs": "cross-env BABEL_ENV=cjs babel src --extensions '.ts' --out-dir 'lib/cjs' --source-maps"
  ...
}
Enter fullscreen mode Exit fullscreen mode

At the time of writing, source maps seem not to be generated when configured in .babelrc which is why --source-maps has been added.

Running the scripts gives the following result:

Dist folder with unbundled artifacts

Unsurprisingly, the esm folder contains the unbundled esm and cjs the unbundled cjs artifact.

For the unbundled case we are almost done. What is missing is a reference to our index.js entry files from to package.json to make Bundlers aware of the available versions.

As described in detail here we need to:

  1. Set the main property to our cjs index.js and the module property to the esm index.js
  2. Set the appropriate properties in exports
    • require again to the cjs index.js
    • import again to the esm index.js
{
  ....
  "main": "lib/cjs/index.js",
  "module": "lib/esm/index.js",
  "exports": {
    "require": "./lib/cjs/index.js",
    "import": "./lib/esm/index.js"
  }
  ....
}
Enter fullscreen mode Exit fullscreen mode

Having the package.json setup like that, Bundlers can now choose whatever alternative is best supported. For example modern ones can take the esm artifact whereas as older ones (not supporting the new module and exports property) fall back to what is referenced in main.

To finalize our package we will next look how to generate the bundled artifacts for esm and umd.

Bundling

To bundle our library we need a ... Bundler. I chose Rollup for the job since it has good support for creating different versions for each module system from a single entry file. Of course it can again be replaced by whatever Bundler you prefer as long as it bundles to the required module systems and also comes with a plugin for the Transpiler, Terser... of your choice.

As shown in the overview from the beginning of this section, there is not much difference between the build steps of the unbundled and bundled versions:

  • the Bundler takes care of orchestrating the build process and build tools (like the Transpiler), so no need to call these "individually"
  • an additional bundling step is added to the end of the build chain

For the example library, the Rollup configuration looks like this:

import babel from '@rollup/plugin-babel';
import resolve from '@rollup/plugin-node-resolve';
import { terser } from "rollup-plugin-terser";

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

export default  {
  input: 'src/index.ts',
  output: [
    {
      file: 'lib/bundles/bundle.esm.js',
      format: 'esm',
      sourcemap: true
    },
    {
      file: 'lib/bundles/bundle.esm.min.js',
      format: 'esm',
      plugins: [terser()],
      sourcemap: true
    },
    {
      file: 'lib/bundles/bundle.umd.js',
      format: 'umd',
      name: 'myLibrary',
      sourcemap: true
    },
    {
      file: 'lib/bundles/bundle.umd.min.js',
      format: 'umd',
      name: 'myLibrary',
      plugins: [terser()],
      sourcemap: true
    }
  ],
  plugins: [
    resolve({ extensions }),
    babel({ babelHelpers: 'bundled', include: ['src/**/*.ts'], extensions, exclude: './node_modules/**'})
  ]
}
Enter fullscreen mode Exit fullscreen mode

There is nothing too fancy going on:

The input property points to the entry index.ts and output defines the configurations for both esm (normal/minified) and umd(normal/minified). Additionally, the sourcemap attribute has been added and set to true to create external source map files. The name property for the umd version defines the namespace for the exported functions (e.g. myLibrary.echo() for the example library).

For the build itself we require three plugins:

  • @rollup/plugin-node-resolve: The plugin adds support to resolve imports to other node packages. This is not required for the example library (as no other dependency is used) but has been added since it is not unlikely to occur for more complex packages.
  • @rollup/plugin-babel: Triggers the transpile step through Babel (basically what we have done by means of the babel-cli for the unbundled versions). As we are using babel only for the bundled artifacts babelHelpers are set to bundled, so in case any helpers are needed these are added to the bundle file (you can read more about the property in the documentation). In include and extensions the files and their extensions (ts/js for the example library) to process are defined, whereasexcludes indicates folders/patterns which should be skipped (just the node_modules folder for the example library).
  • rollup-plugin-terser: Used for minification and therefor only added for the minified outputs. This is optional and can be left out in case not wanted or required.

Executing the Rollup process by using the added package.json script build:bundles produces the following result:

Dist folder with bundled artifacts

An new folder bundles has been created containing the esm and umd artifacts. In contrast to the unbundled ones, there is no need/means to reference the former from the package.json as these will be directly imported and are not meant for further processing.

We now have all required "code" artifacts available for the package. The last thing missing is creating type definitions, so that clients using TypeScript can easily integrate the library.

Types

Babel currently "only" transpiles our TypeScript code to JavaScript. Therefor, as shown in the overview diagram, a dedicated build step is required for creating the type definition files using tsc.

As we already have the transpiled JavaScript code, our tsconfig.json can be kept pretty simple:

{
  "compilerOptions": {
    "declaration": true,
    "emitDeclarationOnly": true,
    "declarationMap": true,
    "outDir": "lib/types",
  },
  "include": [
    "./src/index.ts"
  ],
}
Enter fullscreen mode Exit fullscreen mode

With the declarations and emitDeclarationOnly set to true, tsc only creates declarations files and skips transpilling to JavaScript. The result is then put into the folder defined by outDir.

We also should not miss to create mappings between the *.d.ts and *.ts files, enabling IDEs like VSCode or IntelliJ to navigate directly to the source instead of the declarations files e.g. on CMD + click/Strg + click on a method or property name. This is simply done by adding the declarationMap to the tsconfig.json and setting it again to true.

The script declarations has been added to the package.json to trigger tsc, which will create the declaration files in the types folder (as defined by outDir):

Dist folder with TypeScript types

As a final step we link the index.d.ts file in the package.json by means of the types property, helping IDEs to discover the types:

{
  "types": "lib/types/index.d.ts"
}
Enter fullscreen mode Exit fullscreen mode

With the unbundled-, bundled library versions and type declarations created, we now have a library ready for being published on npm. Since there are numerous posts out there explaining this final step (and the example application is pretty useless) we will not go further into this.

So time for wrapping up...

Conclusion

The goal for this article was to create a versatile build chain to allow creating libraries that:

  • provide raw, non transpilled artifacts based on modern JavaScript or TypeScript which can be further processed by downstream build chains
  • provide an unbundled- (for consumers using Bundlers) and bundled (for direct usage/import) version
  • support all modern and legacy module systems

With the listed requirements ready, we sketched the build steps and setup necessary to create our library artifacts.

To make the theoretical overview more tangible the process has been described based on a simple example library. This included a possible choice of tools required to realize the build chain and creating the artifacts necessary to fulfill our initial goals.

Appendix

Testing locally

To test the example library locally I have created a separate "testing repository". The setup and link procedure is as follows:

  • Example Library
    • Run npm install
    • Run npm run build
  • Testing Repo

    • Use npm link to link to the locally available example library e.g. in case both projects are siblings in the folder structure the command is npm link ../node-module-esm (a more detailed description can be found e.g. here)
    • Run npm install
    • Run npm start (this starts a local http-server)
    • Open localhost:8080 in the browser of your choice
    • Navigate to src
    • The then opened index.html includes imports of umd bundled, esm bundled and esm unbundled from the example library giving the following result:

    Screenshot testing index.html

💖 💪 🙅 🚩
remshams
Mathias Remshardt

Posted on February 7, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related