Building a Nodemon Clone with Node.js: A Step-by-Step Guide — Unleash the Power of Automation for Your Development Workflow

7 min. read

Have you ever wondered how Nodemon reloads a Node.js process whenever a file changes? Let’s explore it together by building our own version, which we’ll call “nodemonc” (short for nodemon clone).

Our nodemon clone will consist of three main components:

Make sure to visit this post’s github repository. Please consider following this project’s author, Sina Bayandorian, and starring the project to show your ❤️ and support. https://github.com/sina-byn/nodemonc

Table of Contents

Initial Setup

mkdir nodemonc
cd nodemonc
npm init -y
mkdir src
mkdir .temp
touch src/index.js
touch .temp/server.js

Now let’s install our dependencies:

npm i chokidar commander chalk@4.1.2

File Watcher

The first feature we’ll implement is the file-watching component, responsible for detecting changes in specific files we designate for monitoring. To achieve this, we’ll use the chokidar package—a lightweight and efficient, cross-platform file-watching library ideal for our needs. Let’s dive into how it works:

// * src/index.js

const fs = require('fs');
const chokidar = require('chokidar');

const init = scriptPath => {
  const scriptExists = fs.existsSync(scriptPath);
  if (!scriptExists) throw new Error(`Failed resolving script at ${scriptPath}`);

  chokidar.watch(scriptPath).on('change', () => {
    console.log('script changed');
  });
};

init('.temp/server.js');

Then, run:

node src/index.js

Start typing in the .temp/server.js file, and you’ll see a "script changed" message logged in the terminal. Now that we have file change detection in place, the next step is to implement a process management system. This system will fork the specified script and manage its restart lifecycle, ensuring smooth restarts on each file change.

Process Management

In this part of the project, we’ll use the fork function from Node.js's child_process module to spawn the specified script as a separate process. To get started, create a new file called process.js where we’ll handle this functionality.

touch src/process.js
// * src/process.js

const { fork } = require('child_process');

let cp;

const nodeProcess = scriptPath => {
  const terminate = () => {
    // * return if there is no process to terminate
    if (!cp || cp.killed) return;

    // * terminate the process
    cp.kill();
  };

  return {
    init: () => {
      terminate();

      console.log('Initiating a new process');
      cp = fork(scriptPath);

      cp.on('spawn', () => {
        console.log('Process was successfully spawned');
      });

      cp.on('exit', () => {
        console.log('Running process terminated');
      });
    },
    terminate,
  };
};

module.exports = nodeProcess;

Next, we’ll integrate the process module into src/index.js to connect our process management functionality with the main application logic.

// * src/index.js

const fs = require('fs');
const chokidar = require('chokidar');

const nodeProcess = require('./process');

const init = scriptPath => {
  const scriptExists = fs.existsSync(scriptPath);
  if (!scriptExists) throw new Error(`Failed resolving script at ${scriptPath}`);

  const np = nodeProcess(scriptPath);
  np.init();

  chokidar.watch(scriptPath).on('change', () => {
    console.log('script changed');
    np.init();
  });

  // * terminate the running child_process before parent process exit
  process.on('beforeExit', () => {
    np.terminate();
  });

  // * log uncaught errors
  process.on('uncaughtException', err => {
    console.log(err);
  });
};

init('.temp/server.js');

Now, run node src/index.js and make changes to .temp/server.js to see the automatic restart in action. The final main component we’ll implement for our nodemon clone is the command-line interface (CLI).

CLI — Command Line Interface

One of the most popular packages for parsing stdin and creating command-line interfaces (CLIs) is commander, which we’ll use to build our CLI.

Before diving into the CLI setup, we need to make a few adjustments to src/index.js to enable it to be called directly from the CLI.

// * src/index.js

const fs = require('fs');
const chokidar = require('chokidar');

const nodeProcess = require('./process');

const nodemonc = scriptPath => {
  const scriptExists = fs.existsSync(scriptPath);
  if (!scriptExists) throw new Error(`Failed resolving script at ${scriptPath}`);

  const np = nodeProcess(scriptPath);
  np.init();

  chokidar.watch(scriptPath).on('change', () => {
    console.log('script changed');
    np.init();
  });

  // * terminate the running child_process before parent process exit
  process.on('beforeExit', () => {
    np.terminate();
  });

  // * log uncaught errors
  process.on('uncaughtException', err => {
    console.log(err);
  });
};

module.exports = nodemonc;

With src/index.js now updated, we’re ready to begin building the CLI.

Create a bin directory in your project’s root, and within it, add a file named bin.js. This file will serve as the entry point for our CLI.

