Happy Little Monoliths is available for pre-order.

Hot reloading source code after changes is an essential DX feature. This is a solved issue in frontend development, with tools like webpack and Vite, but not so straightforward when it comes to Node.js.

To illustrate just how difficult it can be, you can take a look at the source for lib/watch in the fastify-cli codebase. There you'll find index.js (117 lines), fork.js (56 lines) and utils.js (26 lines). Don't get me wrong — it is an amazingly concise and efficient implementation for what it sets out to accomplish, that is, gracefully restart a Fastify server by forking the current process and carefully cleaning up all lingering items in memory.

Then there's of course nodemon, by the super capable Remy Sharp. With over 4 million weekly downloads on npm, it's the go-to solution for many — including myself for a long time. It will let you monitor changes and restart any Node.js process. Like fastify-cli, it also relies on process forking to handle restarts.

As I've mentioned in the past, I've been working on a new full stack framework based on Fastify. I initially thought about just using fastify-cli, but I wanted to try a simpler approach to do this. In fact, most of my work on this framework that is not directly related to fastify-vite will be about reimplementing bits from fastify-cli, with a more DX-oriented vision.

Restarting without forking

I don't want to rely on process forking to do this. I want to have a controller script that basically takes care of the monitoring, and just kills the existing Node.js instance and cleanly boots a new one. With this we remove a lot of the complexity involved in doing the forking correctly and ensure no memory leaks.

To assert if this was indeed feasible as I imagined, I tested with the zx-based script below. Spawn a Node.js process, hold a reference to zx's ProcessOutput instance and use it to kill the current process and start a new one.

#!/usr/bin/env node

import 'zx/globals'

let node

setTimeout(() => {
  // Force restart after 5 seconds
  node.kill()
}, 5000)

while (true) {
  node = getNode()
  try {
    await node
  } catch {
    console.log('Restarting')
  }
}

function getNode () {
  return $`node ${process.argv[2]}`
}

I can imagine you instantly disliking that infinite loop coupled with an await statement, we'll get to drop it soon enough. This is just a proof of concept.

You can see in getNode() it grabs the path passed to the command-line and just relays it to the dynamic Node.js invocation as-is. This works rather smoothly.

Watching for changes

Now that we have a way to restart the Node.js process we want to run, we can set it up to only do it when it's needed, i.e., when files change. We can do that rather easily with chokidar: setup a file system watcher for CJS and ESM files, ignore all node_modules folders, no matter how deeply nested they might be. Everytime there's a change, any change, kill the current Node.js process triggering a restart.

chokidar
  .watch(['**/*.mjs', '**/*.js'], {
    ignoreInitial: true,
    ignored: ['**/node_modules/**'],
  })
  .on('all', () => {
    node.kill()
  })

You can see the full commit here.

Dropping the infinite loop

If you look at the original proof of concept carefully, you'll realize we don't need the infinite loop at all. We just need to ensure the application only restarts when there are actual file changes. In this iteration, let's just ensure start() is able to recursively execute itself whenever node exits:

let node

chokidar
  .watch(['**/*.mjs', '**/*.js'], {
    ignoreInitial: true,
    ignored: ['**/node_modules/**'],
  })
  .on('all', () => node.kill())

await start()

async function start () {  
  node = $`node ${process.argv[2]}`
  try {
    await node
  } catch {
    start()
  }
}

You can see the full commit here.

This commit shows the use of the EventEmitter class as a way to trigger a restart event, instead of just making start() recursive. This is because at the time I thought I was going to need EventEmitter, but later realized I didn't.

Patching up zx

There are some holes in this implementation. To start, I don't want to see zx's printing the actual command I'm executing, and I don't want it to automatically quote parameters and instead let me handle that on-demand. To that effect, I forked zx to a local zx.mjs file and applied the following patches:

  • Commented out this block on line 83:
if (verbose && !promise._quiet) {
  printCmd(cmd)
}
  • Made these changes on line 133:
$.originalQuote = quote
$.quote = s => s

Now we can ensure we're using the same Node.js executable used to invoke the controller script and pass the entirety of the process.argv along.

chokidar
  .watch(['**/*.mjs', '**/*.js'], {
    ignoreInitial: true,
    ignored: ['**/node_modules/**'],
  })
  .on('all', () => restart())

await start()

async function start () {
  node = $`${
    process.argv[0]
  } ${
    process.argv.slice(2).map($.originalQuote).join(' ')
  }`
  try {
    console.log('ℹ started')
    await node
  } catch {
    // No need to do anything as zx will print the original stderr
  }
}

function restart () {
  node.catch(() => start())
  node.kill()
  console.log('ℹ restarting')
}

Notice how we're no longer calling restart() from the start() function. In this iteration, we now trigger the event directly from chokidar. With this fix, we prevent the process from restarting automatically in case it exits on its own.

You can see the full commit here.

Adding configuration

The settings passed to chokidar are sensible enough and should handle most cases, but this is something that should definitely be configurable. Suppose for instance you have a folder full of frontend JavaScript files, which are already hot reloaded by Vite — you'd want those patterns in ignored. Let's make our little script recognize the presence of a JSON5 configuration file, minimon.conf:

const config = await loadConfig()

chokidar
  .watch(config.watch, {
    ignoreInitial: true,
    ignored: config.ignored,
  })

// ...

async function loadConfig () {
  const defaults = {
    watch: ['**/*.mjs', '**/*.js'],
    ignored: ['**/node_modules/**'],
  }
  if (await fs.exists('./minimon.conf')) {
    return Object.assign(defaults, JSON5.parse(await fs.readFile('./minimon.conf', 'utf8')))
  } else {
    return defaults
  }
}

Wrapping up

I think this about covers it — we have here a minimal replacement to nodemon that doesn't use forking and has so far proven to be rather reliable! Time to turn this into a tool everyone can use. In package.json, just add the bin entry linking the minimon alias to minimon.mjs.

{
  "files": [
    "minimon.mjs",
    "zx.mjs"
  ],
  "bin": {
    "minimon": "./minimon.mjs"
  }
}

This package is published on npm. Just npm i minimon -g and you're done. I've left all original iterations in the repository if you want to check them out:

https://github.com/galvez/minimon

Follow me on X and GitHub.

Go to the index page.