Home

Building Runtime-Aware JavaScript Packages

Exports

JavaScript makes it easy to share modules. Package managers like npm, pnpm, and yarn let developers publish code and distribute it globally through CDNs like jspm. Other developers reuse that code in their own projects. The open-source nature of many packages means the community improves and updates them continuously.

JavaScript runs on multiple runtimes: browsers, node, deno, and mobile. Each runtime has its own built-ins. The ecosystem needed standard practices for writing and distributing modules across these environments.

Different standardising bodies govern various technologies. tc39 proposes changes to the JavaScript language. w3c and node-tsc handle standards for browsers and Node.js.

Example

  • fs is available in node, but not in browsers.
  • document (DOM) is available in browsers but not in node.

These are runtime-specific built-ins, dependent on the environment. Since JavaScript runs everywhere, nothing stops a module written for browsers from being used in node or deno. Package authors started using polyfills. But bundling polyfills into the module itself has downsides.

Built-ins

Say you are building an authentication package for both browser and node. The polyfills approach creates problems:

  • node-fetch handles fetch calls in node, but browsers already have a native fetch. Bundling node-fetch means browser users download code they don’t need.
  • Each npm package bundling its own polyfills bloats dependencies. Every module in node_modules brings its own copy, increasing build size.
  • If the modules skip polyfills, end users must install them manually.

Recent package specifications make building for different runtimes easier.

Imports #

Node v16 added import paths mapping. You can define a dependency that resolves differently depending on whether the module runs in browser or node.

A fetch can be mapped like this:

{
  "name": "@example/authentication",
  "type": "module",
  "imports": {
    "#middleware": {
      "node": "./middleware.js",
      "default": "./middleware-browser.js"
    }
  }
}

Implement for both environments using their built-ins or polyfilling where needed:

// middleware-browser.js
export const authenticate = () => {
  return fetch("https://example.com/authenticate");
};
// middleware.js
import fetch from "node-fetch";

export const authenticate = () => {
  return fetch("https://example.com/authenticate");
};

When consuming authenticate, import it from the mapped path:

import { authenticate } from "#middleware";

The runtime resolves to the correct module. This eliminates polyfill duplication and uses native modules where available.

Example

chalk, one of the most used packages, uses imports to load built-ins efficiently.

Here is imports from its package.json:

"imports": {
  "#ansi-styles": "./source/vendor/ansi-styles/index.js",
  "#supports-color": {
    "node": "./source/vendor/supports-color/index.js",
    "default": "./source/vendor/supports-color/browser.js"
  }
}

https://ga.jspm.io/npm:chalk@5.2.0/source/vendor/supports-color/index.js

https://ga.jspm.io/npm:chalk@5.2.0/source/vendor/supports-color/browser.js

Chalk animation

Exports #

The exports field in package.json lets authors specify how their package should be imported in different environments. It defines different entry points for different runtimes (browser, node, default) and different formats (CommonJS or ES modules) for the same entry point.

imports and exports serve different purposes. imports maps runtime-aware external packages into your module. exports exposes your module across runtimes and formats.

Preact’s package.json shows a good example of exports:

"type": "module",
"exports": {
  ".": {
    "types": "./src/index.d.ts",
    "browser": "./dist/preact.module.js",
    "umd": "./dist/preact.umd.js",
    "import": "./dist/preact.mjs",
    "require": "./dist/preact.js"
  }
}

umd is not a standard field in the specification.

WinterCG, the collaboration platform for different JavaScript runtimes, is standardising these runtime keys. The specification proposes more identifiers for different runtimes. This optimises packages by leveraging built-ins available in the target runtime and polyfilling only what’s needed.


ENV #

process.env.NODE_ENV is standard practice for distinguishing production and development builds in applications. For libraries, this breaks. process is not a browser built-in.

Library authors need to serve different versions for different build targets: better error messages in development, smaller builds in production. Loading these modules in browsers from CDNs crashes script execution.

react-router@5.2.0 fails when loaded as an ESM module in the browser because its entry uses process.env.NODE_ENV:

https://unpkg.com/browse/react-router@5.2.0/index.js

// react-router/index.js
if (process.env.NODE_ENV === "production") {
  module.exports = require("./cjs/react-router.min.js");
} else {
  module.exports = require("./cjs/react-router.js");
}

Importing process from node:process fixes this. Bundlers and CDNs detect the built-in usage and polyfill accordingly:

const process = require("node:process");

if (process.env.NODE_ENV === "production") {
  module.exports = require("./cjs/react-router.min.js");
} else {
  module.exports = require("./cjs/react-router.js");
}

Explicit built-in references give bundlers precise control over what to polyfill, instead of polyfilling all node built-ins for every browser build.

This pattern only works in CommonJS. Use conditional exports and imports for branching instead of relying on code analysis.


JSPM #

The ESM transition broke many build pipelines. Projects needed a combination of bundlers, transpilers, node, and multiple module formats. Loading a cjs module from npm into an ESM project, or vice versa, left most systems out of sync.

JSPM builds all packages from npm ahead of time into spec-compliant ESM modules. It serves them through a distributed global CDN, regardless of the format in which packages were published to npm. Any module from npm loads in any project.


package.json

Different fields inside package.json control module resolution and runtime compatibility.

browser

When a package specifies a browser field, JSPM uses it instead of main when generating import-maps for browser targets. The package author explicitly states which module to use in browser environments.


module

JSPM detects module builds. If main points to a CJS build and module points to an ESM version, and neither type: "module" nor .mjs extension is set:

"main": "dist/index.js",
"module": "dist/index.es.js"

JSPM treats main as ESM. This happens because module is not an official specification, only popularised by bundlers.

Rely on specifications. The exports field now exposes multiple build formats. Stop using module. You don’t need main if you expose the package using exports.


exports

JSPM’s package-builder parses packages and creates an export-map for those that don’t expose one. It detects subpaths based on internal import usage, making exports more efficient. If a package already exposes an exports map, JSPM uses it instead of regenerating.

"exports": {
    ".": {
      "import": "./dist/default/lib.mjs",
      "require": "./dist/default/lib.js"
    }
}

react-router from unpkg:

https://unpkg.com/browse/react-router@6.8.2/package.json

  "main": "./dist/main.js",
  "unpkg": "./dist/umd/react-router.production.min.js",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts"

All these fields are deprecated in favour of exports.

The same package from JSPM:

https://ga.jspm.io/npm:react-router@6.8.2/package.json

"exports": {
  ".": {
    "module": "./dist/index.js",
    "default": {
      "development": "./dist/dev.main.js",
      "default": "./dist/main.js"
    }
  },
  "./package.json": "./package.json.js",
  "./package": "./package.json.js",
  "./dist/main.js": {
    "development": "./dist/dev.main.js",
    "default": "./dist/main.js"
  },
  "./dist/index.js": "./dist/index.js",
  "./dist/main.js!cjs": "./dist/main.js",
  "./dist/dev.main.js!cjs": "./dist/dev.main.js",
  "./package.json.js!cjs": "./package.json.js"
}

These export maps load modules through import-map. The next post explores how import maps load modules into any environment.