Making Development Environments Consistent with Hall & Oates (& JavaScript)
A common theme in software development is “it works on my machine”.
It works on my machine https://t.co/0PepWfzXW2
— Marcel Pociot 🧪 (@marcelpociot) April 24, 2018
Having inconsistencies between each developer’s environment and between development and production application environments can lead to a ton of unexpected behaviors and bugs. JavaScript is a core part of our tech stack and processes at Niche. Outside of our CSS and its different flavors — most of which originates from our Design team — the Front End and Quality Assurance teams predominantly write JavaScript to build and maintain products that serve millions.
Shake it up is all that we know
There are two dependencies in the modern JavaScript ecosystem that need to be used at the global environment level: the server-side runtime Node.js, and a JavaScript package manager (we use npm).
Since these dependencies are handled at the global level, versioning these dependencies across our teams provided more challenges than versioning our local dependencies. Every month, someone would hop onto Slack and ask “What version of Node/npm are we running?”
We made some efforts to document the versions of Node and npm each project depended on in internal wikis, but that requires developers to know about the existence and location of that documentation. Additionally, those docs quickly became outdated if we didn’t manually keep it in sync with our environments.
Ideally, the required versions of Node and npm for a given project would be specified within that project. In doing so, we could:
- Keep the Node and npm version location consistent across projects.
- Be able to version control those specified versions along with the relevant code.
- Keep those versions in a simple format for any tools or processes that require them.
On that last point, we would save ourselves a lot of headaches by having a tool that enforces that developers, QA analysts, and any other team member running the application are using those specified versions.
What my head overlooks, the senses will show to my heart
The first step to solving this problem was first seeing what solutions may already exist. JavaScript developers are often derided for relying on too many dependencies, reaching for npm install
before understanding the problem.
Legendary Apollo project programmer Margaret Hamilton, next to a printout of the node_modules directory listing for her first Hello World react app pic.twitter.com/tOsBifPtmI
— Thomas “🐈🔭🕹” Fuchs (@thomasfuchs) March 7, 2019
I agree that the less you depend on third-party resources, the more secure and maintainable your code will be. However, there’s definitely merit in leveraging the Node open-source ecosystem in order to move faster and avoid working on solved problems. So it was worth asking the question: Has someone solved it?
Nothing is new, I’ve seen her here before
When exploring different options, I decided to look at what tools were already a part of our process. Much of our team was already using nvm, the “Node Version Manager”, to install multiple versions of Node and switch between them easily.
I discovered that the docs for nvm include a section on an .nvmrc dotfile that developers can set up on a per-project basis. In this file, you can specify which version of Node the project uses. When running an nvm command such as nvm use or nvm install without providing an argument, nvm will use the version specified in .nvmrc.
This gave us a lot of what we were looking for!
- An easy way for anyone working in a particular codebase to locate those specified versions.
- The ability to version control those specified versions along with the relevant code.
- A versatile format for those specified versions so that they could be read by various tools.
However, there were some gaps in what an .nvmrc file could provide us with and the extent of our needs.
nvm doesn’t prevent someone from running a different version of Node from the version specified in the .nvmrc file. The file acts as a guideline, and a default if no version is specified, but it was still easy for someone who is hopping between projects to not switch their Node version before working on said project.
While many people on the team use nvm, we didn’t have full team adoption. Requiring everyone working in the codebase to use it would mean that we have yet another global dependency (and thus another opportunity for development environments to get out of sync).
Even if everyone was on board with nvm and wanted to use it, we’re a cross-platform team, and nvm does not support Windows. While we’ve tried various Windows-compatible version managers, each has had their own hiccups when attempting to add them into our workflow.
Finally, the most glaring issue with relying solely on an .nvmrc file is that it leaves out half of the equation: npm. The required format for the .nvmrc file is very specific:
The contents of a .nvmrc file must be the <version> (as described by nvm --help
) followed by a newline. No trailing spaces are allowed, and the trailing newline is required.
Were it to allow arbitrary content on subsequent lines, we might be able to get away with adding the required npm version there.
Times that are broken can often be one again
With .nvmrc not solving the issue, it was back to scouring the internet and library docs.
After some reading, another possible option stood out. The package.json file that is present in practically every Node project that uses packages from npm has an optional engines
field.
According to the docs, this property can be used to specify the versions of Node and npm (among other things). It would look like:
“engines”: {
“node”: “10.15.3”,
“npm”: “6.9.0”
}
Once again, we had something promising here.
- An easy way for anyone working in a particular codebase to locate those specified versions.
- The ability to version control those specified versions along with the relevant code.
- A versatile format for those specified versions so that they could be read by various tools.
This also had two big advantages over using an .nvmrc as well.
- You can document and version control the npm version.
- You can use an existing, centralized file instead of creating more bloat in your project’s root directory.
But unfortunately, there were still a few gotchas with using the engines
field exclusively.
The npm docs specify that engines
is merely advisory, so while there may be warnings to inform you if you’re running the incorrect versions of Node and npm, the docs do not guarantee that it will actually stopping you from doing so.
While enforcing versions might not be built into how npm behaves, I wondered if it was a feature provided by nvm. From the looks of it, it’s been heavily considered, and it’s actively being worked on, but isn’t fully worked out yet by the nvm maintainers.
You make my dreams come true
Given the considerations of different tooling out there, I settled on a home-grown solution to meet our needs.
If engines
is Daryl Hall and .nvmrc is John Oates, then our solution, node-can-do, is their fateful meeting in West Philadelphia’s Adelphi Ballroom in 1967.
node-can-do is a script that prevents developers from getting too far when using the incorrect versions of Node and npm. We use it in conjunction with npm scripts in package.json, specifically the pre
script hook that npm provides to run scripts before other scripts.
For example, let’s imagine we have a Node app with an important script, and we want to ensure we’re using the correct versions of Node and npm before running that script. In the app’s package.json, I would add the following scripts:
“scripts”: {
“important-script”: “echo ‘Hello world!’”,
“preimportant-script”: “node-can-do”
}
Running npm run important-script
will first run npm run preimportant-script
. At this point, node-can-do will perform multiple checks to ensure that environments are the same across developer and QA machines.
First, node-can-do checks to make sure that the required version of npm is specified in package.json, and that the required version of Node is specified in either package.json or an .nvmrc file.
Next, node-can-do checks if the running version of Node and the globally installed version of npm are the same as the versions specified in engines
.
If the first or second check fails, then an error and instructions to remedy it are logged and the script exits with exit code 1, preventing npm run important-script
from being run.
If those checks pass, then the script exits with exit code 0 and then runs npm run important-script
.
This allows us to put this script in front of commonly used npm scripts, and if node-can-do fails, then the developer or QA analyst can’t continue without correcting their Node/npm version.
You can get along if you try to be strong
We started out with node-can-do being copy and pasted into every repo that we needed it, which works completely fine since we don’t need to update it regularly. But in an effort to reduce duplication, and also to allow us to share it more freely with others, we’ve moved the code to a new repo as a standalone package, open sourced it on GitHub, and published it to the npm registry!
If you would like to use it in your Node project, feel free to install it with
npm install --save-dev node-can-do
And if you notice any bugs or problems with it, feel free to file an issue. 🙂
I ain’t the way you found me
Despite the fix for this inconsistency issue being a simple script, it’s helped us be more confident in our environment. Developing in our core projects is now more consistent, and rarely-if-ever are two developers running the most updated version of a project while using different versions of Node or npm.
In the time since writing node-can-do, we’ve further expanded how we’re making our project environments more consistent, not only in development, but in production as well. Principal software engineer Nathan Cochran has pushed for implementing Docker at Niche. This has reduced the development friction and inconsistency across machines in a holistic way that’s agnostic of the language that various projects are written in, while node-can-do is only applicable to our Node.js applications. We hope to talk about our experience with Docker in a future technical blog article.
On a personal level, I also learned a lot in developing a solution for this. node-can-do, in its simplicity, helped us solve a challenge we were running into time and time again. I learned a lot about the Node.js API and npm packages.
It was also a lesson in avoiding overengineering. As you can see for yourself, node-can-do has none of the modern tooling of TypeScript’s static type checking, Babel’s transpiling and minification, ESLint’s code quality assurance, or even 😱 unit tests 😱. All of those are great things, but sometimes it’s better to hack something together, make sure it solves the problem, and then — if there’s enough of a need — iterate on it.
Finally, node-can-do was just fun to write. Having an excuse to combine my love of puns, Hall & Oates, and JavaScript took some of the dryness out of an otherwise tedious issue. Finding opportunities to let developers, analysts, and technical folks take initiative on problems and do weird things to solve them is one part of a recipe for creating a good work environment where people are excited by challenges.