Support Node 6 installs

How to bundle and transpile your NPM module to run on older Node versions.

What is the minimum version of Node your NPM module requires? You might think it is Node 8 or Node 10 or even Node 6. But in reality you don't know - because the direct or transient NPM dependencies your project uses might require a different higher version, without even declaring it explicitly in the engines field of their package.json files.

Recently, we have experienced sudden "bumps" in minimum Node version required for our Cypress NPM package because of chalk and execa dependencies. While we promised supporting Node v8.0.0, due to these dependencies our true minimum Node version turned out to be v8.12.0!

The only reliable way to determine if your project runs on Node 8.0.0 is to run your NPM package on Node 8.0.0. In this blog post I will show how to bundle and transpile your NPM package so it truly runs on Node v8.0.0 or even on Node v6.

Note: you can find the source code at bahmutov/support-node-v6

The problem

Let's start a new NPM application that uses chalk and execa to print the list of files. We want to use the latest versions of all the tools, so we are working on Node v12

1
2
3
4
5
6
7
8
~/git/support-node-v6 on master
$ nvm use 12
Now using node v12.13.0 (npm v6.13.7)
~/git/support-node-v6 on master
$ npm i -S chalk execa
npm notice created a lockfile as package-lock.json. You should commit this file.
+ [email protected]
+ [email protected]

Here is our application file index.js

1
2
3
4
5
const chalk = require('chalk')
console.log(`Chalk is ${chalk.red('working')} if you saw red`)

const execa = require('execa')
execa('ls', ['-la']).then(r => r.stdout).then(console.log)

The app runs and the colors show up (on Node v12)

chalk and execa work on Node 12

Now let's try the same application on Node v6.

chalk uses spread operator

Because chalk uses spread operator, it does not run on Node v6 - and you would need to downgrade to [email protected]. But then the same happens with execa - it also uses the spread operator!

execa uses spread operator

You would need to downgrade execa all the way to v1 to get the syntax compatible with Node v6. In the process you just lost soooo many features and fixes from execa and chalk, it is almost sad.

execa v1 and chalk v2 work on Node 6

Bundling

Let's switch tactics. Instead of searching for an old version of chalk and execa, let's install the latest versions - and let's bundle them into a single JavaScript file. Typically, bundling is done for browsers, but we will use @zeit/ncc to bundle for Node.

1
2
3
4
5
$ npm i -S chalk execa
+ [email protected]
+ [email protected]
$ npm i -D @zeit/ncc
+ @zeit/[email protected]

We can use ncc to produce a single JavaScript file from index.js entry using npm run build script

package.json
1
2
3
4
5
{
"scripts": {
"build": "ncc build index.js"
}
}
1
2
3
4
5
6
7
8
9
$ npm run build

> [email protected] build /Users/gleb/git/support-node-v6
> ncc build index.js

ncc: Version 0.21.1
ncc: Compiling file index.js
106kB dist/index.js
106kB [920ms] - ncc 0.21.1

The bundle we produced does not run on Node 6 yet.

bundle does not work on Node 6 yet

But the bundle has all dependencies included. We can remove node_modules folder and run it to prove this.

bundle works without node_modules folder

Now we can transpile this single file to make it work on (almost) any Node version.

Transpiling down

I like using TypeScript compiler to transpile code.

package.json
1
2
3
4
5
6
7
8
9
{
"scripts": {
"transpile": "tsc --allowJs --target ES5 dist/index.js --outFile dist/out.js"
},
"devDependencies": {
"@types/node": "13.7.6",
"typescript": "3.8.2"
}
}

This almost works.

transpile almost works

The bundle is transpiled - the spread operators in chalk and execa have been replaced, but the bundle still has Object.entries method that Node v6 does not understand. We can polyfill this method though.

1
2
$ npm i -S babel-polyfill
+ [email protected]

Then require the polyfill from index.js file

1
require('babel-polyfill')

Let's build and transpile again.

works on Node 6

Nice, the latest dependencies do work on Node v6!

Tips

Run scripts

Since we only plan to distribute a single bundle, we can generate the intermediate bundle in the build folder, and run the transpile step post-build.

