A NodeJS Dual Module Deep Dive

Andrea Giammarchi
9 min readMar 9, 2020

--

Photo by NeONBRAND on Unsplash

Check latest documentation: it’s finally here, it’s unflagged, and it works 🎉

Before we start …

The latest node, version 13.10, unflagged --experimental-modules and dropped most related loader warnings, so that using standard ECMAScript modules is finally possible, and without any surrounding uncertainty.

Why Dual Module Though?

I am sure concepts such as “backward compatibility”, or “graceful migration”, are not new to anyone developing with node, but even if we’ve used npm only to publish Web/Browsers related modules, we might be happy to know most common bundlers are already compatible with this new feature, only thanks to the amazing job, and collaboration, the node module group has done so far.

Challenges

While at the beginning of this journey most of us where like “just do what bundlers do, node!”, the challenges faced by the module group were more like:

  • no way we can get rid of CommonJS any time soon
  • no way we can do what bundlers do at pre-processing time, lazily deciding what to do and how, with the file to import
  • node should still be fast, reliable, and stable, without any edge case left out

and so on and so forth … and they made it, so … thank you folks!

Constraints

If we’d like to successfully publish a dual module today, we have to take into account at least these constraints:

  • we don’t get to know where our module will be used: an old LTS? version 10? version 12? version 8 or even 6?
  • we don’t get to know which bundler would need to understand the module: an old Webpack? Parcel? An outdated Rollup? Go figure it out 🤷‍♂️

What matters in the “constraints” argument, is that the most known “standard” regarding both node and bundlers world, so far, is the CommonJS one, and following what it means:

Legacy CommonJS Constraints

  • the file extension is not mandatory, so that require("./file") would automatically search for file.js in the current folder
  • the only package.json meaningful hint is the main file, something automatically discovered if no index.js or main.js file is present, whenever such main field is absent
  • every unknown file is treated as if it was a .js one, so that even explicitly doing something like require("./file.cjs") or an absurdity such as require("./file.mjs") would handle the file like a regular .js file

These 3 points inevitably suggest the following consequences:

  • no way we can use an extension different from .js if we’d like to be fully backward compatible with projects requiring our module in the past, as the old resolver wouldn’t search for .cjs out of the box, hence fail
  • we must keep the main field, in package.json confined to the CommonJS world, so that’s a no-go, as entry point, for standard ES modules
  • we could, eventually, explicitly require a file with a .cjs extension, as such extension is an explicit hint for the modern node, that we’re importing something from the CommonJS world

Standard ECMAScript Constraints

  • no way we can use an extension different from .js if we’d like to have files compatible with both node or browsers, where latter requires a mandatory application/javascript, or legacy text/javascript, mime type to work, and not all the Web Servers and their related programming languages, know how to server .mjs, as example, so that .js would simply 100% work everywhere, including outdated IDEs: win win
  • the main field should be reserved to the CommonJS version of the module, so that package.json needs to be instrumented to serve ESM instead, whenever the importer is capable
  • we could, eventually, explicitly import a file with a .cjs extension, forcing the import operation to treat that file as CommonJS , ’cause modern node is perfectly capable of that, while older versions wouldn’t properly understand .cjs or .mjs extensions

Why Importing CommonJS In ESM?

There is a very important section, in the official documentation, about the “Dual Package Hazard” (honestly folks, how about an id per topic, so that one could link to it directly?).

The TL;DR summary, of that section, is that our module could be present in a situation where:

  • some modern module import ours as ESM
  • some older module require ours as CJS

In this situation, everything is fine, if the module is stateless, providing some utility to consume and nothing else … but what if our module trusts its private scope to do operations like:

  • connect to a database, that should happen once
  • use a Map or WeakMap or similar constructs, to cache some already processed input, to produce always same output
  • use an internal counter to ensure things happen in a certain way around such counter

In these cases, or others, it’s super important to grant that our module would survive in projects where we don’t get to know which 3rd party import, or require, our module, so that having 2 redis connections, as example, instead of one, is a no-go for any project trusting us to provide the best practices, and performance, we can … do you know what I mean? 😉

Accordingly, if we are after a dual module based project, aiming to help migrating to full ESM, which is the Browsers’ present, but also node’s future, falling back to the lowest common denominator is the easiest thing to do.

Dual Module in a Nutshell

If you’ve read so far, and kept all described constraints in mind, you might as well wonder what’s the solution to all this … and there it is:

{
"type": "module",
"module": "./esm/index.js",
"main": "./cjs/index.js",
"exports": {
"import": "./esm/index.js",
"default": "./cjs/index.js"
}
}

Describing Each Field

"type": "module"

This field automatically projects our module into the ESM world, telling all modern node executables, that we published a modern ESM based package.

"module": "./esm/index.js"

This field communicates to most bundlers that our ESM module is reachable through the esm/index.js file. It has no meaning for CommonJS or node itself, so it’s kinda very safe, de-facto standard, to use for updated bundlers.

"main": "./cjs/index.js"

This field tells every legacy bunlder, or node, that despite the module type is module, the entry point is a CommonJS one.

"exports": {
"import": "./esm/index.js",
"default": "./cjs/index.js"
}

This field communicates to modern node, that by default, modules can just grab the CJS version, but if they are importing from an ESM module, the esm/index.js file is the one to use.

The exports field offers much more, like enabling import extra from "module/extra" or require("module/extra"), by defining an entry like:

