Is Node ready for native ECMAScript modules? Yes, and here is how to upgrade your existing repos with a codemod.
By: Albert Still
Tags: Web Development, JavaScript, Nodejs, Servers, Ecmascript

Is Node ready for native ECMAScript modules? Yes, and here is how to upgrade your existing repos with a codemod. Albert Still Sep 7·4 min read

Seeing that ECMAScript modules (ESM) is now stable in latest LTS Node, from 14.17.0, I recently upgraded the backend of Tern, a sustainable commuting app I run.

A motive to change was that it got annoying having to differ between the two module systems for FE code that used ESM via webpack/babel, and the backend code that used CommonJS on vanilla Node. Also seeing libraries such as node-fetch only support ESM in their latest major, moving to ESM is probably an inevitability in the long term. And all that aside, being a Node fanboy I obviously wanted to try the new bells and whistles!

There are three ways to opt in to ESM, I went with the "type"="module" package.json approach vs using .mjs extensions everywhere. I preferred using the standard .js file extension and forcing ESM everywhere vs having a mix of .js and .mjs .

Firstly I used a codemod to change all CommonJS uses to ESM Modules.

I know jscodeshift is a great tool for this, and I’ve used it before at work on a sass-to-emotion tool, so I looked for a transform.

I found a repo azu/commonjs-to-es-module-codemod and it works great but I wanted it to also add the .js extension to all local imports, something my ESlint had once forbidden the use of but is now required by Node ESM.

I made a fork of the project to make this tiny change, and ran:

jscodeshift -t ../commonjs-to-es-module-codemod/transforms/index.js ./server/**/*.js

Note: azu’s repo suggests grabbing the transform off a unpkg.com url, this did not work for me, I referenced the transform locally instead.

I needed to tweak some imported packages that did not support ESM named exports, Node’s error messages gave good hints how to fix. For example lodash had to be changed from:

const { snakeCase } = require('lodash')

to

import lodash from 'lodash'; const { snakeCase } = lodash;

Note you will not have to do this to all packages, some CommonJS packages support named exports. It seems it’s just packages that re-assign module.exports :

import { foo } from 'pkg' // if pkg did this in it's root export it would not work

module.exports = { foo: 42 }; // if pkg did this it would work

module.exports.foo = 42;

__dirname is no longer available in ESM files, but there is a very easy work around.

There is no more loading of index.js when you pass require() a folder, simply add /index.js to the import.

Now for the next challenge, 3rd party tools that require JS config files that live in your repo with the new "type"="module" package.json.

For Eslint, Stylelint and my NewRelic config luckily they support the .cjs extension, it’s a way to opt-out of ESM because Node will error if the .js file being required is not ESM or is not being required with await import .

So I ended up converting the config files to .eslintrc.cjs , .stylelint.cjs and newrelic.config.cjs , no dramas.

Even better Jest supported ESM properly, I could simply change jest.config.js to ESM in-place and Jest would import it using a dynamic import() in it’s CommonJS module.

It catches the failed require call, see’s it’s error code is ERR_REQUIRE_ESM , and attempts an ESM compatible await import , very neat, here is the code. I hope more libraries take this approach as it requires no changes on the consuming side.

All I needed to change Jest wise was a Node flag, not for ESM that requires no opt in, but for ESM in Node VM’s which Jest uses to run tests in parallel.

My curve ball happened with e2e, Cypress, it has no support for ESM and because my plugins file used some src modules, e.g my ./db.js ESM module, I got into a pickle.

I had to go with a temporary workaround for my e2e suite using edm and manually removing "type"="module" when running e2e locally, I don’t run it in CI atm. Hopefully they will fix it soon.

Now for the production deployment, the site went down, I had some code that did a dynamic const newRelic = isProd ? require('newrelic') : null in a ESM module, it is not possible to use require in a ESM module and I stupidly forgot to test a prod run locally.

You can either fix this with a const newRelic = isProd ? await import('newrelic') : null using Node’s new top level await feature, but this broke the latest eslint parser.