evanw / evanw/esbuild

Incompatibility when bundling code that contains both ESM and CommonJS

Open
#3,360 5 comments 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Go
Stars
40.1k
Forks
1.3k
PR merge metrics
No merged PRs in 30d

Description

Note I may just be missing a flag somewhere. If that is the case would love to know about it!

common.cjs

const fs = require('node:fs/promises');

console.log('IN COMMONJS LAND');

(async () => {
    await fs.readFile('foo', 'utf8');
    await import('./module.mjs');
})()

module.mjs

import { readFile } from 'node:fs/promises';

console.log('IN ESM LAND');

console.log(await readFile('foo', 'utf8'))

Goal:
To create a bundled file that contains both of these files, and is legally executable.

Considering that common.cjs is the entrypoint, my first attempt is:

esbuild common.cjs --bundle --platform=node --format=cjs --outfile=bundle.cjs

This fails on

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

    module.mjs:5:12:
      5 │ console.log(await readFile('foo', 'utf8'))
        ╵             ~~~~~

Ok, I can probably work with that. Since we want to support ESM internally, I switch format to esm.

esbuild common.cjs --bundle --platform=node --format=esm --outfile=bundle.cjs

That successfully creates a build! But the syntax is incorrect.
Note that my output file is a .cjs so I expect commonjs format... Perhaps this conflicts with my --format=esm setting...

bundle.cjs

var __getOwnPropNames = Object.getOwnPropertyNames;
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');
});
var __esm = (fn, res) => function __init() {
  return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
};
var __commonJS = (cb, mod) => function __require2() {
  return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};

// module.mjs
var module_exports = {};
import { readFile } from "node:fs/promises";
var init_module = __esm({
  "module.mjs"() {
    "use strict";
    console.log("IN ESM LAND");
    console.log(readFile("foo", "utf8"));
  }
});

// common.cjs
var require_common = __commonJS({
  "common.cjs"() {
    var fs = __require("node:fs/promises");
    console.log("IN COMMONJS LAND");
    (async () => {
      await fs.readFile("foo", "utf8");
      await Promise.resolve().then(() => (init_module(), module_exports));
    })();
  }
});
export default require_common();

Note a couple issues.
The import { readFile } from "node:fs/promises"; is hoisted to the top level!
This is illegal in cjs.

I understand the attempt, and that non-dynamic imports have to be top level, but I would expect the bundler to work around this.

For maximum "compatibility" I would expect something like:
foo.mjs

import { bar } from 'bar';
import bing from 'bing';

to transform into something like

var init_foo = __esm({
  async "foo.mjs"() {
    const [
        { bar },
        { default: bing }
    ] = await Promise.all([
        import('bar'),
        import('bing'),
    ])
  }
});

To maintain the parallel async loading functionality of ESM.

The second issue with this cjs output is the necessity of require to exist.
typeof require !== "undefined"

I would have expected this to be polyfilled (along with values like __dirname) via something like

const require = createModule(importURLToFilePath(import.meta.url));

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Reproduce the issue with common.cjs and module.mjs using the two esbuild CLI commands shown, then inspect the generated bundle.cjs. Compare the output against the stated goal: a legally executable CommonJS bundle that preserves the ESM module behavior without relying on unsupported syntax or unavailable runtime values.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, node.js
Domain
build-system, tooling
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.