evanw / esbuild

An extremely fast bundler for the web
https://esbuild.github.io/
MIT License
38.19k stars 1.15k forks source link

Export commonjs named exports in ESM #442

Open remorses opened 4 years ago

remorses commented 4 years ago

Currently a module that uses commonjs features like exports and require will translate to a default export exporting the module.exports object.

But some packages like react rely on bundlers like webpack to allow you to use named imports like import { useEffect } from 'react', event thought react is a commonjs module.

It would be cool to detect the cjs named exports and reexport them in the ESM output.

Code

currently executing esbuild --bundle --outdir=out --format=esm x.json

exports.x = 9

becomes

...
var require_x = __commonJS((exports, module) => {
  module.exports.x = 9;
});
export default require_x();

what i am asking for is to have this output

...
var require_x = __commonJS((exports, module) => {
  module.exports.x = 9;
});
export default require_x();

export {
  x: require_x().x
}

Nodejs is trying to allow the same interopability and they are using a lexer to detect commonjs named exports: https://github.com/guybedford/cjs-module-lexer

That package implements the lexer in C and uses WASM to execute it in js, we could use the same code nodejs uses to detect commonjs named exports and reexport them as ESM

remorses commented 4 years ago

I could do this in a js plugin that changes the entrypoint in a fake ESM module that exports from the commonjs module (we are already doing the same thing in snowpack with rollup) but it would be cool to have this feature built into the bundler

ije commented 4 years ago

this is useful for me, i created a cdn using esbuild to bundle npm package to esm version:
https://github.com/postui/esm.sh

to get export names of a commonjs module, i created apeer.js like:

const mod = require('xxx')
fs.writeFileSync('./peer.exports.json', JSON.stringify({exports: Object.keys(mod)}))

then run node peer.js.

shrinktofit commented 4 years ago

I wonder why my module exports only a default binding even if I passed --format=esm and my module(in esm) exports many named bindings. Does it because I import commonjs module?

vinsonchuong commented 3 years ago

@remorses, I noticed that you're doing a lot of work in exporting npm packages as ESM. Have you been able to work out a fully working solution?

I've also been looking to accomplish the same. I started with esinstall, but it's way too slow to experiment with. So, I've been trying to do the same with esbuild.

So far, it seems to more or less just work except for named exports from CJS.

I like the workaround from @ije, in just evaluating the module to get its export names. But, that doesn't account for packages that export different code for browsers.

After reading evanw/esbuild#532, I found guybedford/cjs-module-lexer. Combining this with webpack/enhanced-resolve does the trick for me:

import {promisify} from 'util'
import enhancedResolve from 'enhanced-resolve'
import * as moduleLexer from 'cjs-module-lexer'

const resolve = promisify(
  enhancedResolve.create({
    mainFields: ['browser', 'module', 'main']
  })
)

let lexerInitialized = false
async function getExports(modulePath) {
  if (!lexerInitialized) {
    await moduleLexer.init()
    lexerInitialized = true
  }

  try {
    const exports = []
    const paths = []
    paths.push(await resolve(process.cwd(), modulePath))
    while (paths.length > 0) {
      const currentPath = paths.pop()
      const results = moduleLexer.parse(await fs.readFile(currentPath, 'utf8'))
      exports.push(...results.exports)
      for (const reexport of results.reexports) {
        paths.push(await resolve(path.dirname(currentPath), reexport))
      }
    }
    return `{ ${exports.join(', ')} }`
  } catch (e) {
    return '*'
  }
}

Then, since plugins aren't applied to entry points (evanw/esbuild#546), I use this to write export statements in temporary files. Passing these temporary files as entry points into esbuild allows me to accomplish what esinstall does.

ije commented 3 years ago

@vinsonchuong it's amazing, i will try your reslution!

zheeeng commented 3 years ago

Any progress on this?

ije commented 3 years ago

since the guybedford/cjs-module-lexer @vinsonchuong suggested above can't handle some edge cases, i created another cjs export parser that uses swc ast walker as a wasm module, it is used by esm.sh CDN, for now it is good: https://www.npmjs.com/package/esm-cjs-lexer

ruanyl commented 3 years ago

@ije Nice work! Could you share your setup of using cjs-esm-exports to compile cjs module to esm module? I checked esm.sh and I believe that's what I'm looking for, but I wanna run it locally.

paralin commented 1 year ago

@ije @ruanyl What was the last word on this? What's the best way to fix transforming packages like "react" erasing the esm named exports? Thanks!

ije commented 1 year ago

@paralin check https://github.com/esm-dev/esm.sh/blob/main/packages/esm-node-services/cjs-lexer.js

paralin commented 1 year ago

@ije That works great, thanks, with one issue: https://github.com/esm-dev/esm.sh/issues/713 - exportDefault: true is not set for React for some reason.