Setting up multi-platform npm packages

[2017-04-01] dev, javascript, esnext, npm, jsmodules, babel
(Ad, please don’t block)

This post is part of a series of three:

  1. Current approaches: “Setting up multi-platform npm packages”
  2. Motivating a new approach: “Transpiling dependencies with Babel
  3. Implementing the new approach: “Delivering untranspiled source code via npm

This blog post explains ways of targeting multiple platforms via the same npm package.

Before we get into the actual topic, let’s quickly review common JavaScript module formats.

Overview  

This following table gives an overview of standard properties in package.json that are used for pointing to source code.

main browser module es2015
ES version ES5+ ES5+ ES5+ ES6
Module format CJS CJS ESM ESM
webpack
Rollup (✔) (✔)
jspm

Note: “ES5+” means “whatever language features are supported by the JavaScript engines you are targeting”.

Background: JavaScript module formats  

At the moment, these are the most common JavaScript module formats:

  • AMD (asynchronous module definition): an asynchronous module format for browsers.
  • CJS (CommonJS): a synchronous module format designed for servers (such as Node.js). Due to the popularity of Node.js and npm, CJS has become the most widely used format for browsers, too. But it has to be compiled to asynchronous code there. Tools that do that include webpack and Browserify.
  • ESM (ECMAScript modules): With ES6, modules became a built-in part of JavaScript. ESM modules are designed to work both synchronously and asynchronously, enabling them to be a universal module format. Support for ESM in browsers is slowly appearing. Support in Node.js is work in progress and estimated to be production-ready by early 2018 (preliminary support may appear earlier).

UMD (Universal Module Definition)  

The idea of UMD is that you can implement a JavaScript module in such a manner that it supports (up to) three formats at the same time: AMD, CJS and delivery via a global variable.

This is a UMD module that supports AMD and CJS (source):

(function(define) {

    define(function (require, exports, module) {
        var b = require('b');

        return function () {};
    });

}( // Help Node out by setting up define.
    typeof module === 'object' && module.exports && typeof define !== 'function' ?
    function (factory) { module.exports = factory(require, exports, module); } :
    define
));

Documentation:

The problem  

You can only deliver source code for a single platform via an npm package. Property engines of package.json lets you specify exactly what platform that is:

{ "engines" : { "node" : ">=0.10.3 <0.12" } }
{ "engines" : { "npm" : "~1.0.20" } }

However, that doesn’t help you with the following use cases, where you need source code for multiple platforms per package:

  1. Browsers: deliver both a native version (e.g. in ES5 via CJS) and a “bleeding edge” version (e.g. latest ECMAScript version via an ES module), to be transpiled by Babel.
  2. Node.js: deliver the same module for several versions of Node.js.
  3. Browsers: allow new libraries to age gracefully – transpile only as long your target platforms don’t support the features, yet. We want the same convenience that babel-preset-env affords us.

There are two dimensions at play here:

  • On one hand, there is a distinction between code that is to be transpiled and “native” code.
  • On the other hand, native code may have to run on platforms with different capabilities.

The next section covers solutions for use case 1.

Solutions  

The following subsections explain properties in package.json that can be used to point to alternate versions of the same code.

When I use the term “native features”, it means: language features supported by the platforms you are targeting.

main: native features, CJS  

main is the standard mechanism for pointing to the module code inside a package if you want to override the default path, index.js. It is supported everywhere. This is an example:

{
  "name": "the-package",
  "version": "1.0.1",
  "main": "dist/the-package.umd.js",
}

module: native features, ESM  

This property helps tools such as the tree-shaking module bundler Rollup that depend on the ESM format. Other than that, only native language features are supported. That is, module is just main with a different module format:

{
  "name": "the-package",
  "version": "1.0.1",
  "main": "dist/the-package.umd.js",
  "module": "dist/the-package.es2015.js"
}

Documentation:

es2015: ES6, ESM  

Angular v4 delivers each package in three formats:

  • UMD: via property main
  • ES5/ESM: via property module
  • ES6/ESM: via property es2015

This is what its package.json looks like:

{
  "name": "@angular/core",
  "main": "./bundles/core.umd.js",
  "module": "./@angular/core.es5.js",
  "es2015": "./@angular/core.js",
  ···
}

I like the idea of this property. But its name and semantics mean that it’ll age relatively quickly.

Documentation:

jsnext:main: the precursor of module  

The property jsnext:main is now deprecated. It was superseded by module.

browser: browser-specific code  

The idea of the property browser is that:

  • main provides Node.js code
  • browser provides browser-specific code

The simplest mode of browser is as an alternative to main:

{
    "main": "dist/the-package.server.js",
    "browser": "dist/the-package.client.js",
    ···
}

An advanced mode lets you replace specific files:

"browser": {
    "module-a": "./shims/module-a.js",
    "./server/only.js": "./shims/client-only.js"
}

Documentation:

Support by bundlers  

main browser module es2015
webpack
Rollup (✔) (✔)
jspm

Comments:

  • webpack lets you configure where it looks for source code (see next section), so getting it to support es2015 is simple.
  • Rollup specializes in the ESM format. If you want it to handle CJS modules, you need a plugin.
  • jspm has its own configuration mechanisms (property jspm and others).

webpack  

For webpack, you can configure where it searches for module code inside packages via the resolve.mainFields option:

module.exports = {
    ···
    target: "web",
        // the environment in which the bundle should run
    resolve: {
        // options for resolving module requests
        mainFields: ···,
        ···
    },
    ···
}

The default value of this property depends on the value of target.

If target is "web", "webworker" or unspecified then the default is:

mainFields: ["browser", "module", "main"]

If target has any other value (including "node") then the default is:

mainFields: ["module", "main"]

Documentation:

Conclusion  

Support for multi-platform packages has come a long way. The main challenge ahead is to make sure transpiling external dependencies is as “auto-updating” and hassle-free as babel-preset-env.

Further reading