Native ESM in Node.js w/ require() fallbacks and support for all front end compilers!
Tags: javascript, node, webpack, testing
Published: 2020-06-23 22:34:10

Native ESM support was unflagged in Node.js CURRENT and LTS a few months ago. Once I started diving in it turned out to be  a bit more difficult than I anticipated.

One thing I worried about was navigating the differences between the way front-end compilers might interpret ESM and the way Node.js does. If I want to split up the entry points for browser, ESM and require they need to all understand the same package.json properties.

{% twitter 1270059044443545600 %}

That answer was “no!” Compilers do not yet understand Node.js’ export map.

If you want consumers of your library to be able to import it with `require()` you’ll need to use an export map and this mapping will be used by Node.js but invisible to compilers.

This means a few things:

1. You’ll probably want to set `{ “type”: “module” }` in your package.json in order to use ESM everywhere by default. This will make Node.js interpret the .js files in your project as ESM and compilers can already detect ESM in the source file. There’s really no benefit to using .mjs unless you want to maintain separate source files of identical implementations, and you probably don’t.

2. You won’t be able to use an export map the way they were intended, which is to allow something like `import main from ‘packageName/defaults’` because that’s not a valid file path and this mapping won’t be visible to the compilers.
 
{% twitter 1273991148541902848 %}

You can use `import` to load Node.js modules written to the old module standard, but you cannot `require()` an ESM module, so the compatibility only flows in one direction.

You quite literally have to have a separate source file, written in the old module format, and overlayed against your ESM files in an export map if you want to support `require()`.

Here’s an example from [js-multiformats](https://github.com/multiformats/js-multiformats/blob/master/package.json) which has many exports.

```js
 "exports": {
    ".": {
      "import": "./index.js",
      "require": "./dist/index.cjs"
    },
    "./basics.js": {
      "import": "./basics.js",
      "require": "./dist/basics.cjs"
    },
    "./bytes.js": {
      "import": "./bytes.js",
      "require": "./dist/bytes.cjs"
    },
    "./cid.js": {
      "import": "./cid.js",
      "require": "./dist/cid.cjs"
    },
    ...
}
```

Compiling these with rollup was pretty simple once @mylesborins pointed me in the right direction, but I needed a bit more.

Here’s another example from [js-multiformats](https://github.com/multiformats/js-multiformats/blob/master/rollup.config.js).

```js
import globby from 'globby'
import path from 'path'

let configs = []

const _filter = p => !p.includes('/_') && !p.includes('rollup.config')

const relativeToMain = name => ({
  name: 'relative-to-main',
  renderChunk: source => {
    while (source.includes("require('../index.js')")) {
      source = source.replace("require('../index.js')", "require('multiformats')")
    }
    while (source.includes("require('../")) {
      source = source.replace('require(\'../', 'require(\'multiformats/')
    }
    return source
  }
})

const plugins = [relativeToMain('multiformats')]
const add = (pattern) => {
  configs = configs.concat(globby.sync(pattern).filter(_filter).map(inputFile => ({
    input: inputFile,
    output: {
      plugins: pattern.startsWith('test') ? plugins : null,
      file: path.join('dist', inputFile).replace('.js', '.cjs'),
      format: 'cjs'
    }
  })))
}
add('*.js')
add('bases/*.js')
add('hashes/*.js')
add('codecs/*.js')
add('test/*.js')
add('test/fixtures/*.js')
console.log(configs)

export default configs
```

You want to compile all the .js files **and** all the tests. There’s a lot that can go wrong in this translation so compiling a version of each test that uses `require()` is quite useful. It also ensure the exported interface remains the same for each entry point.

You also need to make sure you compile out any relative imports in the tests and instead use the local package name. Node.js will resolve the local package name correctly, but if if you use a relative import you’ll actually skip the export map entirely and that will fail.

It would be tempting to just migrate the tests away from relative imports but compilers often don’t support lookups against the local package name the way Node.js does so you can’t.