package.json
1
2
3
4
5
6
7
8
9
{
"scripts": {
"build": "ncc build index.js --out build",
"postbuild": "npm run transpile",
"transpile": "tsc --allowJs --target ES5 build/index.js --outFile dist/index.js"
},
"main": "dist",
"files": ["dist"]
}

Specify bundled dependencies

We can see the produced bundle using npm pack --dry command.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ npm run build
...
$ npm pack --dry
npm notice
npm notice 📦 [email protected]
npm notice === Tarball Contents ===
npm notice 598.9kB dist/index.js
npm notice 895B package.json
npm notice === Tarball Details ===
npm notice name: support-node-v6
npm notice version: 1.0.0
npm notice filename: support-node-v6-1.0.0.tgz
npm notice package size: 114.0 kB
npm notice unpacked size: 599.8 kB
npm notice shasum: f5c816747afe915e9e6cb5ad483050dbe60df662
npm notice integrity: sha512-pRaaeDthI0+7C[...]XYiVKrziJa60w==
npm notice total files: 2
npm notice
support-node-v6-1.0.0.tgz

The zipped archive has size 114 kB, which looks like a lot. But remember, that anyone installing this NPM app should only download this single file, which should be as fast as downloading multiple production dependencies. But we still list execa and chalk as production dependencies, thus our users will get two copies of them - the second one coming from the dist/index.js bundle.

Hmm, NPM understands bundled dependencies list, but this list forces chalk and execa into the TGZ archive twice!

package.json
1
2
3
4
5
6
7
8
9
10
{
"main": "dist",
"scripts": {
"build": "ncc build index.js --out build",
"postbuild": "npm run transpile",
"transpile": "tsc --allowJs --target ES5 build/index.js --outFile dist/index.js"
},
"files": ["dist"],
"bundledDependencies": ["babel-polyfill", "chalk", "execa"]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
$ npm pack --dry
npm notice
npm notice 📦 [email protected]
npm notice === Tarball Contents ===
npm notice 598.9kB dist/index.js
npm notice 958B package.json
npm notice === Bundled Dependencies ===
npm notice babel-polyfill
npm notice chalk
npm notice execa
npm notice === Tarball Details ===
npm notice name: support-node-v6
npm notice version: 1.0.0
npm notice filename: support-node-v6-1.0.0.tgz
npm notice package size: 930.7 kB
npm notice unpacked size: 3.8 MB
npm notice shasum: aec06c58bc556e0050e54f690ed936978a45341f
npm notice integrity: sha512-+KVCnHxym0jYp[...]moDzoUVuxD6kQ==
npm notice bundled deps: 3
npm notice bundled files: 1910
npm notice own files: 2
npm notice total files: 1912
npm notice
support-node-v6-1.0.0.tgz

Thus my advice is to move all production dependencies into devDependencies - they will be included in the bundle as needed and skip using bundledDependencies.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"name": "support-node-v6",
"main": "dist",
"scripts": {
"build": "ncc build index.js --out build",
"postbuild": "npm run transpile",
"transpile": "tsc --allowJs --target ES5 build/index.js --outFile dist/index.js"
},
"files": ["dist"],
"dependencies": {},
"devDependencies": {
"babel-polyfill": "6.26.0",
"chalk": "3.0.0",
"execa": "4.0.0",
"@types/node": "13.7.7",
"@zeit/ncc": "0.21.1",
"typescript": "3.8.3"
}
}

Let's make sure the bundled dependencies work. I have created a new NPM package in a different folder and will install and run the above project using Node v6.

installing and running the bundled app on Node 6

Perfect.

Source maps

While @zeit/ncc and typescript can both generate source maps, I could not find a way to connect the two to get the source maps to link an error back to the original source file. If you know how to do this, open a pull request in bahmutov/support-node-v6, please.

Unsupported features

Some ES6+ syntax and features cannot be transpiled down, for example if your project requires WeakMaps or Proxies - you are out of luck.

alternative: use Parcel

Instead of @zeit/ncc we can use Parcel bundler to bundle code like this:

package.json
1
2
3
4
5
6
7
8
{
"scripts": {
"build": "parcel build index.js --target node --bundle-node-modules --no-minify"
},
"devDependencies": {
"parcel-bundler": "1.12.4"
}
}

See also