7 minute read

The baseline project

This post assumes you are following along with the baseline project tutorial, but the concepts apply to any Typescript project.

Node best practices

Logging

We are going to use a logging library called pino for our application. Using a mature logging library is recommended because it allows us to access structured log data as JSON objects. Later, we can configure it to persist log data by outputting logs to a file or an external server.

Install pino, along with its type definitions:

$ yarn add pino pino-pretty && yarn add -D @types/pino

Create a util folder in your project source directory and create the logger.ts file:

$ mkdir src/util && touch src/util/logger.ts

Create and export a pino logger instance. For now, we will use the default transport, which logs to the console. We will also enable prettyPrint when we are not in a production environment:

// src/util/logger.ts

import pino from "pino";

export const logger = pino({
  level: "info",
  prettyPrint: process.env.NODE_ENV !== "production",
});

Error handling

When your application encounters an unknown error, it should terminate. From the official Node.js documentation:

By the very nature of how throw works in JavaScript, there is almost never any way to safely “pick up where it left off”, without leaking references, or creating some other sort of undefined brittle state.

The safest way to respond to a thrown error is to shut down the process. Of course, in a normal web server, there may be many open connections, and it is not reasonable to abruptly shut those down because an error was triggered by someone else.

The better approach is to send an error response to the request that triggered the error, while letting the others finish in their normal time, and stop listening for new requests in that worker.

Let’s ensure that our application catches any unsafe errors by passing them to a centralized error handler.

$ touch src/util/error.ts

To ensure that our application issues a final log message when we crash it, pino provides a final function that we will use to issue a fatal log:

// src/util/error.ts

import pino from "pino";

import { logger } from "./logger";

export const handle = pino.final(logger, (err, finalLogger) => {
  finalLogger.fatal(err);
  process.exitCode = 1;
  process.kill(process.pid, "SIGTERM");
});

Now, in our main file src/index.ts, let’s ensure that any unhandled errors are passed to our handle function. To do that, we will add listeners to the unhandledRejection and uncaughtException events in the Node process:

// src/index.ts

import { handle } from "./util/error";

process.on("unhandledRejection", (err) => {
  throw err;
});

process.on("uncaughtException", (err) => {
  handle(err);
});

Our application will now issue a final log before terminating on any unhandled errors.

express app setup

express is an extremely popular web framework for node.js, and it’s what we will use for the baseline project.

In your project folder, install express, along with its type definitions:

$ yarn add express && yarn add -D @types/express

We are also going to use pino-http, which is a middleware that logs https requests to your server using the pino logger:

$ yarn add pino-http && yarn add -D @types/pino-http

I prefer to store the definition of my express application separately from the code that starts the HTTP server.

Create a file called app.ts in your source folder:

$ touch src/app.ts

In app.ts, we will set up a basic express application, register built-in middlewares for parsing JSON request bodies and encoded URLs, register the pino-http middleware, and create a single healthcheck route that we can use to check the status of our server. Finally, we will export the express app so that we can use it elsewhere:

// src/app.ts

import express from "express";
import pinoHttp from "pino-http";

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

const app = express();

app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(pinoHttp({ logger }));

app.get("/api/health", (req, res) => res.send({ message: "OK" }));

export { app };

Server config

Our server configuration may change depending on the environment we are running it in. We may also need to use sensitive values (like database authentication credentials) that should not be exposed in our source code. For these reasons, we will store our application configuration in the environment using environmental variables managed by the operating system.

For development, it’s convenient to use a file named .env, which is not checked in to version control, to manage those variables on the fly. We will use a library called dotenv to parse .env files and set the corresponding environmental variables:

$ yarn add dotenv

It is important that our .env files do not get checked into source control, since they may contain sensitive information. Let’s update our .gitignore file to ensure they are excluded:

#.gitignore

node_modules
dist
.env

Now create a .env file in the project root, and populate it with a single PORT variable:

$ touch .env
# .env

PORT=5000

Next, let’s load any .env variables as the first thing we do when we run src/index.ts:

// src/index.ts

import { config } from "dotenv";

config();

// error listeners ...

The config function exported by dotenv parses our .env file and sets the environmental variables accordingly so that we can use them throughout our application.

Starting and stopping the server

When our application terminates, due to external input or an internal error, there may be a number of ongoing client connections that are in the process of being resolved. Rather than abruptly terminating those connections, we want to allow any existing connections to resolve before shutting down the server gracefully.

To do this, we need to store a list of ongoing connections and implement logic to ensure connections are closed before the process is allowed to end. Rather than implement that logic ourselves, we are going to use a library called http-terminator that does it for us:

$ yarn install http-terminator

Now, in src/index.ts, we will start the server and use http-terminator to gracefully close the server if a shutdown signal is received:

// src/index.ts

import { createHttpTerminator } from "http-terminator";

import { app } from "./app";

// existing code ...

const server = app.listen(process.env.PORT || 3000, () => {
  logger.info(
    `started server on :${process.env.PORT || 3000} in ${
      process.env.NODE_ENV
    } mode`
  );
});

const httpTerminator = createHttpTerminator({ server });

const shutdownSignals = ["SIGTERM", "SIGINT"];

shutdownSignals.forEach((signal) =>
  process.on(signal, async () => {
    logger.info(`${signal} received, closing gracefully ...`);
    await httpTerminator.terminate();
  })
);

Notice that we are using process.env.PORT to set the port that express binds to, which should be loaded from our .env file. Otherwise, we use port 3000 as a fallback.

We are also registering listeners on the SIGINT and SIGTERM events, which are issued when node receives a signal from the environment to terminate the process. Earlier, when we implemented our error handler function, we told node to issue a SIGTERM event when terminating the process. This means our graceful shutdown listener will be called when closing the process from our error handling code, or when the process terminates from an external signal.

Your final src/index.ts should look like this:

// src/index.ts

import { config } from "dotenv";
import { createHttpTerminator } from "http-terminator";

import { app } from "./app";
import { handle } from "./util/error";
import { logger } from "./util/logger";

config();

process.on("unhandledRejection", (err) => {
  throw err;
});

process.on("uncaughtException", (err) => {
  handle(err);
});

const server = app.listen(process.env.PORT || 3000, () => {
  logger.info(
    `started server on :${process.env.PORT || 3000} in ${
      process.env.NODE_ENV
    } mode`
  );
});

const httpTerminator = createHttpTerminator({ server });

const shutdownSignals = ["SIGTERM", "SIGINT"];

shutdownSignals.forEach((signal) =>
  process.on(signal, async () => {
    logger.info(`${signal} received, closing gracefully ...`);
    await httpTerminator.terminate();
  })
);

Testing the server

Let’s start the server using the yarn scripts we set up in the last post:

$ yarn dev

You should see a log message that includes the port we set in our .env file earlier:

[1621625365575] INFO (90294 on Jons-MacBook-Pro.local): started server on :5000 in development mode

Now, using your browser, or an API testing tool like Postman, make a GET request to the healthcheck route we implemented earlier:

GET http://localhost:5000/api/health

The response should be:

{
	"message": "OK"
}

Now try terminating your application from the terminal by pressing ctrl-C, which sends a SIGINT signal to the node process. You should see a log message showing that our graceful termination code is being executed:

[1621626736712] INFO (93255 on Jons-MacBook-Pro.local): SIGINT received, closing gracefully ..

Commit

Go ahead and stage your changes:

$ git add .

And commit them to source control:

$ git commit