Move on to ESM-Only
antfu.me
antfu.me
Since Node 18 maintenance ends in less than a month, this means all Node versions will have good esm support, including for consuming esm-only libraries (which until recently did not work!)
This is noted somewhat in the article, but basically is the whole story to me. Its now possible to stop doing CJS libraries entirely. And with that, I don't see why we would do CJS at all.
If they need to use a new library version, they can install their own version of node instead of relying on the OS supplied one.
All the OS version is doing is supplying the convenience of not having to install it.
Or have I misunderstood how those versions of node are used?
If a user wants to use an old version of node, they'll have to use an old version of the library that still supports cjs.
However given my NPM experiences in the past, I would not be surprised that someone updated to ESM in a revision bump.
https://gist.github.com/joepie91/bca2fda868c1e8b2c2caf76af7d...
Reading that rant, the author did not seem to take much time to understand the rationale behind ES modules: “ yet for some completely unclear reason, ESM proponents decided to remove that property” is just pure ignorance.
In a limited way that covers something like 99.9% of CJS usage. Bundling is based on static analysis.
If by side effects you mean running code, not just declaring exports, ES6 import does these side effects too.
Thinking that CJS can't be used for static analysis or tree-shaking is the widest spread pure ignorance.
await import( someExpression )
Besides, static analysis and tree shaking completely break down if modules impose side effects from being required, which is one of the main gripes of Python as well. ESM completely alleviates that.ESM import runs side effects as well.
const app = express()
app.use("/users", await import("./routers/users"))
…if you’re so inclined to do things exactly as you did in the past. I’m pretty sure bundlers will even transform that into an ordinary module import.The cases where you actually need dynamic imports are few and far between. Are we actually talking about engine limitations here, or is it just a few snowflakes that insist on creating loggers like this?
require("debug")("acme")
What is so particularly pretty about that is beyond me.> ESM import runs side effects as well.
At runtime, yes. Not during static analysis.
Depending on how your application is structured, an ESM version of a server-only Node application may still benefit from the performance optimizations of the server being able to do dead code elimination at runtime.
Latest Node version added Node options as config feature. I wish that was ported to every version of Node.
Otherwise you have to set NODE_OPTIONS which can often be overwritten by some scripts in the execution chain.
I also feel like browser support “feels” more official than support from bundlers.
Just weird to leave out a mention of that.
My understanding is that HTTP 2 pipelining was supposed to make requesting lots of ESM modules from the same site fast enough that bundling could be consigned to history, but for various reasons it didn't work as intended and it was ultimately removed from most browsers.
This article delves into just that: https://csswizardry.com/2023/10/the-three-c-concatenate-comp... The first pair of waterfall graphics illustrates the problem clearly.
(What you vaguely remember as not working as intended is probably Server Push).
HTTP/2’s better multiplexing helps a little, but you’ve still completely got the waterfall problem: so you have at an absolute minimum of overhead the dependency graph depth times the round-trip time—frequently multiple seconds.
HTTP/2 Server Push could have improved it in some regards, as it can in theory break out of the waterfall problem, but in practice it would have required much more complex servers, and was missing important pieces so that it was completely useless anyway (a way for the client to tell the server what resources it has cached), and they eventually just removed it all round rather than inventing and implementing the missing pieces, with which it still would have been a good deal less efficient to execute than bundling.
Minifying and bundling is just better, no matter what.
HTTP/2+ Pipelining works pretty well. (As others mentioned, it was HTTP/2 Server Push that didn't quite survive in the wild, which could have helped additional scenarios.)
I've been personally moving towards Vite's dev approach for Production (just external deps and big libraries with esbuild) and little to no bundling in dev (only external deps that don't run out-of-the-box as ESM with an importmap). A handful of small "local" files and couple big shared libraries works very well in Production in my experience.
Sure, there's a learning curve to running even a one-liner localhost HTTP server, but when I was learning HTML the first time there were all sorts of strange learning curves (many of which aren't even relevant anymore).
Sure, it makes it harder to distribute things like Twine apps, but there are known workarounds (bundle all the scripts into the HTML file) that Twine already does. (I'd love to see a new well-supported "HTML app container" format/standard to download/distribute HTML apps safely. It seems unfortunate that PWAs went so deep into Service Worker mania and the simple ideas like I should be able to ZIP a folder of HTML and JS files, rename it to something like myapp.pwa and it "just work" kind of got lost in several shuffles. Sure, Service Worker-based file auto-updating is nice when it works, but it is so complicated and sometimes I just want a dumb ZIP-like file users can double-click.)
Presumably for download size and backward compatibility everyone will still serve JS to browsers.
Now typescript just needs some better defaults from tsc --init to match :)
That being said, there are currently still some hurdles. Necessity for explicit file-extensions in the imports is definitely the big offender (it's invalid typescript syntax without the allowImportingTsExtensions-flag).
The trend is definitely clear though, most devs want ESM, most devs want types; it's just a matter of time until the ecosystem adapts. I suppose for types to finally land in the browser, TC39 will have to undergo the "progress is one funeral at a time"-principle, which will probably take another while.
> Modern Tools
> With the rise of Vite as a popular modern frontend build tool, many meta-frameworks like Nuxt, SvelteKit, Astro, SolidStart, Remix, Storybook, Redwood, and many others are all built on top of Vite nowadays, that treating ESM as a first-class citizen.
> As a complement, we have also testing library Vitest, which was designed for ESM from the day one with powerful module mocking capability and efficient fine-grain caching support.
> CLI tools like tsx and jiti offer a seamless experience for running TypeScript and ESM code without requiring additional configuration. This simplifies the development process and reduces the overhead associated with setting up a project to use ESM.
> Other tools, for example, ESLint, in the recent v9.0, introduced a new flat config system that enables native ESM support with eslint.config.mjs, even in CJS projects.
I think the author and I must have very different definitions for the word "ready"
And yes I know that Grafana has a fork called Sobek that has ESM support, but it is tailored for k6 and they don't have plans for making it easier to use. [2]
The thing that is annoying with ESM is that it requires to have extensions in imports, i.e. `import .. from '/foo.js'`.
This gets messy with TypeScript, where your files are named `foo.ts` but you need to import `foo.js`.
The previous "best practice" in TS world was to have extensionless JS imports, so this move would require a massive codemod to update all imports in an entire codebase.
For now, we've been using `ts-node` with swc under the hood, but without ESM. I tried `tsx`, but the compilation time of esbuild is way too slow, some of our node CLI tools written in TS take 15s to boot up, which is not acceptable (with `ts-node` it's 3-4s) (tbh, it's probably partly a fault of our CLI framework, which crawls all workspaces to discover all CLI tools, and as we have a lot of them, tsx has a lot of useless work to do).
- rewriteRelativeImportExtensions: this will allow you to write `import foo from './foo.ts'` and have tsc transform it to `import foo from './foo.js'`
- erasableSyntaxOnly: this will error on non "erasable" syntax, that is, TypeScript code that has a runtime output (e.g. enums)
With these two settings enabled, you'd be able to run TypeScript code directly with Node: `node src/index.ts`, and cut boot up time substantially
I created a small tool to address ESM + TypeScript issues that the tsc doesn't handle: https://github.com/2BAD/tsfix
import foo from '@Schemas/foo.ts' won't work since it is not a 'RelativeImport'. Is there a fix for this use case?
In my experience the reason people don’t is that it offends their aesthetics.
Which I understand. But personally I don’t program for aesthetics.
https://github.com/theogravity/fastify-starter-turbo-monorep...
Usage:
Search for loader.js refs.
https://github.com/theogravity/fastify-starter-turbo-monorep...
Does it?
The chart being used in the opening argument is far from compelling. When I hold my phone sideways, it looks as though the ESM portion is plateauing or feebly (at best) increasing. Maybe by 2040 we’d see widespread ESM adoption?
Jokes aside, I prefer ESM (and would prefer if everyone else preferred it), but leading with adoption rates is weak sauce.
What problems did you encounter specifically? Did you report them?
I would not want to set up a new project from scratch myself, but with the templates it works great, as long as you don't have to do anything outside of what they want you to do.
Also, last time I tried to use ESM in a browser with Vue and unpkg without builders and web server, it didn't work (probably because one cannot import modules from a local folder). I wish support for ESM and localhost was improved also. I don't want to use a webserver or a bundler because it is easier without them so I switched to legacy scripts without modules.
Pretty sure this works and it is indeed possible to use ES modules without builders and a webserver, I recently rewrote a Node app this way (with Claude's help even because I'm a ES noob). No idea about Vue and unpkg though, there could be an issue with that.
edit: eh you mean like "import 'file://module.js'", but I don't understand why would you need that when you can use "import './module.js'"
> file: URLs are supported by many non-browser runtimes such as Node, since scripts there already have file: URLs, but they are not supported by browsers due to security reasons.
If you open HTML file directly in a browser, it cannot import ES modules from a filesystem. It can import legacy scripts, but they, as I understand, cannot import anything at all.
[1] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...
This works in Firefox, I have one project open like that right now. Chrome blocks that because of their CORS policy, but that's another issue, can be mitigated by some commandline options (--allow-file-access-from-files --cors-exempt-headers "*")
Firefox has the same behavior (I just tested using Firefox 136 on macOS). The error message says "CORS request not HTTP"[0]. You might have disabled `security.fileuri.strict_origin_policy` in about:config?
[0]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/COR...
Therefore, when it is possible to not break practical code, it should not be done. This is the "linux philosophy", if you will. Among JS runtimes, Bun is a great example. In bun today, you can, in the same file (!!), with zero code changes, 100% transparently:
1. require() a module regardless of whether it is cjs or esm [1]
2. synchronously `import` a module regardless of whether it is cjs or esm.
3. do either of this with typescript or javascript
Some of these required changes in JavascriptCore, which were implemented - it's good to see a lack of cribbing about "it needs to be supported in upstream" and a focus on practical end-user usage.
Any runtime/kernel that doesn't put this level of effort into not breaking code is just not serious. While Node is at least moving in the right direction, supporting require(esm) with some restrictions, Deno is completely hopeless - CJS code is completely unusable on Deno. The reasoning is crazy too - how does it matter if ESM is the "standard", if millions of lines of practical, real world code is using CJS? It does not matter that popular libraries move to ESM. Even a single internal tool that's locked into CJS for some reason means that a project cannot move away from it easily. Then like someone mentioned below, many "plugin architectures" and "command architectures" are pretty much locked in to require(). The whole Deno project screams "ideology > pragmatism". Hopefully, just like they came to their senses w.r.t node compat [2], they implement CJS interop as well.
[1] You cannot require() an ESM module that uses top level await
[2] In contrast, bun's node compat story is very good, they run the Node.js test suite on every commit to Bun, and pass a huge majority of the test cases. Track the progress here: https://bun.sh/docs/runtime/nodejs-apis
There is even some effort put into V8 APIs(in a JSC runtime!!). This helps with using Bun with the usual debugger in the chrome browser/VSCode, modules that use `node:v8`, etc,. Read about it here: https://bun.sh/blog/how-bun-supports-v8-apis-without-using-v...
There is also compat the other way - there are the beginnings of node/browser polyfills for Bun-only APIs: https://github.com/oven-sh/bun/tree/main/packages/bun-polyfi...