esbuild icon indicating copy to clipboard operation
esbuild copied to clipboard

[doc] explain how to setup a library

Open rmannibucau opened this issue 2 years ago • 2 comments

Hi,

It is nlt straight forward to build a library with esbuild which exports multiple formats (requires multi passes and some setup to either have multiple package.json or right metadata at root level).

It would be neat to have a doc page ewplaining how to issue cjs and esm in the same build for nom publishing I think.

rmannibucau avatar Nov 18 '23 09:11 rmannibucau

Honestly I'm not sure what to recommend. Node's ESM+CJS support is a mess. Node set up their system so that publishing packages with both ESM and CJS is error-prone and leads to subtle bugs. They call this situation the dual package hazard and it's a deliberate design decision. What it means is that your code may end up being instantiated twice, once for ESM and once for CJS, and that can introduce bugs in libraries that expect to be instantiated once (e.g. you may end up with multiple copies of objects that are supposed to be singletons). This causes real issues in practice, typically with libraries that use the instanceof operator.

Node's recommended approach is to write most of your code in CJS and then write a small ESM shim that imports the CJS and re-exports it. This avoids the dual package hazard because you are only publishing a single copy of your code (in CJS). That technically "works" as it means your library now has ESM exports, but you don't get any of the benefits of ESM (e.g. better tree-shaking) so it seems rather pointless to me. The underlying problem is that node lets you import CJS from ESM but not import ESM from CJS. Ideally you could just write everything in ESM and publish that, and node would let you import that ESM library into a CJS codebase.

The reason why node doesn't allow importing ESM in CJS is somewhat obscure. There's a feature called top-level await that makes ESM imports potentially asynchronous, and the CJS require() function doesn't have a way to wait on a promise first before returning without support from the VM. However, this could have been handled by just throwing an error in that situation. That's how Bun handles top-level await, for example. It's also how esbuild handles top-level await. This wouldn't be that big a deal in practice because from what I understand, the most common usage of top-level await is in the entry point file where this situation can't happen. And node already scans imported files for syntax features, so I assume this would have been straightforward to do. I'm not sure why they don't do this as it seems like it would make everyone's lives easier.

Anyway, the only thing I can think of to suggest looking into as far as dual ESM+CJS publishing is to ignore node's recommendations (specifically to not use the require and import conditions recommended by node's documentation) and to use the module and default conditions instead. So instead of this:

// ./node_modules/pkg/package.json
{
  "exports": {
    "import": "./index.mjs",
    "require": "./index.cjs"
  }
}

You could do this:

// ./node_modules/pkg/package.json
{
  "exports": {
    "module": "./index.mjs",
    "default": "./index.cjs"
  }
}

The difference here is that node doesn't know anything about the module condition so it will always ignore it and use the default condition instead. That should always work in node because node supports importing a CJS file everywhere. The module condition is a bundler-specific convention, so bundlers should always match the module condition and use that over the default condition (at least bundlers that support module). That should also always work in bundlers since bundlers that support module likely also support importing an ESM file everywhere. So it means your library will be ESM-only in bundlers for better tree-shaking but CJS-only in all environments that don't support module for better compatibility, all without the possibility of a dual-package hazard.

But I'm hesitant to publish a recommendation like that on esbuild's website as it goes against what node recommends, and node owns both the npm ecosystem and designed the exports field for the package.json format. There could be good reasons behind node's recommendation too that I'm not aware of. So perhaps esbuild's documentation isn't the best place for something like this.

evanw avatar Nov 18 '23 17:11 evanw

@evanw think there are two topics maybe, the one you mention that I fully share (maybe speaking of browser case can be more relevant than node one?) but there is also the esbuild/build part too (ie running twice vs running with a bridge as you mentionned). Personally I think having a single source version is crucial but running twice esbuild is very doable but is not built-in (format is not a list for ex) so this part can need some sample (can be as easy as [format1, format2].forEach(format => esbuild....({...conf, format: format, maybe the output in dist/${format} }))) avoids to spend time trying to see how to do it. Then a paragraph to explain than the output and package.json options and be it? wdyt?

rmannibucau avatar Nov 18 '23 17:11 rmannibucau