#utility #cli #nodejs #javascript

app rn

Command line tool for managing per-session nodejs versions based on package.json files

2 releases

Uses old Rust 2015

0.2.2 Jul 23, 2017
0.2.1 Jul 22, 2017

#35 in #nodejs

MIT license

736 lines

rn - Rust Node manager

build status coverage report

Nursing your node environment to good health!

rn is yet another version manager for Node.js, a bit like rvm is a Ruby version manager. It's primary goals are ease of use and fast execution for use in a local development environment on MacOS or Linux.


RN includes a bash script to install itself on MacOS and Linux and perform simple diagnostics on your shell:

curl -sSL https://gitlab.com/bff/rn/raw/master/install.sh | bash

Check that it works (either platform):


Compiling from Source

curl https://sh.rustup.rs -sSf | sh
git clone git@gitlab.com:bff/rn.git
cd rn
cargo build --release

sudo cp target/release/rn /usr/local/bin/rn
cp bin/rn.sh .config/rn/
echo 'source $HOME/.config/rn/rn.sh' >> .bashrc


Set node version in shell

  "engines": {
    "node": "0.10.37"
  1. Fully specify your node version in package.json using an exact triple as shown above.
  2. CD into the directory with the package.json
  3. There is no step 3. Node will just work as that version in your shell until CD into another node project.

List versions

$ rn --list


envvar RN_DIR

Defaults to $HOME/.rn; this is where rn will store node versions, and potentially metadata in the future. Node binaries are stored in $RN_DIR/versions.

For example, if you had node 5.7.1 locally, and RN_DIR was unset, you could find the local copy of node 5.7.1 at /home/bryce/.rn/versions/v5.7.1.

Features Comparison

I think the easiest way to understand rn is to compare it to the other Node.js version managers in the space.

Project rn nvm nodenv[^1] n
Design Goal local dev local dev prod & dev system node
Feature Set tiny large enormous small
Implemented in Rust Bash/Zsh All Shells Bash
Full Semver Support never yes yes yes
Per-shell Versions yes yes yes no
gLobal Version soon yes yes yes
Sourced? partially yes partially no
Package.json Parsing yes no no no
Manages npm soon no no no
Hooks cd yes no no no

Design Decisions

Why Rust?

  • I'm passionate about Rust
  • Even more than C or Go, if it compiles it almost definitely JustWorks (tm)
  • As a compiled language, Rust is cheap to into memory on every invocation making it faster for shell init than a large Bash script.
  • Although Bash is great for small script and single file utilities, it's not necessarily an ideal way to structure a large project that you want to maintain
  • Rust (and other compiled languages) are less prone to side-effecty problems prone to crop up in shell scripts
  • Rust offers great intuition into memory management
  • Rust has a fantastic for package management (compared to Go or Bash, for example)
  • Rust has excellent FFI support for calling into C code (making it less portable) which I've leverage quite a bit through dependencies
  • Rust offers opportunities to explore parallelizing network requests

The biggest downsides to working with Rust are that it isn't as portable as Go or Bash. However, I think there are enough good technical reasons to justify using Rust inspite of the portability.

Why Not Full Semver?

My main goal was make life better for developers in their local shell. If the package.json specifies only the major version of Node.js, then it requires an additional network and more parsing to find acceptable verion to download and install.

Additionally, I've worked on teams that found breaking changes happening on minor versions (semver not followed) or serious bugs emerge from changing even the patch version. I also happen to believe that having a high fidelity between one's development environment and production environment is a great way to avoid nasty surprises. By locking in the exact patch version, I hope I'm saving you from some serious issues.

Lastly, if those (hopefully) sound technical reasons don't sway you, I'll admit that I didn't want to go implement a full semver logic and I wanted to execute more quickly on this project.

For all these reasons, I have no plans to implement full semver in the package.json parsing.

Why Per-shell Versions of Node.js?

It's often handy to run multiple Node.js applications simultaneous (for microservices, etc). By configuring each shell's PATH separately, I can ensure that each terminal session has exactly the version of node it needs. Think of it as a poor man's Docker.

Why Is Sourcing a Large Bash File Bad?

On 2015 MacBook Pro's, I've seen sourcing thousands of lines of Bash add seconds to the init time required to open a new shell. Imagine if every time you opened a new tab in Firefox, you had wait 2+ seconds for the location bar to become responsive. You'd switch to Chrome in a heartbeat.

Sourcing large Bash files is a great way to avoid re-loading them every time you want to use their functionality. However, by leveraging Rust (or any compiled language for that matter), it's very cheap to load machine code into memory for each execution. This also allows me to skip the expensive upfront time spent sourcing a large Bash file.

Why Override cd?

Because rvm does it?! One problem with rvm's implementation is that it has a large feature set mostly built in Bash that's easy to screw up. By wrapping cd in a small, fast, and narrowed scoped Bash function, I can easily have the native Rust code do all the logic and heavy lifting of downloading node versions and updating the PATH with less opprotunity for things to go badly. However, to udpate your current shell and not have my changes to the shell disappear in a subshell, Bash functions are an obvious choice. Honestly, I can't think of another way to do it that's less terrible. If you know of one, open an issue and tell me!

[^1]: I haven't use nodenv personally, so don't 100% trust my description of nodenv, gleaned from the documentation.

How It Works

rn.sh replaces the builtin cd utility with a Bash function. This function checks for the existence of a package.json file. If a package.json file is found, rn is invoked with the full path to that file.

Internally, rn finds the previously downloaded versions of node (if any), and determines whether or not to download a tarball of the desired version of Node.js from nodejs.org. rn streams the response data through a decompression algorithm in memory and unpacks the decompressed archive into RN_DIR.

Finally, whether or not a download occurred, rn will attempt to strip out any previously set node versions from the path and add the new node version to the front of the path. It prints out the final desired PATH over STDOUT and exits 0.

rn.sh receives STDOUT and replaces PATH in the current shell. You're ready to run your project!

If anything goes wrong, rn will print out an error to STDERR and exit 1; the PATH will not be updated. Helpful error messages have already been implemented for invalid package.json files and unusable node versions, among other scenarios. Additionally contextual error messages will be implemented as time permits.


~134K SLoC