JavaScript package development for the ES modules era

Bye bye Common.js, open source, snowdev

Lots of tools out there are focusing on building medium to large scale applications (Webpack, Parcel) or require some configuration to get started (Rollup…). Now that we have a widespread support for ES Modules, the tools for editing packages should also evolve to cater for prototyping, small experiments and package development.


ES modules, finally

April 2021, it looks like Node.js is informally moving on from Common.js with Node.js 10 reaching its end-of-life status: support for ES Modules is now a thing in Node.js. 🎉

So what’s the big change? Mainly saying so long module.exports: by specifying "type": "module" in your package.json (or passing --input-type in node’s CLI), you are now able to use the JavaScript official and standard format to write packages targeted at both Node.js and the browser.

Using import and export statements is something that you could already do in most browsers (and that for a while, see the caniuse table) but only partially in Node.js, most notably with the help of the esm package.

If you are nostalgic or have some other constraints, you can still provide a "type": "commonjs" and get hybrid support; I’d recommend following the de facto standard and move on to ESM though. If you are still interested, follow the links:

Does that mean we can start writing packages in ESM and not care about the rest? Almost! You just have to be aware of the difference between writing for Node.js or for the browser. See my previous post on The bare import problem and its solution, the import-maps (previously called Package name maps). Also this doesn’t eliminate the need for bundling to provide the smallest code possible and support older browsers.

That being said, it is already a big win for educational purpose and when you want to prototype or write your own packages. Since everything is standard JavaScript, bundler can pick up the work later if you need to transpile for different targets than your current development environment.

Writing open source packages

I have been writing small JavaScript packages from some time and published them on my GitHub. Many hours of my free time went into this, but it is also an investment:


If you feel extra grateful, I have setup PayPal and Coinbase donation links where you can say thanks.


Most of these packages consists of a single file: I really don’t want to spend time configuring things over and over, but automate as much as I can my workflow. The goal is to avoid a cumbersome development process going forward. What I need is simple:

But the DX (Developer experience) is not everything, I also want to make the package easy to use (for me and everyone) by:

I followed pretty much all of the above in my packages but not without hiccups: the tools exists but I needed them all in a central place. Why reinvent the wheel when you can just aggregate the good stuff?

snowdev: zero configuration, unbundled, opinionated, development and prototyping server for simple ES modules development

Let’s break it down.

zero configuration

snowdev in essence is just another CLI tool you install via npm: npm i -g snowdev. It offers 5 commands that should aid anyone looking for an easy solution to develop small ES modules packages:

Some fine tuning is allowed via cosmiconfig which will look for snowdev in package.json or a snowdev.config.js file. Other options for cosmiconfig looking up are highlighted in their documentation.

unbundled

The biggest problem so far was developing ES Modules while being forced to bundle to UMD/IIFE for your codebase to run in browsers unless you put everything (your code and its dependencies) in <scrip src=""></script> tags. We can now leave this behind thanks to the import-map proposal and its ES module shim polyfill.

The concept of unbundled has been popularized by Snowpack: it simply means compiling your dependencies upfront, once, and caching it. If a file changes, only that file will be reloaded. They slightly departed from the simplicity of the initial concept and now seem to target larger scale application with a myriad of plugins and doing auto magic resolution of import statements (this is not something I am looking for). In snowdev though, I am using their great package es-install to convert the packages node_modules into a 100% ESM compatible web_modules directory and generate an import-map.

development and prototyping server

That workflow of compiling to ESM and creating an import-map is crucial. I can now go back to a more straightforward workflow of a single index.html file from where I import all the code in a standard way with <script type="module"></script>. Which is what browsers understand!

The convenience of a popular devserver is undeniable: snowdev is relying on the very versatile Browsersync. Any CLI options will be passed to it so to start an https server on port 1234 you would go: npx snowdev dev --https --port 1234.

opinionated

Configuration is great and all but sometimes opinions will unblock tricky situations: Prettier is for me one of the best thing that happen to the JavaScript ecosystem in the past 5 years. Less time wasted in code review, no extra burden over trivial matters. I am pretty satisfied with the default of version 2.0 so I use them and npx snowdev build will run it automatically on the source files (unless disabled by --no-format).

Another mandatory tool on the belt is ESLint: here again, the default eslint:recommended are pretty solid for package development in conjunction with plugin:prettier/recommended. On top of that, the new overrides functionality makes it east to adjust for TypeScript:

{
	"parser": "@babel/eslint-parser",
	"extends": ["eslint:recommended", "plugin:prettier/recommended"],
	"plugins": ["eslint-plugin-prettier"],
	"rules": {
		"prettier/prettier": "error"
	},
	"parserOptions": {
		"ecmaVersion": 2021,
		"sourceType": "module",
		"ecmaFeatures": {
			"experimentalObjectRestSpread": false
		},
		"requireConfigFile": false,
		"babelOptions": {}
	},
	"env": {
		"browser": true
	},
	"overrides": [
		{
			"files": ["**/*.ts"],
			"parser": "@typescript-eslint/parser",
			"extends": [
				"eslint:recommended",
				"plugin:@typescript-eslint/recommended",
				"plugin:prettier/recommended"
			],
			"plugins": ["@typescript-eslint"]
		}
	]
}

The dev server will log any issue in the browser’s console (also works for TypeScript compilation error when running npx snowdev dev --ts):

Documenting

Regarding documentation, snowdev leverages JSDoc comments to do two things:

Having TypeScript types generated, regardless if a package was redacted using TypeScript or just JavaScript, is great for auto-complete in editors such as VSCode and general understanding of a package’s API.

Typical package development workflow

# Create folder
mkdir ~/Projects/package-name
cd ~/Projects/package-name

# Generate folder structure (entry: index.js)
npx snowdev init

# Start a dev server and compile dependencies to ESM in web_modules
npx snowdev dev

# Write code and commit all changes
git add -A && git commit -m "feat: add feature"

# Build package:
# - lint and format sources
# - generate documentation and insert it directly in README
# - generate TypeScript types from either JSDoc
npx snowdev build

# or directly prepare a release (build then run standard-version committing all artefacts eg. docs)
npx snowdev release

# and push/publish it
git push --follow-tags origin main && npm publish

For projects using TypeScript, adding --ts options will slightly change the behaviour:

# Use a TypeScript structure (entry: src/index.ts)
npx snowdev init --ts

# Watch ts files without dev server
npx snowdev dev --ts --no-serve

# Generate documentation in docs folder and compiling ts files and types using tsconfig.json
npx snowdev build --ts

# or directly prepare a release (build then run standard-version committing all artefacts eg. docs)
npx snowdev release --ts

For more options, check the sources on GitHub or run npx snowdev --help.

Parting words

Inspired and feeling validated by Sindre Sorhus similar plan, snowdev will definitely help me transition my packages from Common.js to ES modules. There is room for improvement, such as setting up some default way of testing or a deploy command but that’s a start. I understand it might not work for everyone, I thought I’d share anyway.