Skip to content
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

Should modern option be orthogonal to module format? #618

Closed
cowboyd opened this issue May 14, 2020 · 7 comments
Closed

Should modern option be orthogonal to module format? #618

cowboyd opened this issue May 14, 2020 · 7 comments

Comments

@cowboyd
Copy link

cowboyd commented May 14, 2020

We recently decided that we wanted to drop support for older browsers and move to not transpiling generators and async/await. Our problem is that (using the recently released 0.12) we can't seem to generate a modern JavaScript that is transpiled to both commonjs and esm, and it's unclear whether it is possible to do this with microbundle today.

Given the churn around #518 #582 #570 and what is hopefully the final fix with #605 it has me wondering if things would be simpler both internally and externally if "modernity" were treated as type of content rather than a module format.

In other words, the module format could vary independently of the transpilation target. That way you could run something like:

$ microbundle -f esm,umd,cjs --target modern,es5

And microbundle would emit a matrix of modules crossed by content type and module format such as:

my-package.modern.esm.js
my-package.es5.esm.js
my-package.modern.umd.js
my-package.es5.umd.js
my-package.modern.cjs.js
my-package.es5.cjs.js

This would give package maintainers the ability to target modernity (or lack thereof) and be able to deliver easily regardless of the consumer's preferred module format.

@developit
Copy link
Owner

Hi Charles - just a foreword, this reply got a little long. My apologies in advance! In terms of a straightforward answer to your petition, you can actually already use a browserslist key in package.json or a .browserslistrc file to specify your browser/node support target. It governs the syntax output in all files except .modern.js (which in your description you would actually want to disable).

Now, on to the underlying question: how can we publish modern code to npm without breaking everything?

FWIW, unless you're specifically targeting only Node.js, publishing a package as modern-only is going to create a lot of confusion. Create React App transpiles node_modules by default, but no other popular bundler configurations do - that means most projects simply aren't set up to handle modern code in npm packages.

That's not to say I don't want to fix the whole "modern npm packages" issue - very much the opposite. I believe there is a way forward, but from both my own experience and in talking to the folks who maintain downstream tooling, publishing modern-only browser packages isn't going to get us there. Instead, I think we may be in a position to standardize on the existence of an Export Map as the indicator that a package provides modern code. Because Export Maps have a designated mechanism for falling back to the package.json main field, they can also be used to fall back to ES5.

However, there's a huge missing piece. In all of these approaches, from modern-only packages to backwards-compatible multi-mode packages, the syntax we refer to as "modern" is an arbitrary point-in-time decision that happens on a per-package basis. Scale that up to the whole ecosystem and we end up having no way to know if a package is ES3 or ES2020 - bad for performance, and potentially impossible to work with. The "modern" name used by Microbundle refers to "the JavaScript syntax supported in all browsers that implement <script type=module>". It's a mouthful, and my fingers are tired from having to explain that sentence to hundreds of people at this point, but it's the only syntax "cutoff" (or target) that is based on real-world browser support constraints.

@cowboyd
Copy link
Author

cowboyd commented May 15, 2020

Thanks for the insight into where microbundle is coming from. Indeed, the only thing we can be sure about what "modern" JavaScript is that it will eventually be succeeded by "post-modern" JavaScript.

Sometimes I feel what's really needed is a meta-language for packages to declare unambiguously which language features they use so that a decision on what transformations to make can be deferred all the way until very last moment where an application itself is assembled. Because really, only the app knows what its target is. As it stands these days, it feels analogous to shipping C++ code with partially evaluated preprocessor macros.

Again, thanks!

@cowboyd cowboyd closed this as completed May 15, 2020
@developit
Copy link
Owner

developit commented May 15, 2020

That's a great analogy.

FWIW here's the current best we can do at providing that mega-language based on properties already supported in Node.js (and coming soon to webpack & rollup):

// package.json
{
  "main": "./es5.umd.js",              // ← lowest common denominator (Node LTS, IE11, etc)
  "module": "./es5.esm.js",            // ← optional, enables tree shaking in legacy bundler configurations
  "browser": "./es5.browser.umd.js",   // ← for all browsers (legacy support)
  "exports": {
    "browser": "./es2017.browser.mjs", // ← for modern <script type="module">-supporting browsers
    "import": "./es2017.mjs",          // ← for Node 12+ and <script type=module>
    "default": "./es5.umd.js"          // ← for Node 12+ require()
  }
}

