Writing JavaScript modules consumable as esm or cjs

So you want to write a library? Oh, and you want to leverage some ultimate-mode TC39 syntax? AND you want to make your code consumable in a variety of module formats? Well, friend... a few things.

Most front-end developers know that we can’t just write JavaScript using the latest features and expect it to work across any environment. That’s why we use tools like babeljs to transpile our source code into ye olde JavaScript of 200X. Good on ya’.

When we’re writing libraries, we have the problem of not knowing what module format will acceptable to applications that use our library. Additionally, we need to consider how our library will impact bundle sizes.

That being the case, I like to build to multiple formats if I’m going to distribute my library on npm. This isn’t the easiest thing to do, and there’s a few ways to skin it, but it’s not too bad with a little boiler-plate and tools like rollup and Webpack.

webpack or rollup

If we’re writing a library, deciding is easy. Use rollup. I don’t know of a way to transpile to esm using webpack. However, I’m still including examples of webpack configurations, as I think it exposes a pattern that can be super useful, namely extensible configuration. I’ll likely write about that pattern in the future.

Goals

  • Deliver a library in multiple module formats (umd/cjs/esm)
  • Deliver a library that is transpiled, regardless of module format
  • Keep our library size down by omitting dependencies
  • Communicate module formats to folks that use the library

Extensible configuration

Both webpack and rollup are configurable to build to different module formats. Whichever we choose, we’ll want to create a base configuration that can be extended with information about what module format to target.

  // base.js

  // Both tools require a config object that has an `entry`
   export default {
    entry: 'src/index.js',
    // etc... This will differ based on what build tool we choose
  }

Now, we need to write configurations for multiple module formats by extending our base configuration. To do this, we use the trusty Object.assign. Notice that both of these examples export arrays.

  // webpack.config.js

  const baseConfig = require('./base.js')

  // umd
  const umd = Object.assign(
    {},
    baseConfig,
    {
      output: {
      filename: '[name].js',
      libraryTarget: 'umd'
      path: path.resolve(__dirname, 'dist/umd')
    }
  )

  // cjs
  const cjs = Object.assign(
    {},
    baseConfig,
    {
      output: {
      filename: '[name].js',
      libraryTarget: 'commonjs',
      path: path.resolve(__dirname, 'dist/cjs')
    }
  )

  // yep yep
  module.exports = [ cjs, umd ]
  // rollup.config.js

  import base form './base.js'

  const esm = Object.assign(
    {},
    base,
    {
      output: {
        file: 'dist/esm/index.js',
        format: 'es'
      }
    }
  )

  const cjs = Object.assign(
    {},
    base,
    {
      output: {
        file: 'dist/cjs/index.js',
        format: 'cjs'
      }
    }
  )

  export default [ cjs, esm]

Omit dependencies

Bundle sizes… yeesh. webpack and rollup both have the ability to exclude module dependencies from the bundles that are generated, a feature that’s very useful for libraries. By using it, we’ll reduce duplicate module code in applications that consume our library.

  // rollup
  const rollupBase = {
    external: [
      'debug',
      'rxjs/Observable',
      'rxjs/Rx',
      'rxjs/add/observable/fromEvent',
      'rxjs/add/operator/take'
    ]
  }

  // webpack
  const webpackBase = {
    externals: {
      'debug': 'debug',
      'rxjs/Rx': {
        commonjs: 'rxjs/Rx',
        commonjs2: 'rxjs/Rx',
        amd: 'Rx',
        root: 'Rx'
      }
    }
  }

Build it.

Now we just need a build script in package.json, and we can generate our distributable modules with npm run build.

  // package.json

  {
    "scripts": {
      "build": "npx rollup -c rollup.config.js"
    }
  }

Communicate module options

Now that we have a library that delivers a few different formats, we need to make sure our package.json points at things correctly.

main absolutely has to point at a cjs or umd format. Otherwise, we’ll be sort of breaking faith with the wider npm ecosystem.

Some build tools know to use module instead of main when importing modules. We’ll point module at our esm code, which is explicitly not our source code. Why? Well, we have no guarantee that folks consuming our library will have the appropriate transforms in their build pipeline, but we can still deliver tree-shakeable code, which once again helps reduce the bundle-size cost of using our library.

  // package.json

  {
    "main": "dist/cjs/index.js",
    "module": "dist/esm/index.js"
  }

Finally, good documentation about the various module formats is welcome. For instance, a umd build might not be exposed at all in package.json, but letting folks know where to get it is a nice-person move.

  <!-- README.md -->

  # Which formats?
  Blah blah blah. 

  If you want the `umd` build, grab it from `module/dist/umd`.
  

Real life

If you want to see a real life example of this, check out my observable-socket library. At the time I refactored it to deliver better module formats, I don’t believe rollup had the ability to receive an array of configurations, so the build script is quite a bit different.