DEV Community

Marco Gonzalez
Marco Gonzalez

Posted on

Node.js and esbuild: beware of mixing cjs and esm

TL;DR

When using esbuild to bundle code with --platform=node that depends on npm packages with a mixture of cjs and esm entry points, use the following rule of thumb:

If you're wondering about the difference between cjs and esm, take a look at Node.js: A brief history of cjs, bundlers, and esm.

Symptom

When executing esbuild bundled code with --platform=node you may have come across one of the following runtime errors:

Error: Dynamic require of "<module_name>" is not supported
Enter fullscreen mode Exit fullscreen mode
Error [ERR_REQUIRE_ESM]: require() of ES Module (...) from (...) not supported.
Instead change the require of (...) in (...) to a dynamic import() which is available in all CommonJS modules.
Enter fullscreen mode Exit fullscreen mode

Cause

This is because of one of the following limitations:

  • esbuild's esm to cjs (and vice-versa) transformations.
  • Node.js cjs/esm interoperability.

Analysis

esbuild has limited transformation capabilities between esm and cjs. Additionally, some scenarios while supported by esbuild are not supported by Node.js itself. As of esbuild@0.24.0, the following table summarizes what's supported:

Format Scenario Supported?
cjs static import Yes
cjs dynamic import() Yes
cjs top-level await No
cjs --packages=external of esm entry point No*
esm require() of user modules** Yes***
esm require() of node:* modules No****
esm --packages=external of cjs entry point Yes

* Supported by esbuild but not by Node.js

** Refers to npm packages or relative path files.

*** User modules are supported with some caveats: __dirname and __filename are not supported without a polyfill.

**** node:* modules can be supported with the same polyfill.

What follows is a detailed description of these scenarios without the use of any polyfills:


npm packages

We'll use the following example npm packages:

static-import

esm module with a static import:

import { version } from "node:process";

export function getVersion() {
  return version;
}
Enter fullscreen mode Exit fullscreen mode

dynamic-import

esm module with a dynamic import() within an async function:

export async function getVersion() {
  const { version } = await import("node:process");
  return version;
}
Enter fullscreen mode Exit fullscreen mode

top-level-await

esm module with a dynamic import() and a top-level await:

const { version } = await import("node:process");

export function getVersion() {
  return version;
}
Enter fullscreen mode Exit fullscreen mode

require

cjs module with a require() invocation:

const { version } = require("node:process");

exports.getVersion = function() {
  return version;
}
Enter fullscreen mode Exit fullscreen mode

--format=cjs

We'll run esbuild with the following arguments:

esbuild --bundle --format=cjs --platform=node --outfile=bundle.cjs src/main.js
Enter fullscreen mode Exit fullscreen mode

and the following code:

import { getVersion } from "{npm-package}";

(async () => {
  // version can be `string` or `Promise<string>`
  const version = await getVersion();

  console.log(version);
})();
Enter fullscreen mode Exit fullscreen mode

static import

Produces the following which runs just fine:

// node_modules/static-import/index.js
var import_node_process = require("node:process");
function getVersion() {
  return import_node_process.version;
}

// src/main.js
(async () => {
  const version2 = await getVersion();
  console.log(version2);
})();
Enter fullscreen mode Exit fullscreen mode

dynamic import()

Produces the following which runs just fine:

// (...esbuild auto-generated helpers...)

// node_modules/dynamic-import/index.js
async function getVersion() {
  const { version } = await import("node:process");
  return version;
}

// src/main.js
(async () => {
  const version = await getVersion();
  console.log(version);
})();
Enter fullscreen mode Exit fullscreen mode

Notice how the dynamic import() is not transformed to a require() because it's also allowed in cjs modules.

top-level await

esbuild fails with the following error:

[ERROR] Top-level await is currently not supported with the "cjs" output format

    node_modules/top-level-await/index.js:1:20:
      1 │ const { version } = await import("node:process");
        ╵                     ~~~~~
Enter fullscreen mode Exit fullscreen mode

--packages=external

Using --packages=external succeeds with all npm packages:

esbuild --packages=external --format=cjs --platform=node --outfile=bundle.cjs src/main.js
Enter fullscreen mode Exit fullscreen mode

produces:

var npm_package_import = require("{npm-package}");
(async () => {
  const version = await (0, npm_package_import.getVersion)();
  console.log(version);
})();
Enter fullscreen mode Exit fullscreen mode

However, they all fail to run because Nodes.js doesn't allow cjs modules to import esm modules:

/(...)/bundle.cjs:1
var import_static_import = require("static-import");
                           ^

Error [ERR_REQUIRE_ESM]: require() of ES Module /(...)/node_modules/static-import/index.js from /(...)/bundle.cjs not supported.
Instead change the require of index.js in /(...)/bundle.cjs to a dynamic import() which is available in all CommonJS modules.
Enter fullscreen mode Exit fullscreen mode

--format=esm

We'll now run esbuild with the following arguments:

esbuild --bundle --format=esm --platform=node --outfile=bundle.mjs src/main.js
Enter fullscreen mode Exit fullscreen mode

require() of user modules

src/main.js

const { getVersion } = require("static-import");

console.log(getVersion());
Enter fullscreen mode Exit fullscreen mode

produces the following which runs just fine:

// (...esbuild auto-generated helpers...)

// node_modules/static-import/index.js
var static_import_exports = {};
__export(static_import_exports, {
  getVersion: () => getVersion
});
import { version } from "node:process";
function getVersion() {
  return version;
}
var init_static_import = __esm({
  "node_modules/static-import/index.js"() {
  }
});

// src/main.js
var { getVersion: getVersion2 } = (init_static_import(), __toCommonJS(static_import_exports));
console.log(getVersion2());
Enter fullscreen mode Exit fullscreen mode

require() of node:* modules

src/main.js

import { getVersion } from "require";

console.log(getVersion());
Enter fullscreen mode Exit fullscreen mode

produces the following:

// (...esbuild auto-generated helpers...)

var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
  get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
}) : x)(function(x) {
  if (typeof require !== "undefined") return require.apply(this, arguments);
  throw Error('Dynamic require of "' + x + '" is not supported');
});

// (...esbuild auto-generated helpers...)

// node_modules/require/index.js
var require_require = __commonJS({
  "node_modules/require/index.js"(exports) {
    var { version } = __require("node:process");
    exports.getVersion = function() {
      return version;
    };
  }
});

// src/main.js
var import_require = __toESM(require_require());
console.log((0, import_require.getVersion)());
Enter fullscreen mode Exit fullscreen mode

However, it fails to run:

Error: Dynamic require of "node:process" is not supported
Enter fullscreen mode Exit fullscreen mode

--packages=external

Using --packages=external succeeds with all npm packages, including those with cjs entry points. For example:

esbuild --packages=external --format=esm --platform=node --outfile=bundle.mjs src/main.js
Enter fullscreen mode Exit fullscreen mode

with:

src/index.js

import { getVersion } from "require";

console.log(getVersion());
Enter fullscreen mode Exit fullscreen mode

produces a nearly-verbatim output which runs just fine because esm modules can import npm packages with cjs entry points:

import { getVersion } from "require";
console.log(getVersion());
Enter fullscreen mode Exit fullscreen mode

Conclusion

I hope you find this post useful to troubleshoot esbuild outputs now and in the future. Let me know your thoughts below!

Top comments (0)