A NodeJS Dual Module Deep Dive
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 forfile.js
in the current folder - the only
package.json
meaningful hint is themain
file, something automatically discovered if noindex.js
ormain.js
file is present, whenever suchmain
field is absent - every unknown file is treated as if it was a
.js
one, so that even explicitly doing something likerequire("./file.cjs")
or an absurdity such asrequire("./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, inpackage.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 mandatoryapplication/javascript
, or legacytext/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 thatpackage.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
orWeakMap
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 inrequire("../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, butexport 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 👍