I’ve recently been upgrading some of my NPM packages from Node.js v12 to Node.js v18 and one part of this process I’ve enjoyed has been stripping away their dependencies and replacing functionality with features now provided by Node.
One common criticism of JavaScript is that it lacks much of a standard library, in languages like Go, Ruby, Python, and PHP there are a huge number of functions, extensions and data structures all built in which can be used to construct quite complex applications without installing additional tools and dependencies.
Node has always shipped with enough features needed to create basic web services but until recently it’s libraries have been fairly bare bones; few services are created using http.Agent
and http.request
directly but instead use higher-level and more user-friendly tools like Express and Axios. The problem with this is that maintaining ever growing trees of dependencies takes effort and can create a huge potential attack surface area, so reducing the number of dependencies required by projects is good for their long term health.
In recent years Node has started to include functionality for the most repeated use cases and some useful features from the Web platform have started appearing too. Deno - another JavaScript runtime from Node’s creator Ryan Dahl - has shown that universal problems like making HTTP requests, parsing arguments, and running tests shouldn’t have to mean exploring the complex and fragmented JavaScript ecosystem for solutions.
So here are a few features that are new(ish) to Node that have enabled me to shed the dependencies from my projects.
Parsing arguments
Command line programs can usually be configured by passing a string of flags and options to them. For Node programs turning those sequences of dashes and spaces into something usable has usually meant installing one of the 300+ argument parsers available on NPM. In my projects I’ve usually turned to the minimist
package between this and the other most popular argument parsers these tools rack up more than 450 million downloads each week!
$ node program.js -x 1 -y 2 --abc --beep=boop
In June 2022 Node v18.3 was released with a new function included in it’s standard library to parse command line arguments: util.parseArgs
. This function is more fully featured than I had expected - it supports aliases, booleans, multiple and default values, and has useful defaults just like the packages we’ve all installed a thousands of times do.
const { parseArgs } = require('node:util')
const { values } = util.parseArgs({
options: {
x: {
type: 'string',
},
y: {
type: 'string',
},
abc: {
type: 'boolean',
},
beep: {
type: 'string',
},
},
})
console.log(values) // { x: '1', y: '2', abc: true, beep: 'boop' }
So far I’ve found that the util.parseArgs
function has been a straightforward swap for external dependencies across my codebases and its been capable of everything I’ve needed it to do 🥳
HTTP requests
Most of the services and scripts I’ve worked on have to perform HTTP requests to fetch or send data at some point. Node has always included a http.request
function able to do this but because it’s so basic (and callback based) most of the time I’ve installed a more fully featured solution from NPM instead. The most popular packages for making requests currently generate up to 140 million downloads each week.
I’ve mostly used the most downloaded package - node-fetch
- in my work because it implements the (almost) same Fetch API that has been available in browsers since 2015. Using this API in Node means that my knowledge and code can be shared between the server and browser so when Node v18 released with global.fetch
enabled I was keen to update my code to use it.
// const fetch = require('node-fetch')
async function getRegistryData(packageName) {
const response = await fetch(`https://registry.npmjs.com/${packageName}`)
if (response.ok) {
return response.json()
} else {
throw Error(`The npm registry responded with a ${response.status}`)
}
}
In my code the switch to global.fetch
has mostly been as easy as removing the node-fetch
dependency but because the new function has been built on a completely new HTTP client called Undici rather than the existing http
module, this means that features which hook into or intercept requests at that layer no longer work with my updated code.
Some of my project test suites had previously mocked HTTP requests using Nock which patches the http
module and I’ve had to refactor these tests to use undici.MockClient
instead. I’m also aware of all the services I’ve worked on which have instrumented outgoing HTTP requests also by patching http
and how these will need to be refactored if they make the transition too.
Test suites
Most of the codebases I maintain use a test runner to find test files, execute them, and collect the results. In the past I’ve usually reached for Jasmine or Mocha with Chai (for assertions) and between them and their most popular peers these packages receive more than 12 million downloads each week.
Node v16.17 introduced the test
module to define test suites and the --test
flag for the node
command to find and execute them. The test
module provides functions to construct test suites following a BDD or TDD style and the test command can output results in a few formats including TAP so I’ve found it easy to integrate with my existing projects whichever testing tools they previously used.
Node has always included a basic assert
module and this has grown over time to include some more powerful features such as assert.deepStrictEqual
and function spies. The test
module builds on this by providing further function mocks and fake timers too, so across most of my codebases I’ve been able to drop external dependencies used for testing completely.
const { describe, it, mock } = require('node:test')
const assert = require('node:assert')
const subject = require('../')
describe('when given a function', () => {
it('calls the function once', () => {
const stub = mock.fn()
subject(stub)
assert.equal(stub.mock.calls.length, 1)
})
it('calls the function with the request and response objects', () => {
const stub = mock.fn()
subject(stub)
const [req, res] = stub.mock.calls[0].arguments
assert.ok(isRequest(req))
assert.ok(isResponse(res))
})
})
Conclusion
I’m really happy to rely more on the Node standard library, it makes me feel more at ease that my projects will require fewer updates, receive fewer security bulletins, and be more likely to continue working even if I leave them untouched for a few years - just like my ancient Ruby projects.
I also see more potential for removing dependencies in the near future; mock timers for dates and watch mode in particular have my attention. There are more Web APIs on the horizon too including CustomEvent
and the Web Crypto API which will continue to make code sharing between server and browser easier.