"exports": {
...
"./extra": {
"import": "./esm/extra/index.js",
"default": "./cjs/extra/index.js"
}
}

Why ESM and CJS folders

If we’re writing a module, we’re either writing 100% ESM or CJS. Since we don’t want to have a plethora of files with the same name, but different extensions, in our IDE, and since most bundlers easily target a source folder to a destination one, it makes sense to simply develop our module in its ESM scope, hence within its esm folder.

Automating CJS

There are probably plenty of tools able to do this, but what I’ve been using for years now, is ascjs. This module can be an entry such as "cjs": "ascjs esm cjs", so that whenever we build via npm run cjs, it will transpile only the esm import/export dance, and nothing else, for the whole esm folder, into the cjs one.

Is That It?

Kinda, except for these two renaming, but extremely important, points.

Ensure The ./cjs CommonJS Type

As previously mentioned, the “dual module” is a migration pattern from legacy to modern packages, so that falling back for legacy, is a common pattern seen in the Web polyfills field for years, hence proven to work.

So while old node and bundlers wouldn’t care less about this step, it’s mandatory for node 13.10 or higher, to understand how to handle the cjs folder. The key is to provide a different folder scope, as described in the official documentation, so that any file in there will always be loaded as CJS.

To obtain such behavior, simply add, in the cjs folder, a package.json like:

{"type": "commonjs"}

The ./cjs/package.json file will enforce any modern loader to handle its files as CJS, without penalizing the dependency graph of the esm folder, so that static npm only package analysis, when it comes to bundlers, and for both ESM or CJS cases, are still possible, and fully optimized.

Ensure Hazard-less Modules

If there’s anything crucial that should never be exposed to both CJS and ESM worlds, such as a database connection or anything else important, the suggested practice here is to have a third shared folder in the project.

package.json
cjs/index.js
esm/index.js
shared/index.cjs

As migration forward pattern, it is important that the main file in the shared folder has a .cjs extension, and these are the reasons, or constraints:

  • CommonJS files must require the file via explicit extension, as in require("../shared/index.cjs"), avoiding automatic resolution to a .js extension that wouldn’t exist otherwise
  • ECMAScript files must import the file regularly, as it will simply work: import stuff from "../shared/index.cjs" will force the loader to use a CommonJS logic
  • accordingly with previous steps, the file must contain good old CommonJS code, meaning module.exports = {} is allowed, but export default {} is not, ’cause it’s gonna be always imported as CommonJS

The Future Is Bright

Following all these rules, the only needed maintenance in the future would be to drop the npm run cjs task, and eventually move the shared folder within the esm one, but that’s also not mandatory at all, as CommonJS ain’t going anywhere, anytime soon, so that there’s really no need to even do any of these things, unless our module is super huge, and we’d like to speed up publishing dropping the cjs folder, which would do already.

How To Test All This

Not only testing, but we could check the dualmodule repository, which only purpose is to demonstrate that everything mentioned in here works, so that its structure is exactly the one described in all this post cases.

Create A Test Folder

Simply create a new folder, and once in there, npm i dualmodule.

Test The CommonJS Version

In order to check the module via default CommonJS, simply type:

node -e 'const test = require("dualmodule"); console.log(test)'

The execution will output CJS 1, meaning the shared counter within the module incremented once, and the required module type was CommonJS.

Test The ECMAScript Version

In order to check the module via ECMAScript module, simply type:

node --input-type=module -e 'import test from "dualmodule"; console.log(test)'

The execution will output ESM 1, meaning the shared counter within the module incremented once, and the imported module type was ECMAScript.

Test Both CJS And ESM

To perform this operation, we are going to use the default module loader, which is still CommonJS, by writing this content in the c.js file:

const dualmodule = require('dualmodule');
console.log(dualmodule);

At this point, all we need to do is to type the following:

node --input-type=module -e 'import esm from "dualmodule"; import "./c.js"; console.log(esm);'

As we imported first, and required after, the output will be CJS 2 followed by ESM 1, demonstrating the dualmodule works in both cases, and the shared/index.cjs counter also does what it’s supposed to do: keep a shared state.

How Can I Default To ESM?

The same way cjs/package.json can tell node its content is CommonJS only, we can place in any folder the following package.json too:

{"type": "module"}

From that time on, any file we’ll write in there will be interpreted as ESM, unless the file extension is .cjs, so that we could write our tests, as example, via test/index.cjs and benefit from the mechanisms that CommonJS offers, compared to ECMAScript, including:

  • __fileName and __dirName variables
  • the ability to delete require.cache[require.resolve("...")] when polyfills, and code coverage, are relevant for our project

In few words, writing modules as ESM is a natural path forward, but whenever it comes handy, ditching ESM for CJS is just a .cjs extension away, as long as we remember to explicit the extension also in CJS, whenever we need these kind of files.

As Summary

The node modules situation has been very blurry for years, but it finally dropped most of its experimental flags, and the outcome is that we can finally deploy modules that won’t break anywhere, simply following all the explained rules, and patterns, in this post, so that not only the future is bright, but the present too 🎉

P.S. How To Scafold Dual Modules

If we’d like to try this structure, simply type:

npx modulestrap --help
# or
npx modulestrap --node my-dual-module

And bootstrap with the same logic, any new module we are going to write next, so that patterns, configuration, and utilities, are already there 👍

--

--

Andrea Giammarchi

Web, Mobile, IoT, and all JS things since 00's. Formerly JS engineer at @nokia, @facebook, @twitter.