-
Notifications
You must be signed in to change notification settings - Fork 1.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Node.js default interop case #532
Comments
It's very unfortunate that the situation with Side note: If we're talking about better interop between bundlers, one thing I'd advocate for is at least behaving consistently regardless of syntax. Right now Webpack, Rollup, and Parcel all expose different exports depending on how you access them syntactically. I did a brief survey of this here: #472 (comment). This makes it difficult for me to know what the most compatible behavior is supposed to be. I scanned the thread but I'm not too sure what the proposal is. The discussion is about how the behavior differs from Rollup, but I'm not too familiar with Rollup's behavior. If the proposal is just aligning with Node's semantics, that would look like this for esbuild I think:
But wouldn't that break compatibility with the existing package ecosystem? I'm trying to make esbuild compatible with the ecosystem so it has a high chance of "just working" with existing packages, as long as it still follows ECMAScript module semantics (e.g. module namespace objects must not be callable). If a package works with Webpack, I'm inclined to also have it work with esbuild, even if it doesn't work with Node. The package may not even be intended for consumption with Node so enforcing strict compatibility with how Node does things even if it breaks stuff seems like it makes unnecessary hassle for users of bundlers. I could see having esbuild do something like the union of the two approaches. If To fully specify the behavior you'd want to describe what is done with non-enumerable properties and properties on the prototype of |
Interestingly it was actually created and specified to specifically support
This is exactly what Node.js has now implemented with named exports extraction as described in https://nodejs.org/dist/latest-v15.x/docs/api/esm.html#esm_commonjs_namespaces. Node.js does this via a static analysis because it cannot execute CJS modules before the ES module execution phase and in the ES module specification named exports must be known at linking time, before execution. As of the next 12.x release this month this named exports support will be fully backported and be the stable legacy target support base by early next yea. So it is fine for bundlers to extract named exports, and for modules without But the compat subset breaks down on the |
Here's a quick compatibility matrix I just generated to get a better idea of the situation:
Here's what each icon means: ✅ = Always imported (never The normal, non-enumerable, and prototype properties were all set to let o = Object.create({ inherited: true })
Object.defineProperty(o, '__esModule', {value: true})
o.normal = true
Object.defineProperty(o, 'nonEnumerable', {value: true})
module.exports = o The test is a It looks like esbuild should be generating a For the discussion, it seems like tools seeking to maximize compatibility will end up just doing the union of what other tools do. So that's where my preference would be and I'm imagining that's where things will end up. |
@evanw I'd suggest using a Node.js statically analyzable test case as well for the Node.js tests: exports.name = 'value'; I'd also suggest checking the shape of
The default should always be
Agreed, but the thrashing issue I'm talking about is when there is mutual exclusivity in the solution space with the case I describe above. |
And I will still be pushing very hard to get RollupJS to change its default behaviour here for the edge case to match Node.js as well, as I think very slightly worse compat now is better than a future of bad compat. The greedy algorithm of software evolution that usually doesn't do too badly breaking down exactly why I'm having this discussion with you. |
Ah, I finally understand. That is tricky. Now that I think about it, I'm pretty sure that's why I didn't make esbuild generate a Here's a simple test file (statically analyzable this time): Object.defineProperty(module.exports, '__esModule', { value: true })
module.exports.default = 123 This is how the different environments behave on this one as far as the value of the import
Node and Webpack are the majority of the ecosystem so if that's the case, I think that should be the behavior to converge to. |
Actually, after thinking about it more I'm not totally sure. Doing that means that converting ESM to CJS can no longer be done correctly, which seems like a mistake. In particular, that would mess up esbuild's automatic ESM-to-CJS transform that happens in certain scenarios. For example, if an ESM module has a direct call to I could always work around this by making esbuild's automatic ESM-to-CJS transform special somehow and excluding files transformed by it from this rule. It'd be great to not have to do that since doing that is even more complexity but I'm not sure what the alternative is. |
|
It's mainly just me trying to implement a correct bundler and minifier. For example, I've been looking at test cases from the UglifyJS and Terser projects and there are lots of test cases about direct eval. Of the real-world uses of direct eval I've seen:
There are other cases where esbuild does ESM-to-CJS in addition to
|
Yeah, another interop discussion... Note that webpack has two different modes. One that's compatible with the ecosystem and one that's compatible with Node.js. Node.js compat mode is used when import originates from a .mjs file (or type: module). Otherwise the normal mode is used. That are the main differences:
And btw. commonjs imports are always live in webpack. Not having live-bindings would break modules that are transpiled from ESM to CJS, so it doesn't really make sense to me why it is this way in Node.js... |
Thanks for pointing this out. It didn't occur to me that this could be the case. This is interesting. Perhaps this could be the approach esbuild ends up taking too.
Good to know. I plan on keeping them live in esbuild too, since that would obviously break ESM code that has been converted to CJS (including code that esbuild auto-converts itself which needs to continue to work correctly, as described above). |
I created a larger test repo to analyse the interop behavior of different tools: https://sokra.github.io/interop-test/ @guybedford Have fun analysing that... @evanw One interesting case is btw. parcel doesn't run on windows, but I created the test case anyway. Maybe someone can run it on a Mac and PR the results. |
This is a fantastic resource. Thanks for creating this.
I was not aware of that case. Thanks for the heads up. Will fix. |
Works great, updated: https://sokra.github.io/interop-test/#esbuild Another runtime crash happens when |
The good thing is I can now link you a summary how other tools behave in this case: https://sokra.github.io/interop-test/#single-promise-object-export PS: I had the same problem in webpack 5 and have chosen that the Promise should be resolved and the result should be returned from |
This way esbuild handles this is changing slightly in the next release. I tried going all-in on the node behavior but it broke compatibility so I'm rolling that back to being mostly still Babel-compatible. This release fixes the case where Given:import defaultValue from './some-cjs-file' Old behavior:
New behavior:
|
Is the following problem when transpiling with
This generated default export value by |
It's unclear what's going on because you didn't post a complete code sample but yes, that sounds like the same thing that's being discussed here. Importing a CommonJS file using If so, you should be able to run Not including a TL;DR: The situation is a mess and you should try to avoid using or relying on |
@evanw please find a reproduction of this bug here: The complete code sample there is 2 files: // named-exports.js
export const a = "a";
export const b = "b"; // index.js
import * as namedExports from "./named-exports";
console.log(namedExports);
console.log(namedExports.default); There are two npm scripts:
whereas when you run
As you could see above, On a side note, your advice is irrelevant as I'm not relying on
|
So what's the solution here? How do I output a Typescript lib to be compatible with node? i.e. given source: export default function jsSerialize(object: any, options?: Partial<Options>): string {
return "whatever";
} If I build my lib with: npx esbuild --bundle src/index.ts --outdir=dist --platform=node --target=node14 And then try to use the lib:
i.e. it's outputting the |
@mnpenner you can write a wrapper to edit the exports object: // cjs-wrapper.js
module.exports = require("./dist/index.js").default
// package.json
"main": "cjs-wrapper.js" |
We ran into this issue as well with the I was able to work around it by adding the following wrapper: module.exports = require('dom-serializer').default; I then added a custom plugin to resolve this wrapper when importing the module.exports = {
name: 'fix-dom-serializer',
setup(build) {
build.onResolve({ filter: /^dom-serializer$/ }, ({ kind }) => {
if (kind === 'import-statement') {
return { path: require.resolve('./dom-serializer-wrapper') }
}
})
},
} By checking the The importing ESModule looks like: import serialize from 'dom-serializer;
// ...
serialize(element); The bundled code of the module using import_dom_serializer = __toESM(require_dom_serializer_wrapper(), 1);
//...
(0, import_dom_serializer.default)(element); I'm not sure if this is the recommended workaround but it seems to work. It can probably be avoided if |
I wrote up some documentation about this here: https://esbuild.github.io/content-types/#default-interop |
Is using a wrapper still required? I feel like it would be nice to have some sort of flag somewhere. I can't think of a package that intends users to do My usage is coding in ESM only and use esbuild to build CJS for backwards compatibility. I would like to have the same syntax for both I also have multiple exports, so it wouldn't be a single wrapper, but several: "exports": {
"./lib/*": "./lib/*",
".": {
"require": "./exports/index.cjs",
"import": "./exports/index.js"
},
"./stream": {
"require": "./exports/stream.cjs",
"import": "./exports/stream.js"
}
}, Edit:
This is exactly what I would want (intended) and I would like if we can do the same with |
Thanks for sharing this quick fix - we've implemented in a PR here to an ESM project with |
Having a flag would be super nice though to do this automatically! Agreed @clshortfuse! |
This PR builds on the work started in #10083. It does two things: - ~builds the `@redwoodjs/vite` package with esbuild instead of babel (this is optional but just did it to debug, but I'd imagine we'd want to start doing this anyway)~ - CI wasn't happy with this change so removed for now - introduces a CJS wrapper to handle ESM export default interoperation in Node To frame this change at a high level, one of the ways I'm approaching ESM is backwards from Redwood apps. If Redwood apps can be ESM (via "type": "module" in package.jsons, updates to tsconfigs, adding file extensions to relative imports, etc), then we may have an easier time rolling out packages that can't be shipped as dual ESM/CJS exports. (Vite is probably one of them. But since Vite evaluates the config file itself, it may be ok.) One of the framework-level bugs that shows up when you make an app ESM is this error: ``` failed to load config from ~/redwood/redwood-app-esm/web/vite.config.mts file:///~/redwood/redwood-app-esm/web/vite.config.mts.timestamp-1709251612918- 6f6a7f8efad05.mjs:7 plugins: [redwood()] ^ TypeError: redwood is not a function at file:///~/redwood/redwood-app-esm/web/vite.config.mts.timestamp-1709251612918- 6f6a7f8efad05.mjs:7:13 ``` The culprit is this import in `web/vite.config.ts`: ```ts import redwood from '@redwoodjs/vite' ``` The problem is confusing, but basically... when you 1) transpile an ES module into CJS (which is what we're doing for most of our packages) and then 2) import that CJS module from a bona fide ES module, the default export doesn't behave how you'd expect. Evan Wallace has a great write up of it [here](https://esbuild.github.io/content-types/#default-interop). (For Node's official docs, see https://nodejs.org/docs/latest/api/esm.html#commonjs-namespaces.) As a potential fix, @Tobbe pointed me to this Vite plugin: https://github.com/cyco130/vite-plugin-cjs-interop. I tried adding it but the issue is that this **is** the Vite config file. 😅 But I thought I may be able to add the plugin here when we call `createServer`: https://github.com/redwoodjs/redwood/blob/e9ecbb07da216210c59a1e499816f31c025fe81d/packages/vite/bins/rw-vite-dev.mjs#L28-L37 But it still wasn't working. I stepped through the source a bit and it doesn't seem like there's room for configuring how Vite loads the config file. The two main functions were these, `loadConfigFromBundledFile` and `loadConfigFromFile`: - https://github.com/vitejs/vite/blob/e92abe58164682c2e468318c05023bfb4ecdfa02/packages/vite/src/node/config.ts#L1186 - https://github.com/vitejs/vite/blob/e92abe58164682c2e468318c05023bfb4ecdfa02/packages/vite/src/node/config.ts#L962 `bundleConfigFile` has plugins configured, but they're hardcoded. But luckily I don't think it's necessary... based on this esbuild thread, it seems like we can get away with a small wrapper. See evanw/esbuild#532. When we actually make this an ES module (which will happen pretty soon I think), this will go away. But for the time being, this is a CJS module.
This PR builds on the work started in #10083. It does two things: - ~builds the `@redwoodjs/vite` package with esbuild instead of babel (this is optional but just did it to debug, but I'd imagine we'd want to start doing this anyway)~ - CI wasn't happy with this change so removed for now - introduces a CJS wrapper to handle ESM export default interoperation in Node To frame this change at a high level, one of the ways I'm approaching ESM is backwards from Redwood apps. If Redwood apps can be ESM (via "type": "module" in package.jsons, updates to tsconfigs, adding file extensions to relative imports, etc), then we may have an easier time rolling out packages that can't be shipped as dual ESM/CJS exports. (Vite is probably one of them. But since Vite evaluates the config file itself, it may be ok.) One of the framework-level bugs that shows up when you make an app ESM is this error: ``` failed to load config from ~/redwood/redwood-app-esm/web/vite.config.mts file:///~/redwood/redwood-app-esm/web/vite.config.mts.timestamp-1709251612918- 6f6a7f8efad05.mjs:7 plugins: [redwood()] ^ TypeError: redwood is not a function at file:///~/redwood/redwood-app-esm/web/vite.config.mts.timestamp-1709251612918- 6f6a7f8efad05.mjs:7:13 ``` The culprit is this import in `web/vite.config.ts`: ```ts import redwood from '@redwoodjs/vite' ``` The problem is confusing, but basically... when you 1) transpile an ES module into CJS (which is what we're doing for most of our packages) and then 2) import that CJS module from a bona fide ES module, the default export doesn't behave how you'd expect. Evan Wallace has a great write up of it [here](https://esbuild.github.io/content-types/#default-interop). (For Node's official docs, see https://nodejs.org/docs/latest/api/esm.html#commonjs-namespaces.) As a potential fix, @Tobbe pointed me to this Vite plugin: https://github.com/cyco130/vite-plugin-cjs-interop. I tried adding it but the issue is that this **is** the Vite config file. 😅 But I thought I may be able to add the plugin here when we call `createServer`: https://github.com/redwoodjs/redwood/blob/e9ecbb07da216210c59a1e499816f31c025fe81d/packages/vite/bins/rw-vite-dev.mjs#L28-L37 But it still wasn't working. I stepped through the source a bit and it doesn't seem like there's room for configuring how Vite loads the config file. The two main functions were these, `loadConfigFromBundledFile` and `loadConfigFromFile`: - https://github.com/vitejs/vite/blob/e92abe58164682c2e468318c05023bfb4ecdfa02/packages/vite/src/node/config.ts#L1186 - https://github.com/vitejs/vite/blob/e92abe58164682c2e468318c05023bfb4ecdfa02/packages/vite/src/node/config.ts#L962 `bundleConfigFile` has plugins configured, but they're hardcoded. But luckily I don't think it's necessary... based on this esbuild thread, it seems like we can get away with a small wrapper. See evanw/esbuild#532. When we actually make this an ES module (which will happen pretty soon I think), this will go away. But for the time being, this is a CJS module.
This PR builds on the work started in #10083. It does two things: - ~builds the `@redwoodjs/vite` package with esbuild instead of babel (this is optional but just did it to debug, but I'd imagine we'd want to start doing this anyway)~ - CI wasn't happy with this change so removed for now - introduces a CJS wrapper to handle ESM export default interoperation in Node To frame this change at a high level, one of the ways I'm approaching ESM is backwards from Redwood apps. If Redwood apps can be ESM (via "type": "module" in package.jsons, updates to tsconfigs, adding file extensions to relative imports, etc), then we may have an easier time rolling out packages that can't be shipped as dual ESM/CJS exports. (Vite is probably one of them. But since Vite evaluates the config file itself, it may be ok.) One of the framework-level bugs that shows up when you make an app ESM is this error: ``` failed to load config from ~/redwood/redwood-app-esm/web/vite.config.mts file:///~/redwood/redwood-app-esm/web/vite.config.mts.timestamp-1709251612918- 6f6a7f8efad05.mjs:7 plugins: [redwood()] ^ TypeError: redwood is not a function at file:///~/redwood/redwood-app-esm/web/vite.config.mts.timestamp-1709251612918- 6f6a7f8efad05.mjs:7:13 ``` The culprit is this import in `web/vite.config.ts`: ```ts import redwood from '@redwoodjs/vite' ``` The problem is confusing, but basically... when you 1) transpile an ES module into CJS (which is what we're doing for most of our packages) and then 2) import that CJS module from a bona fide ES module, the default export doesn't behave how you'd expect. Evan Wallace has a great write up of it [here](https://esbuild.github.io/content-types/#default-interop). (For Node's official docs, see https://nodejs.org/docs/latest/api/esm.html#commonjs-namespaces.) As a potential fix, @Tobbe pointed me to this Vite plugin: https://github.com/cyco130/vite-plugin-cjs-interop. I tried adding it but the issue is that this **is** the Vite config file. 😅 But I thought I may be able to add the plugin here when we call `createServer`: https://github.com/redwoodjs/redwood/blob/e9ecbb07da216210c59a1e499816f31c025fe81d/packages/vite/bins/rw-vite-dev.mjs#L28-L37 But it still wasn't working. I stepped through the source a bit and it doesn't seem like there's room for configuring how Vite loads the config file. The two main functions were these, `loadConfigFromBundledFile` and `loadConfigFromFile`: - https://github.com/vitejs/vite/blob/e92abe58164682c2e468318c05023bfb4ecdfa02/packages/vite/src/node/config.ts#L1186 - https://github.com/vitejs/vite/blob/e92abe58164682c2e468318c05023bfb4ecdfa02/packages/vite/src/node/config.ts#L962 `bundleConfigFile` has plugins configured, but they're hardcoded. But luckily I don't think it's necessary... based on this esbuild thread, it seems like we can get away with a small wrapper. See evanw/esbuild#532. When we actually make this an ES module (which will happen pretty soon I think), this will go away. But for the time being, this is a CJS module.
I'm posting this here, which is a discussion that came up between Node.js and RollupJS but has grown to include members from other projects as well. See rollup/plugins#635 for the original issue.
@evanw I would really value your feedback here as interop is one of those things where it happens too quickly in a brief moment, and then we are stuck with it for years. Even if the action is do nothing, we need to make sure it is a considered do nothing.
If you have a chance to read that thread or have any questions I would value your input. Perhaps we may just have to live with some thrashing. I still hope not. Best case we get alignment between bundlers.
The text was updated successfully, but these errors were encountered: