Shedding Node.js dependencies

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.

Screenshot of a terminal showing npm audit fix output

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.