Re: CommonJS (or other) as an ESM-based module subsystem (working draft)

Today I read this thread by Evan Plaice which took my thinking to discussions from almost a year ago in the Node.js Module bi-weekly… One particular remark that was maybe not yet relevant:

Thought: Wonder how a CJS system would look like if it was written in ESM — not a dumb one!

Today, this may no longer be irrelevant at all, but not for CJS per say, and here is why.

Why not CJS

It is sad for us to have to repeatedly revisit how CJS as successful as it is cannot scale as intended. But we all forget (at least I do, a lot) and then we remember, and it somehow hurts.

  1. Scaling beyond the synchronous façades offered by Node.js and bundles takes one solving the paradox of synthetically synchronous asynchronous calls to require.

  2. Doing that breaks many modules not written to be loaded async, where linking attempts to pierce across the threshold of the cycle that is to happen to resolve the one that is to conclude.

Aside: Top-level await does not solve this problem. That is like a proposal being a little a more ambitious saying something like await as a prefix keyword to any function call is okay, and then we want to magically slap that before we evaluate the function body to all relevant require(…) calls — and what about modules that return a promise because they have to!

What if not CJS?!

Let’s start with legacy:

  • AMD style
  • CJS self-contained graph payloads (aka bundles)

This was what we learned from all those years of browser based hacking, before the tooling façade made us all forget.

But today we can say:

  • import style

Details?!

It should be on everyone’s radar by now that await import(…) is completely valid CJS code, provided it lives inside an async function () { /* body */ } — did not yet land, but likely not far out. And that forces us to reconsider how we go about dependency graphs.

This is not as clean-cut yet, like if import(cjs) will one day give us named-exports.

This will likely start leading to funny outcomes, imho. In most cases, it is best to port to ESM, and offer CJS for backcompat where necessary. The drawback there is that you must consider how far back you are going. And my best intuition here is to deprecate CJS support once you port to ESM, ie in the next few months, if we do not find a good solution for code to run without the maintenance for consistency costs that will pile up with it, we will drop it.

Aside: ESM code runs in Node.js fine with solutions like @jdalton’s esm, and if you are wondering it supports { "engines": { "node": ">=6" } } as defined in the package.json.

You can say this is a gentle push, there are questions without answers, and ESM certainly is not the superior thing, but it is the thing that is standard, because it can be.

It came to be because of what we learned from AMD versus CJS back in the day. And we can no longer say that userland loader complications are not a problem, because userland loaders that eval are at least harder to properly trust.

To be continued…