Back to main page Traveling Coderman

Node.js Microservice Architecture Node.js subcommands without CLI library

When building a Node.js server application, most of the time we want to start the application as a server. It should continously run exposing an HTTP API and respond to incoming requests.

Beside that, we want to be able to execute the application to run a task and terminate. These are tasks that regularly run as a job in production, like database cleanup jobs. Also, these are tasks that we want to run locally, to quickly access specific functionality like retrieving a fresh JWT.

There are several powerful libraries for sophisticated CLIs like commander and docopt.

For our purposes, we want to implement a simpler approach. After all, we only want to be able to distinguish between subcommands. We don't need to provide further arguments. We are in control of the source code. If we need to configure something, we can simply adjust the code.

%%{init: {'theme': 'base', 'themeVariables': {"darkMode":true,"background":"#003890","primaryColor":"#0063b1","fontFamily":"Montserrat, sans-serif","fontSize":"1rem","lineColor":"white"} }}%%
graph TD
    A(main)
    A-->B(server)
    A-->C(send-delay-notifications)

We want to accept the commands server and send-delay-notifications. The command server should be the default.

Selecting the command in main 🔗

In the function main of the entrypoint file main.ts, we assign the third element of the input array to the variable command. If this element does not exist, we specify the default value to be server.

import { logger } from "./logger";
import { cmdServer } from "./commands/server.command";
import { cmdSendDelayNotifications } from "./commands/send-delay-notifications.command";

async function main([_node, _script, command = "server"]: string[]) {
switch (command) {
case "server":
await cmdServer();
break;
case "send-delay-notifications":
await cmdSendDelayNotifications();
break;
default:
logger.error({ command }, "Command does not exist");
process.exit(1);
}
}

We switch over the variable command and execute the appropiate function. If no command matches, then we log an error and exit with a non-zero status code.

The file main.ts continues to contain default error handling for uncaught errors and promise rejections.

main(process.argv).catch((error) => {
logger.error(error, `Uncaught error: ${error.message}`);
process.exit(1);
});

process
.on("unhandledRejection", (reason) => {
logger.error(
reason ?? {},
`Unhandled rejection: ${(reason as Error)?.message}`
);
})
.on("uncaughtException", (error) => {
logger.error(error, `Uncaught Exception: ${error?.message}`);
process.exit(1);
});

Organizing commands 🔗

We create a top-level directory src/commands. Each file in this directory represents one command.

src
`-- commands
|-- server.command.ts
`-- send-delay-notifications.command.ts

The command server configures outgoing HTTP and spins up an HTTP server.

import * as http from "http";
import app from "../app";
import { logger } from "../logger";
import { configureHttp } from "../http/configure-http";

export async function cmdServer(): Promise<void> {
const port = process.env.PORT ?? 3000;
configureHttp();
const server = http.createServer(app);
server.listen(port, () => {
logger.info(`Server listening on port ${port}!`);
});
}

The command send-delay-notifications also configures outgoing HTTP. Then it determines the current date assuming the server is running in UTC. It follows a call to the function sendDelayNotifications containing the logic. At last, it explicitly exits with a zero status code to terminate the application.

import { configureHttp } from "../http/configure-http";
import { sendDelayNotifications } from "../delay-notifications/send-delay-notifications";

export async function cmdSendDelayNotifications(): Promise<void> {
configureHttp();
const now = new Date().toISOString().split("T")[0];
await sendDelayNotifications(now);
process.exit(0);
}

At this point, the logic sendDelayNotifications only logs two lines. We fill in the logic in later posts.

import { logger } from "../logger";

export async function sendDelayNotifications(
startDate: string
): Promise<boolean> {
logger.info({ startDate }, "Starting to send delay notifications.");
// ...
logger.info({ startDate }, "Finished to send delay notifications.");
return true;
}

Convenient npm run commands 🔗

We want to make these commands easily accessible to all engineers. Hence, we add scripts to the package.json.

The script command allows us to execute npm run command <cmd> with a value cmd like server or send-delay-notifications. We make sure to build the application before execution.

The script pretty-logs converts the JSON log lines of Pino.js into a colourful readable format for the CLI.

We keep using nodemon for the script start:app since we want the application to rebuild after changes.

{
// ...
"scripts": {
"postinstall": "tsc",
"build": "tsc",
"start": "npm run build && npx concurrently \"npm:start:*\"",
"start:app": "nodemon --legacy-watch --watch ./dist --inspect=0.0.0.0:9229 dist/main.js -- server | npm run pretty-logs",
// ...
"command": "f() { npm run build && node dist/main.js \"$1\" | npm run pretty-logs; }; f",
"pretty-logs": "npx pino-pretty -i time,hostname,module,__in,name,pid"
// ...
}
// ...
}

Conclusion 🔗

We added the capability to run sub commands of the application. These sub commands execute a setup and then delegate to functions executing logic. The commands are easily accessible with properly formatted logs with the script npm run command <cmd>.

Become a GitHub sponsor to access the code of this post as a GitHub repo.