Cross-platform Node.js

An on-going guide to help you write better cross-platform Node.js.

Since most Node.js users are running OS X or Linux, “cross-platform” means “works with Windows too.” Fortunately, almost everything does: Node.js and the whole ecosystem are super Windows friendly. Unlike other, even-more-homogenous ecosystems like Ruby’s, in the three years I’ve been playing with Node.js I’ve only had trouble running Windows a handful of times. So here are some problems you might hit and solutions for dealing with them.

Paths

Avoid 99% of path-related issues by following two rules:

  1. Do not use paths and URLs interchangeably1
  1. When modifying, creating, or parsing a path, use Node’s built-in path APIs instead of string manipulation.

Domenic Denicola has some good advice here.

Hard-coding executable paths

It’s fine to hard-code relative paths to an executable if you’re careful and you:

  1. Don’t hard-code more than you need to. The ./node_modules/.bin folder is automatically added to your shell’s path when running an npm script, so use:
1{
2 "scripts": {
3 "test": "mocha"
4 }
5}
1{
2 "scripts": {
3 "test": "mocha"
4 }
5}

instead of

1{
2 "scripts": {
3 "test": "./node_modules/.bin/mocha"
4 }
5}
1{
2 "scripts": {
3 "test": "./node_modules/.bin/mocha"
4 }
5}
  1. Make sure the path is something any shell can understand. If your path is to a bash shell script it obviously won’t work on Windows. This is why node modules have a “.cmd” version of executables side-by-side with a bash script. Each shell will do the right thing. .cmd version of executables, side-by-side with bash version
  2. Don’t leak the hard-coded path as data. If the path will ever be passed to another module, returned to the user, etc, then use the Node.js path APIs to build it instead so that the path conforms to what a path should look like on that platform.
  3. And if you’re a Windows dev, use forward slashes. They’ll work everywhere while backslashes won’t.

upath as a path replacement

The upath package is a drop-in replacement for path that makes all paths returned by the API have forward slashes instead of backslashes (among other things). I wouldn’t recommend using this library regularly but I’ve used it to quickly fix some Metalsmith plugins that were treating paths as URLs. Switching to this library was an extremely small and easy one-line fix I could make to these plugins that fixed all the downstream code.

Scripts in package.json

I don’t want to deal with the complexity of grunt and gulp when I’m only performing simple tasks, so using the package.json “scripts” section as a simple task runner is appealing2. For example, the package.json3 for this site is:

1{
2 "scripts": {
3 "start": "npm run watch",
4 "watch": "node ./bin/watch.js",
5 "build:staging": "cross-env NODE_ENV=development node ./bin/build.js",
6 "build:staging:debug": "cross-env DEBUG=cdnify npm run build:staging",
7 "build:production": "cross-env NODE_ENV=production node ./bin/build.js",
8 "build": "npm-run-all build:*"
9 }
10}
1{
2 "scripts": {
3 "start": "npm run watch",
4 "watch": "node ./bin/watch.js",
5 "build:staging": "cross-env NODE_ENV=development node ./bin/build.js",
6 "build:staging:debug": "cross-env DEBUG=cdnify npm run build:staging",
7 "build:production": "cross-env NODE_ENV=production node ./bin/build.js",
8 "build": "npm-run-all build:*"
9 }
10}

Task runners abstract away platform incompatibilities from you while npm scripts will run your scripts as shell commands, verbatim4. You can avoid any issues by writing as little shell code as possible and deferring to Node.js scripts instead. Some common pitfalls and ways to avoid them:

Running multiple scripts

Most suggest using && to run multiple commands which works in Bash and cmd.exe5. Good advice, but I prefer npm-run-all. It abstracts shell syntax, gives you the ability to run wildcards, and by default will bail out when a command fails. It’s as simple as this:

  1. npm i npm-run-all --save-dev
  2. "scripts": {"build": "npm-run-all build test"}

Shell scripts that rely on Unix shebangs

cmd.exe/PowerShell don’t understand shebangs. Call scripts using node.exe explicitly:

1{
2 "scripts": {
3 "watch": "node ./bin/watch.js"
4 }
5}
1{
2 "scripts": {
3 "watch": "node ./bin/watch.js"
4 }
5}

instead of

1{
2 "scripts": {
3 "watch": "./bin/watch.js"
4 }
5}
1{
2 "scripts": {
3 "watch": "./bin/watch.js"
4 }
5}

Setting one-off environment variables

In bash you can run a command with an environment variable set without changing the environment variable in the shell:

1NODE_ENV=production webpack
1NODE_ENV=production webpack

The closest you can get with cmd.exe is:

1set NODE_ENV=production; webpack
1set NODE_ENV=production; webpack

But now your shell has NODE_ENV=production set for all future scripts you run. This is easily fixed by using cross-env, e.g.:

1{
2 "scripts": {
3 "build": "cross-env NODE_ENV=production webpack"
4 }
5}
1{
2 "scripts": {
3 "build": "cross-env NODE_ENV=production webpack"
4 }
5}

This runs webpack with the production NODE_ENV and doesn’t dirty your shell session.

Line-endings in test fixtures

The default option in Git for Windows is to set the git config option core.autocrlf to true.

Git for Windows line endings settings

I’ve seen this be an issue multiple times with test fixtures. The output of some module will only contain line-feed characters and it is compared to the contents of a file on disk. If that file was created by git on a Windows machine, it’ll have crlf newlines instead of just lf and the test will fail, e.g.:

1assert("Hello world!\r\n" === "Hello world!\n");
1assert("Hello world!\r\n" === "Hello world!\n");

The fix is easy: add a .gitattributes file to your git repository with something like this:

1test/fixtures/** text eol=lf
1test/fixtures/** text eol=lf

This will make the line endings for all files in the test/fixtures directory (and subdirectories) be line-feed characters regardless of platform. I don’t recommend turning this on for your whole repository, or worse, disabling git’s core.autocrlf option, which would both cause more headaches than they would avoid.

GitHub has some good instructions on how to refresh your already-cloned repository to reflect the changes to the .gitattributes if you’re applying this fix after-the-fact.

Footnotes

  1. This site is built using the static site generator Metalsmith, which is really fantastic in its simplicity. Unfortunately, a lot of the plugins do things like assume that you can use a relative file path, e.g. “subdirectory/file.txt,” as an anchor’s href. Except that on Windows you’re now generating URLs with backslashes in them. Gross.

  2. See “Running scripts with npm” and “How to Use npm as a Build Tool”

  3. Don’t ask why I have a staging environment for such a simple site. I guess I was having fun experimenting with Azure’s deployment slots.

  4. “Commands are passed verbatim to either cmd or sh.” —isaacs

  5. npm will use cmd.exe to run the script, even if you’re in a PowerShell prompt, which is probably a good idea.