Sam Thorogood

CJS Equivalency

I've been working on bundler-like things and I've thought again about the classic CJS vs ESM interop issue. It occurs to me that one way to transform CJS to ESM is to treat each CJS file as a 'builder'. This isn't bundling, but perhaps a step on the way, or for a serving library which purely provides a transform.

Example

Here's a reductive example. We have a CJS file which has both a top-level and lazy import:

const other = require("./other.js");
module.exports.performAction = () => {
  const lazy = require("lazy");
  return other.helper(lazy);
};

Can be thought of as, with the original code largely unchanged in internalBuild:

import otherBuild from "./other.js";
import lazyBuild from "./node_modules/lazy/index.js";

const builders = {
  other: otherBuild,
  lazy: lazyBuild,
};
const require = (name) => builders[name]();

function internalBuild(module) {
  const other = require("other");
  module.exports.performAction = () => {
    const lazy = require("lazy");
    return other.helper(lazy);
  };
}

let cache;
export function build() {
  if (!cache) {
    cache = { exports };
    internalBuild(cache);
  }
  return cache.exports;
}

In practice, you'd autogenerate this code. Watch this space.

Note that cache is important as otherwise we won't get singleton behavior. Each import or require of the same file should return the same object—it's only evaluated once.

What's missing

  1. The final user of the CJS file (whether browser, or a real ESM) needs to invoke its default export. So you can't just rewrite the CJS—the caller needs to know, too.

  2. The builder is still exposed on default, and you can't treat individual export properties as special—CJS is always just exporting 'a singleton'.

  3. Dynamic require() statements don't have a good mapping here. The equivalent in ESM works because it has to be async, and require isn't going to suddenly start supporting that now. Anyway, no serious bundler supports this either.

  4. Going back from CJS to ESM (i.e., a require() that points to a ES Module) could work the same way (moving it inside a builder), but now some of the seams are breaking a bit, because the point of ESM is that we can be purists about it and not rewrite most of that code.

Why this works

I think my initial instinct was to try to convert each require call to be dynamic, somehow. Instead, we can basically precalculate the graph in an ESM-style without actually executing any CJS code. Importing a file which has been transformed will evaluate none of the original code, but set it up in a way that it's ready to be.

Why this post exists

Why not. You've read it. Enjoy!