My plan for the next version of Microbundle (our 1.0) is to use Export Maps to completely control what gets bundled. A microbundle package would look like this:

{
  "name": "foo",
  "main": "./dist/foo.es5.umd.js", // the fallback/web UMD bundle
  "module": "./dist/foo.es5.js",   // the fallback/root ESM+ES5 bundle
  "type": "module",
  "exports": {
    ".": {  // main entry module
      "browser": "./dist/foo.browser.js",  // <script type=module> + MJS
      "module": "./dist/foo.js",           // modern + MJS
      "require": "./dist/foo.cjs"          // modern + CJS   (optional)
      "default": "./dist/foo.umd.js"       // modern + UMD
    },
    "./hooks": {  // additional entry modules (instead of passing files!)
      "browser": "./hooks/index.browser.js",
      "module": "./hooks/index.js",
      "default": "./hooks/index.umd.js"
    },
}

Microbundle will validate the export map configuration, which means every Microbundle package will be correctly loadable in Node and bundlers going forward, with no manual process to follow. Building the above would produce:

dist/
    foo.es5.js
    foo.es5.umd.js
    foo.browser.js
    foo.js
    foo.cjs
    foo.umd.js
hooks/
    index.browser.js
    index.js
    index.umd.js

@donmccurdy
Copy link
Contributor

Perhaps this ticket could be reopened? I'd be interested in a 'modern' CJS variant, as I'm still trying to support CJS for (modern) Node.js versions. My library targets multiple runtimes, so I don't think I can use --target node.

I do eventually hope to go ESM-only, but it seems the timing is never right...

@rschristian
Copy link
Collaborator

I'd be interested in a 'modern' CJS variant, as I'm still trying to support CJS for (modern) Node.js versions. My library targets multiple runtimes, so I don't think I can use --target node.

Are you sure you need "browser CJS"[1] output? Pretty much every bundler for the past 5+ years will prefer ESM if your library provides it, often leaving CJS as being exclusively consumed in Node. We don't clear your output directory or anything before a build, so you could just run Microbundle twice, the second time with --target node & -f cjs.

[1]: By "browser CJS", I mean CJS code that will be fed into a bundler for use in browser envs -- obviously CJS itself won't ever be executed in a browser context.

@donmccurdy
Copy link
Contributor

donmccurdy commented Oct 27, 2023

Are you sure you need "browser CJS" output?

Maybe I don't! I'll ask around. Would my package.json look something like this, in that scenario?

"type": "module",
"sideEffects": false,
"exports": {
  "types": "./dist/pkg.d.ts",
  "node": {
    "types": "./dist/pkg.d.ts",
    "require": "./dist/pkg.cjs" // built with --target node
    "default": "./dist/pkg.modern.js"
  },
  "default": "./dist/pkg.modern.js"
},
"types": "./dist/pkg.d.ts",
"module": "./dist/pkg.esm.js",

I'm supporting Node.js v18+, modern browsers, and Deno if that makes a difference.

@rschristian
Copy link
Collaborator

That should work fine, but is there even a distinction between your browser and Node builds if you're supplying the same modern build to each? Not sure if that's just done for the sake of a simple example or not.

If not, you could go even simpler:

{
  "type": "module",
  "sideEffects": false,
  "exports": {
    "types": "./dist/pkg.d.ts",
    "require": "./dist/pkg.cjs" // built with --target node
    "default": "./dist/pkg.modern.js"
  },
  "types": "./dist/pkg.d.ts",
  "module": "./dist/pkg.esm.js",
  "main": "./dist/pkg.cjs" // though this has a tiny bit of danger in old bundlers & Node versions that don't recognize `.cjs` -- could flip the module type if necessary
}

The question is, do you need to ship a CJS bundle meant for the browser (say, using document or window) AND async/await? --target node doesn't do a ton, basically just supports Node 12 syntax (roughly ES2017) & won't raise noise if you're using built-ins like node:fs and whatnot. If your library doesn't need to distinguish between Node & browser builds, you totally can just build for the Node target only and have the syntax you want (--target web does minify though, so you'd lose that).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants