Introduction
I joined this group during it's inception for one reason; greasing the path to ship ES packages. The basis of that goal is rooted in exploring concrete implementations, tooling, and standards.
Essentially...
- what is available now?
- not what may be.
- not what could be.
- not what will eventually be.
Here are my findings of building a package from a ESM-first (ie "type": "module") perspective
The Package
I wanted to create a library that masquerades as a utility library. It would be published to NPM immediately and updated as progress is made in this group. It should work as a good example but presented it in a way that it isn't taken seriously; so -- in its experimental state -- it doesn't get adopted into an actual production codebase.
What I came up with is Absurdum
A collection of Lodash-esque operators implemented using only reduce and only ES modules.
Evolution
Modules support is a moving target so this library has been updated to keep up-to-date with each milestone.
Phase 1: No Module Support
I loathe C++ so compiling the Node.js source was off the table. Instead, I tapped the same old pattern that the FrontEnd ecosystem has been stuck with for the past 5 years. Transpiling.
Implementation:
- the source is ESM
- ESM is transpiled down to CJS using Babel
- testing is provided by Tape.js
- tests are implemented using CJS
- chokidir is used to watch for changes and re-transpile the source
Observations:
This was by-far the worst DX. As in, an order-of-magnitude more painful than anything used since. Transpiling presents a higher barrier of entry for contributors, transpiled code is harder to debug, setup is painful, shipped code is bloated, browser compat requires a 'kitchen sink' build, and context switching between CJS <-> ESM is not fun.
Phase 2: @std/esm
A few months after this group formed @jdalton shipped the @std/esm package. Which was nothing short of awesome. For the first time, ESM could be used w/o transpilation.
Benefits:
- Tape.js 'just works' w/ the module
- tests could now be written in ESM
Drawbacks:
- the extra layer of abstraction still exists (but is hidden)
- requires an additional dependency
Observations:
Big step in the 'right direction' in terms of DX. Lowered barrier-of-entry is a huge win. Unfortunately, it's still not actually standard JS.
Phase 3: --experimental-modules
This phase includes 2 significant changes. Rudimentary ESM support shipped in both Node and Chrome.
Benefits:
- finally, no extra layer of abstraction
- no additional dependencies required
- debugging ESM (ie via VSCode) is fantastic
- no more 'kitchen sink' build
- ESM 'just works' in browsers
Drawbacks:
- testing/tooling unexpectedly breaks
- browsers only work w/ relative imports
Observations:
DX is beautiful. I can't describe how awesome it was to finally have immediate feedback and full debugging support w/o a complicated build stack. After some research, it appears that Tape.js tests work fine but the test runner eats the experimental flag. I managed a workaround by manually implementing a glob-matching runner using linux commands.
Phase 4: Dev Velocity++
Changes:
- tests were co-located w/ the source (ie no more test/ dir)
- lots more operators added
- process improved to include documentation
- API simplified following a V8 release
- official support means I can automate testing/publishing via CI/CD
DX is not only nice but fast. Whatever effort I wasted previously trying to work around a complicated build process could be focused instead on adding actual value to the project. This is the win I've been anticipating for years.
Phase 5: Approaching Prod-Ready
Frustrated by the continual delay of ESM being unflagged, I decided that -- if I'm going to present this -- I should at least make it look like a prod-ready package.
Changes:
- add 'compatibility' bundles
- add JSDoc strings w/ types
- automate documentation creation (ie from JSDoc)
- add
.d.ts (ie typings) support1
- transition CI/CD from CircleCI to GH Actions2
1Still experimental, can only be done w/ the latest Typescript RC release
2Not necessary, but I managed pick up Beta access and was itching to try it
Bundling:
Since the source is 100% browser-compatible, node-specific module resolution isn't used. While great for DX, the package doesn't play nice with older patterns (ie pre-esm node and bundling). To address this, I create and ship 2 compatibility bundles using Rollup.js. An ESM bundle mapped to pkg.module, and a CJS bundle that can be deep imported in old versions of Node.
JSDoc:
Turns out JSDoc is the oft-overlooked 'secret sauce' of vanilla JS. JSDoc strings not only serve as inline documentation but with the help of tooling can be used for much more.
Documentation:
The doc generation step kind of sucks, the best option I found was DocDown (ie used by Lodash). But I had to modify it to support one-doc-per-module documentation creation. The JS ecosystem has been bundle-focused for so long that even a lot of the tooling is still stuck on that pattern.
Type Checking:
VSCode supports typechecking via JSDoc types out-of-the-box. Nothing more to say, this is incredible.
Typings:
Supposedly not required. I can't say, in the past I've only used Typescript for typed JS. I figure, if I'm going to ship a typed JS it should follow the usual TS 'best practices' for packaging.
Automation:
After months of practice w/ CI/CD I have a well-defined set of workflows. Every push gets verified (ie test/lint/types), every tagged push gets published (ie verify/build/bump/publish).
Observations:
This phase transcends just DX. Typed vanilla JS is incredible. Automatic documentation generation is great but there's a ton of room for improvement in this space. Automating 'all the things' is such a massive time saver, I loathe to think how much time on non-value-add processes. No lie, if I'm 'in the zone' I could easily ship a dozen-or-more releases in a single day.
Phase 6: Unflag ESM (Current)
Not much left
Changes:
- remove all
--experimental-modules flags
- use
tape-es in place of janky test script
- update node version in CI/CD
Testing:
Contrary to my initial assumptions, the Tape.js test runner does not 'just work' with ESM. As a result I created tape-es to replace the sketchy shell-based test runner I've been using.
The test runner is simple, it glob matches to locate the test files and spawns subprocesses to run the tests concurrently with a default max of 10 threads. This runs the tests 3x faster than the previous strategy.
In theory, if the subprocesses run in a separate context then this runner should be capable of running both CJS and ESM. The one downside is the '-r' flag used to pre-import a dependency will never work with this.
CI/CD:
Remarkably, bumping the node version just worked. Now that the tests run 3x faster, CI/CD is fast; like, really fast.
Debug:
For whatever reason VSCode doesn't respect the Node version specified by nvm. This could be user error. Either way, I'll leave the --experimental-modules flag in the debug config for now.
Observations:
ESM as a universal module format works beautifully in both browsers and Node. I'm really looking forward to the day when jank workarounds are the exception. ESM landing unflagged in LTS will be key.
This message ExperimentalWarning: The ESM module loader is experimental. really muddies the output. I can't wait until it's removed.
On an unrelated note. Is tape-es the first pure ESM-based CLI?
Appendix A - Entry Points
I glossed over this b/c it's hard enough to work on the 'bleeding edge' without trying to hit a constantly moving target. While not optimal, here's what I use.
- pkg.main - points to the public API (ie index.js)
- pkg.module - points to the ESM build
- legacy - CJS requires a deep import
It's not that I dislike CJS, I just like ESM imports/exports so much better. By leveraging the capabilities of ESM it's finally possible to build an actual public API.
By comparison, deep imports are really bad. They unnecessarily expose implementation specifics of the package to users. As general rule, if users can see it some will inevitably depend on it. This makes major refacors much more painful than they need to be.
Ideally, I would prefer that (non-contributing) users will never have to open the 'src' directory.
Appendix B - Bundling
Fact, converting ESM->CJS is easier than CJS->ESM. To put it simply, CJS is a 'lesser' format. Meaning, it has fewer features/capabilities than ESM.
The transition path discussed in this group has been backward all along. Not only is the CJS produced by down-conversion less bloated than the opposite, it's also tree-shake-friendly for consumption by bundling tools.
Yes, doing a full refactor to ESM on a large+ scale project is going to be painful (can this be automated?). The silver lining is, once it's done providing backward compat -- CJS, or even ES5 -- build requires very little additional effort.
Appendix C - Dependencies
What about dependencies? This package doesn't include any but -- long story short -- they 'just work'1. Relative importing from node_modules sucks but it's only a minor inconvenience.
*1 I know this from other ES packages I've built for the FrontEnd like wc-markdown
Appendix D - Tooling
Tools that depend heavily on Node/CJS-specific patterns are going to suffer. I have already addressed this in ESLint but that is only a fix for side-loading CJS across package boundaries. Tools that rely on 'magic globals' for convenience are going to transition to ESM.
Also, take this with a grain of salt based on very limited experience. IMO, there's no way to accurately judge the impact ESM will have on the existing tooling ecosystem until support is rolled out at scale.
Appendix E - Obsolete Module Formats (ie IIFE/AMD/UMD)
Unlike CJS -- which integrates relatively well w/ ESM -- older formats really do not. ESM runs in strict mode by default. So, all the packages that bind to globals and include conditional require statements will break.
Speaking from experience, finding and patching these issues is a major PITA. Getting maintainers to merge fixes on these really old projects is nearly impossible.
Finding viable replacements for these really old packages will be a necessary requirement of building an ES package. If ESM achieves ubiquitous adoption, it will likely obsolete a not-insignificant chunk of the package ecosystem.
This should go without saying but this write up is nothing more than a snapshot of 'what is possible' considering the current state of standards and ES module support in both Node and browsers.
What it is not is a qualitative judgement on any debates/decisions made by this group. I'm here strictly as an 'observer'. Opinions and observations stated here are just that, opinions and observations.
Introduction
I joined this group during it's inception for one reason; greasing the path to ship ES packages. The basis of that goal is rooted in exploring concrete implementations, tooling, and standards.
Essentially...
Here are my findings of building a package from a ESM-first (ie
"type": "module") perspectiveThe Package
I wanted to create a library that masquerades as a utility library. It would be published to NPM immediately and updated as progress is made in this group. It should work as a good example but presented it in a way that it isn't taken seriously; so -- in its experimental state -- it doesn't get adopted into an actual production codebase.
What I came up with is Absurdum
A collection of Lodash-esque operators implemented using only reduce and only ES modules.
Evolution
Modules support is a moving target so this library has been updated to keep up-to-date with each milestone.
Phase 1: No Module Support
I loathe C++ so compiling the Node.js source was off the table. Instead, I tapped the same old pattern that the FrontEnd ecosystem has been stuck with for the past 5 years. Transpiling.
Implementation:
Observations:
This was by-far the worst DX. As in, an order-of-magnitude more painful than anything used since. Transpiling presents a higher barrier of entry for contributors, transpiled code is harder to debug, setup is painful, shipped code is bloated, browser compat requires a 'kitchen sink' build, and context switching between CJS <-> ESM is not fun.
Phase 2: @std/esm
A few months after this group formed @jdalton shipped the
@std/esmpackage. Which was nothing short of awesome. For the first time, ESM could be used w/o transpilation.Benefits:
Drawbacks:
Observations:
Big step in the 'right direction' in terms of DX. Lowered barrier-of-entry is a huge win. Unfortunately, it's still not actually standard JS.
Phase 3:
--experimental-modulesThis phase includes 2 significant changes. Rudimentary ESM support shipped in both Node and Chrome.
Benefits:
Drawbacks:
Observations:
DX is beautiful. I can't describe how awesome it was to finally have immediate feedback and full debugging support w/o a complicated build stack. After some research, it appears that Tape.js tests work fine but the test runner eats the experimental flag. I managed a workaround by manually implementing a glob-matching runner using linux commands.
Phase 4: Dev Velocity++
Changes:
DX is not only nice but fast. Whatever effort I wasted previously trying to work around a complicated build process could be focused instead on adding actual value to the project. This is the win I've been anticipating for years.
Phase 5: Approaching Prod-Ready
Frustrated by the continual delay of ESM being unflagged, I decided that -- if I'm going to present this -- I should at least make it look like a prod-ready package.
Changes:
.d.ts(ie typings) support11Still experimental, can only be done w/ the latest Typescript RC release
2Not necessary, but I managed pick up Beta access and was itching to try it
Bundling:
Since the source is 100% browser-compatible, node-specific module resolution isn't used. While great for DX, the package doesn't play nice with older patterns (ie pre-esm node and bundling). To address this, I create and ship 2 compatibility bundles using Rollup.js. An ESM bundle mapped to
pkg.module, and a CJS bundle that can be deep imported in old versions of Node.JSDoc:
Turns out JSDoc is the oft-overlooked 'secret sauce' of vanilla JS. JSDoc strings not only serve as inline documentation but with the help of tooling can be used for much more.
Documentation:
The doc generation step kind of sucks, the best option I found was DocDown (ie used by Lodash). But I had to modify it to support one-doc-per-module documentation creation. The JS ecosystem has been bundle-focused for so long that even a lot of the tooling is still stuck on that pattern.
Type Checking:
VSCode supports typechecking via JSDoc types out-of-the-box. Nothing more to say, this is incredible.
Typings:
Supposedly not required. I can't say, in the past I've only used Typescript for typed JS. I figure, if I'm going to ship a typed JS it should follow the usual TS 'best practices' for packaging.
Automation:
After months of practice w/ CI/CD I have a well-defined set of workflows. Every push gets verified (ie test/lint/types), every tagged push gets published (ie verify/build/bump/publish).
Observations:
This phase transcends just DX. Typed vanilla JS is incredible. Automatic documentation generation is great but there's a ton of room for improvement in this space. Automating 'all the things' is such a massive time saver, I loathe to think how much time on non-value-add processes. No lie, if I'm 'in the zone' I could easily ship a dozen-or-more releases in a single day.
Phase 6: Unflag ESM (Current)
Not much left
Changes:
--experimental-modulesflagstape-esin place of janky test scriptTesting:
Contrary to my initial assumptions, the Tape.js test runner does not 'just work' with ESM. As a result I created
tape-esto replace the sketchy shell-based test runner I've been using.The test runner is simple, it glob matches to locate the test files and spawns subprocesses to run the tests concurrently with a default max of 10 threads. This runs the tests 3x faster than the previous strategy.
In theory, if the subprocesses run in a separate context then this runner should be capable of running both CJS and ESM. The one downside is the '-r' flag used to pre-import a dependency will never work with this.
CI/CD:
Remarkably, bumping the node version just worked. Now that the tests run 3x faster, CI/CD is fast; like, really fast.
Debug:
For whatever reason VSCode doesn't respect the Node version specified by nvm. This could be user error. Either way, I'll leave the
--experimental-modulesflag in the debug config for now.Observations:
ESM as a universal module format works beautifully in both browsers and Node. I'm really looking forward to the day when jank workarounds are the exception. ESM landing unflagged in LTS will be key.
This message
ExperimentalWarning: The ESM module loader is experimental.really muddies the output. I can't wait until it's removed.On an unrelated note. Is
tape-esthe first pure ESM-based CLI?Appendix A - Entry Points
I glossed over this b/c it's hard enough to work on the 'bleeding edge' without trying to hit a constantly moving target. While not optimal, here's what I use.
It's not that I dislike CJS, I just like ESM imports/exports so much better. By leveraging the capabilities of ESM it's finally possible to build an actual public API.
By comparison, deep imports are really bad. They unnecessarily expose implementation specifics of the package to users. As general rule, if users can see it some will inevitably depend on it. This makes major refacors much more painful than they need to be.
Ideally, I would prefer that (non-contributing) users will never have to open the 'src' directory.
Appendix B - Bundling
Fact, converting ESM->CJS is easier than CJS->ESM. To put it simply, CJS is a 'lesser' format. Meaning, it has fewer features/capabilities than ESM.
The transition path discussed in this group has been backward all along. Not only is the CJS produced by down-conversion less bloated than the opposite, it's also tree-shake-friendly for consumption by bundling tools.
Yes, doing a full refactor to ESM on a large+ scale project is going to be painful (can this be automated?). The silver lining is, once it's done providing backward compat -- CJS, or even ES5 -- build requires very little additional effort.
Appendix C - Dependencies
What about dependencies? This package doesn't include any but -- long story short -- they 'just work'1. Relative importing from
node_modulessucks but it's only a minor inconvenience.*1 I know this from other ES packages I've built for the FrontEnd like wc-markdown
Appendix D - Tooling
Tools that depend heavily on Node/CJS-specific patterns are going to suffer. I have already addressed this in ESLint but that is only a fix for side-loading CJS across package boundaries. Tools that rely on 'magic globals' for convenience are going to transition to ESM.
Also, take this with a grain of salt based on very limited experience. IMO, there's no way to accurately judge the impact ESM will have on the existing tooling ecosystem until support is rolled out at scale.
Appendix E - Obsolete Module Formats (ie IIFE/AMD/UMD)
Unlike CJS -- which integrates relatively well w/ ESM -- older formats really do not. ESM runs in strict mode by default. So, all the packages that bind to globals and include conditional
requirestatements will break.Speaking from experience, finding and patching these issues is a major PITA. Getting maintainers to merge fixes on these really old projects is nearly impossible.
Finding viable replacements for these really old packages will be a necessary requirement of building an ES package. If ESM achieves ubiquitous adoption, it will likely obsolete a not-insignificant chunk of the package ecosystem.
This should go without saying but this write up is nothing more than a snapshot of 'what is possible' considering the current state of standards and ES module support in both Node and browsers.
What it is not is a qualitative judgement on any debates/decisions made by this group. I'm here strictly as an 'observer'. Opinions and observations stated here are just that, opinions and observations.