esbuild: When used with `require()` and `node`, does not honor "main" field in package.json if "module" is present

EDIT: Originally ran into this with node-fetch, but it seems to have surfaced a larger issue which is that esbuild doesn’t prioritize the main field in package.json if you’re using require() with node. This means that esbuild diverges from how Node.js/CommonJS would require the same package.

It appears from the comments that this is by design:

We support ES6 so we always prefer the “module” field over the “main” field.

https://github.com/evanw/esbuild/blob/99f587a133b34794c235be5fff9919f57b56297e/internal/resolver/resolver.go#L272-L280

But this means that certain packages just don’t work with require(), such as node-fetch:

To reproduce:

$ node --version
v12.18.3

$ npm install node-fetch

$ node -p "typeof require('node-fetch')"
function

$ node -p "$(echo "typeof require('node-fetch')" | npx esbuild --bundle --platform=node)"
object

Would be great if esbuild supported the same behavior as Node.js for require’ing modules – if not out-of-the-box, then possibly via a flag?

About this issue

  • Original URL
  • State: closed
  • Created 4 years ago
  • Comments: 44 (19 by maintainers)

Commits related to this issue

Most upvoted comments

When switching our bundling mechanism to esbuild, we’ve hit this exact issue with node-fetch. We have users calling this module as require('node-fetch'), which breaks when switching to esbuild because the return value is an object, not a function.

My understanding of what’s happening is the following:

  • the module’s main export is ambiguous, since lib/index may resolve to lib/index.js or lib/index.mjs, depending on which extension the consumer prefers;
  • by default, esbuild prefers .mjs over .js, which effectively makes the main export point to an ESM file;
  • esbuild will then import an ESM file as a CommonJS module, resulting in an object with a default property as opposed to the expected value, a function.

Changing resolveExtensions to prefer .js over .mjs solves the issue, but it doesn’t feel like the right default behaviour. I’m hoping to find a solution that allows existing code to work without compromising better-compliant modules.

The only thing I can think of is to write a plugin that targets require calls to node-fetch and forces the resolved path to be lib/index.js, leaving alone import calls so that ES Modules use the correct entry point:

const replacerPlugin = {
  name: "node-fetch-handler",
  setup(build) {
    build.onResolve({ filter: /^node-fetch$/ }, ({ kind, path }) => {
      if (kind !== "require-call") {
        return;
      }

      return {
        path: require.resolve(path),
      };
    });
  },
};

Does this feel like a reasonable approach?

Thanks in advance!

To be clear, in any case, I would definitely not expect the resolved file (i.e. "main": vs "module":) to differ depending on usage of require vs import.

After doing some research, it appears that a lot of people do expect this. As a counterpoint, here is an example of what kind of problems doing so causes in practice. I think this thread was a good summary of the issue and the opinions of the people behind different bundlers.

One approach I’m currently considering is for require to read from "main" and import to read from "module", but later on during linking when the whole module graph is available, esbuild could rewrite "module" to "main" for any module that is used with both require and import. That should “just work” automatically without any configuration while also not causing problems with duplicate modules in the bundle. One drawback is that esbuild may spend extra time parsing duplicate copies of a module only to throw out that work later, but that seems like an acceptable trade-off to me for being able to handle this case automatically without any configuration.

Changing resolveExtensions to prefer .js over .mjs solves the issue, but it doesn’t feel like the right default behaviour.

That’s not necessarily true. I don’t think node supports implicit .mjs extensions so you could argue that preferring .js would be more compatible when targeting node.

I’m hoping to find a solution that allows existing code to work without compromising better-compliant modules.

If you’re trying to customize the behavior for an individual package, then you will likely need to use a plugin.

Does this feel like a reasonable approach?

Personally I would recommend unconditionally redirecting all imports to lib/index.js regardless of how they were imported. I don’t think there’s any reason to have two copies of the same in module your bundle.

I was under the impression that this situation is not specific to --platform=node and could occur anytime the same module is required/imported by different code leading to two copies of the library in memory - commonjs and esm. Anyway, material-ui aside, the proposal in the last paragraph in https://github.com/evanw/esbuild/issues/363#issuecomment-689265736 seems to be a reasonable solution to avoid duplication.