Skip to main content

09. ESM vs CJS interop

This note describes the CJS vs ESM impedance mismatch the bridge has to handle, and the per-runtime resolution semantics. It is informative.

1. The two module systems​

CommonJS (CJS) is Node's original module system:

// foo.cjs (CJS)
exports.bar = function () { return 42; };

// consumer.cjs
const { bar } = require("./foo.cjs");

ECMAScript Modules (ESM) is the TC39-standardised module system:

// foo.mjs (ESM)
export const bar = () => 42;

// consumer.mjs
import { bar } from "./foo.mjs";

A package on npm can ship CJS-only ("main": "index.cjs"), ESM-only ("type": "module" + "main": "index.mjs"), or dual ("exports": { "import": "./index.mjs", "require": "./index.cjs" }).

2. The exports map conditional resolution​

The package.json exports map (Node 12+, May 2019; finalised in Node 16) lets a package author publish per-condition entry points:

{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./sub": {
"types": "./dist/sub.d.ts",
"import": "./dist/sub.mjs"
}
}
}

The conditions are evaluated in order:

  • types: consumer is a TypeScript compiler.
  • import: consumer is an ESM module.
  • require: consumer is a CJS module.
  • default: fallback.

Custom conditions (worker, browser, node, deno, bun) extend the model.

The bridge's TargetNpmLibrary emits all of types, import, require so downstream consumers in any environment resolve correctly.

3. The dual-package hazard​

Some packages ship CJS + ESM duals where the CJS path and the ESM path expose different module instances (different module state). A package that maintains a singleton (e.g., a connection pool) under CJS may end up with two copies if a downstream dep imports it via CJS while the host imports it via ESM. This is the "dual package hazard" (Node.js Modules WG, 2019).

MEP-72's bridge:

  • The Mochi-emitted JS is always ESM (MEP-52 phase 1's default).
  • When a consumed package ships dual, the bridge picks the ESM path (the import condition).
  • When a consumed package's ApiSurface (read from .d.ts) differs between the CJS and ESM paths, the bridge emits a SkipReport for the differing items. This is rare in practice (the .d.ts is usually the same regardless of CJS / ESM resolution).
  • When a consumed transitive dep depends on a package via CJS and the host depends on it via ESM, the bridge emits a build-time warning. The user resolves by aligning the resolution path (typically by pinning the transitive dep's version to one that ships dual or ESM-only).

4. Per-runtime interop semantics​

Node 22 LTS​

Node 22 LTS supports both CJS and ESM. The runtime auto-detects via:

  • package.json "type": "module" flag (file extension fallback to .mjs / .cjs).
  • The exports map's import / require conditions.

import foo from "cjs-pkg" on Node 22 LTS returns the CJS module's module.exports as the default import. Named imports work via the __esModule flag interop.

Deno 2​

Deno 2 supports both CJS (via the npm: specifier and built-in CJS interop) and ESM (the native module system). The runtime resolves through the node_modules tree (when running under Node compat mode) or through the JSR cache (when running under native Deno).

Bun 1.1​

Bun 1.1 supports both CJS and ESM natively. The runtime auto-detects per-file. Bun's CJS-to-ESM interop is faster than Node's (Bun caches the CJS module's exports as ESM-shaped on first load).

Browser​

The browser supports only ESM (via <script type="module"> or via import statements). CJS-only packages cannot run on the browser without a bundler that converts CJS to ESM (Webpack, Rollup, esbuild, bun build).

MEP-52 Phase 17's TargetBrowserBundle path uses bun build (primary) or esbuild (fallback) to handle the conversion. The bridge surfaces CJS-only deps in the lockfile so the browser-bundle gate catches them at build time.

Edge runtimes (Cloudflare Workers, Vercel Edge, Deno Deploy)​

Each edge runtime supports ESM only. CJS packages are rejected at deploy time.

5. The browser bundle path's CJS rejection​

When the build target is TargetBrowserBundle AND a consumed package is CJS-only:

  1. The build fails at the bundler step with a clear diagnostic.
  2. The diagnostic names the CJS-only package.
  3. The diagnostic suggests npm:<pkg>?esm (Deno-side) or asking the upstream maintainer to ship ESM.

6. The node: import surface​

Node 22 LTS, Deno 2, and Bun 1.1 expose Node built-ins under the node: prefix:

  • node:fs
  • node:net
  • node:http
  • node:child_process
  • node:worker_threads
  • node:crypto

The browser does NOT support node: imports. A consumed package that imports node:fs is browser-incompatible; the bridge surfaces this in the lockfile's capabilities-declared field.

The bridge's edge-runtime gate (phase 17) refuses node:fs, node:net, node:child_process, node:worker_threads, and other non-edge-supported Node built-ins.

7. Cross-references​