mkdir bin
touch bin/cli.js

Define the binary path in package.json to make our CLI executable. This involves adding a bin field to specify the path to bin.js. Here’s how to do it:

// * package.json

{
  ..., 
  "main": "src/index.js",
  "bin": "bin/cli.js"
  ...
}

By adding this, we allow the nodemonc command to be run directly from the command line after installation.

Now, let’s set up the CLI logic by configuring commander to handle command-line options and link it to our process management code.

#!/usr/bin/env node

// * bin/cli.js

const { program } = require('commander');

const nodemonc = require('../src');

program
  .name('nodemonc')
  .description('nodemonc - nodemon clone - CLI')
  .argument('<string>', 'script path to execute')
  .action(scriptPath => {
    nodemonc(scriptPath);
  });

program.parse();

With the CLI complete, you can test it by running the command:

npx nodemon .temp/server.js

Further Enhancements

Logger

We’ll create a simple logger using the chalk package to format console output, making log messages more readable and visually organized.

touch src/logger.js
// * src/logger.js

const chalk = require('chalk');

const _log = (color, ...inputs) => console.log(chalk[color]('[nodemonc]', ...inputs));

const log = (...inputs) => _log('whiteBright', ...inputs);

log.success = (...inputs) => _log('greenBright', ...inputs);
log.error = (...inputs) => _log('redBright', ...inputs);

log.info = (...inputs) => _log('blueBright', ...inputs);
log.warn = (...inputs) => _log('yellowBright', ...inputs);

module.exports = log;

Replace all console.log calls with the new logger to improve log formatting and consistency.

I took this opportunity to enhance the log messages for a better developer experience. Additionally, I introduced a variable called isInitialFork to display different log messages depending on its state.

// * src/process.js

const { fork } = require('child_process');

const log = require('./log');

let isInitialFork = true;
let cp;

const nodeProcess = scriptPath => {
  const terminate = () => {
    // * return if there is no process to terminate
    if (!cp || cp.killed) return;

    // * terminate the process
    cp.kill();
  };

  return {
    init: () => {
      terminate();

      log.warn("to restart at any time, enter 'rs'");
      log.warn("to exit at any time, enter '.exit'");
      log.info(`${isInitialFork ? 'starting' : 'restarting'} 'node ${scriptPath}'`);
      cp = fork(scriptPath);

      cp.on('spawn', () => {
        log.success(`'node ${scriptPath}' successfully started`);
        isInitialFork = false;
      });

      cp.on('exit', () => {
        log.success('clean exit');
      });
    },
    terminate,
  };
};

module.exports = nodeProcess;
// * src/index.js

const log = require('./log');

...
  process.on('uncaughtException', err => {
    log.error(err);
  });
...

Bonus

You may have noticed two log messages: the first instructs that typing rs will manually restart the process, and the second that typing .exit will end it. The rs command is a built-in feature in nodemon, but we’ll implement this functionality ourselves to make it easier to add additional commands, including .exit, as needed.

touch src/readline.js

To implement this feature, we’ll use Node.js’s built-in readline module.

// * src/readline.js

const { createInterface } = require('readline');

const rl = {
  listen: commands => {
    const _rl = createInterface(process.stdin);

    _rl.on('line', input => {
      input = input.trim();

      if (input in commands) commands[input]();
    });
  },
};

module.exports = rl;

Integrate the readline module into src/index.js to handle custom commands.

// * src/index.js

const fs = require('fs');
const chokidar = require('chokidar');

const nodeProcess = require('./process');
const rl = require('./readline');
const log = require('./log');

const nodemonc = scriptPath => {
  const scriptExists = fs.existsSync(scriptPath);
  if (!scriptExists) throw new Error(`Failed resolving script at ${scriptPath}`);

  const np = nodeProcess(scriptPath);
  np.init();

  chokidar.watch(scriptPath).on('change', () => {
    np.init();
  });

  rl.listen({ rs: np.init, '.exit': process.exit });

  // * terminate the running child_process before parent process exit
  process.on('beforeExit', () => {
    np.terminate();
  });

  process.on('uncaughtException', err => {
    log.error(err);
  });
};

module.exports = nodemonc;

Next Steps and Potential Enhancements

Note: I used ChatGPT to enhance readability, grammar, and clarity throughout this article, ensuring a smoother reading experience. Additionally, Microsoft Copilot was used to generate the main title and banner image.

Make sure to visit this post’s github repository. Please consider following this project’s author, Sina Bayandorian, and starring the project to show your ❤️ and support. https://github.com/sina-byn/nodemonc