DEV Community

Cover image for Advanced Fastify: Hooks, Middleware, and Decorators
Damilola Olatunji for AppSignal

Posted on • Originally published at blog.appsignal.com

Advanced Fastify: Hooks, Middleware, and Decorators

In the first article of this series, we introduced Fastify and compared it to Express, highlighting the benefits of switching to Fastify for high-performance web applications. We also explored Fastify's plugin system in detail, showing you how to extend and customize your web applications with reusable modules.

In this part, we'll dive deeper into some of Fastify's more advanced concepts. Specifically, we'll demonstrate how to use hooks, middleware, decorators, and validation to build more robust web applications. We'll also briefly touch on some viable strategies for migrating an existing Express application to Fastify.

Let's get started!

Hooks and Middleware in Fastify

Hooks in Fastify allow you to listen for specific events in your application's lifecycle and perform specific tasks when those events occur. Hooks are analogous to Express-style middleware functions but are more performant, allowing you to perform tasks such as authentication, logging, routing, data parsing, error handling, and more. There are two types of hooks in Fastify: request/reply hooks and application hooks.

Request/Reply Hooks in Fastify

Request/reply hooks are executed during a server's request/reply cycle, allowing you to perform various tasks before or after an incoming request or outgoing response is processed. These hooks can be applied to all routes or a selection of routes. They are typically used to implement features such as:

  • Access control
  • Data validation
  • Request logging

And more.

Examples of request/reply hooks include preRequest, onSend, onTimeout, and others.

Here's an example that uses the onSend hook to add a Server header to all responses sent from a server:

fastify.addHook("onSend", (request, reply, payload, done) => {
  reply.headers({
    Server: "fastify",
  });

  done();
});
Enter fullscreen mode Exit fullscreen mode

The request and reply objects should be familiar to you by now, and the payload parameter represents the response body. You can modify the response payload here or clear it altogether by setting it to null in the hook function. Finally, the done() callback should be executed to signify the end of the hook so that the request/reply lifecycle continues. It can take up to two arguments: an error (if any), or null, and the updated payload.

With the above code in place, each response will now contain the specified headers:

curl -I http://localhost:3000
Enter fullscreen mode Exit fullscreen mode
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
server: fastify
content-length: 12
Date: Sun, 12 Feb 2023 18:23:40 GMT
Connection: keep-alive
Keep-Alive: timeout=72
Enter fullscreen mode Exit fullscreen mode

If you want to implement hooks for a subset of routes, you need to create a new encapsulation context and then register the hook on the fastify instance in the plugin. For example, you can create another onSend hook in the health plugin we demonstrated earlier in this tutorial:

// plugin.js
function health(fastify, options, next) {
  fastify.get("/health", (request, reply) => {
    reply.send({ status: "up" });
  });

  fastify.addHook("onSend", (request, reply, payload, done) => {
    const newPlayload = payload.replace("up", "down");
    reply.headers({
      "Cache-Control": "no-store",
      Server: "nodejs",
    });

    done(null, newPlayload);
  });

  next();
}

export default health;
Enter fullscreen mode Exit fullscreen mode

This time, the onSend hook is used to modify the response body for all the routes in the plugin context (just /health, in this case) by changing up to down, and it also overrides the Server response header from the parent context while adding a new Cache-Control header. Hence, requests to the /health route will now produce the following response:

curl -i http://localhost:3000/health
Enter fullscreen mode Exit fullscreen mode
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
cache-control: no-store
server: nodejs
content-length: 17
Date: Sun, 12 Feb 2023 18:54:21 GMT
Connection: keep-alive
Keep-Alive: timeout=72

{"status":"down"}⏎
Enter fullscreen mode Exit fullscreen mode

Note that the onSend hook in the health context will run after all shared onSend hooks. If you want to register a hook on a specific route instead of all the routes in the plugin context, you can add it to the route options as shown below:

function health(fastify, options, next) {
  fastify.get(
    "/health",
    {
      onSend: function (request, reply, payload, done) {
        const newPlayload = payload.replace("up", "down");
        reply.headers({
          "Cache-Control": "no-store",
          Server: "nodejs",
        });

        done(null, newPlayload);
      },
    },
    (request, reply) => {
      reply.send({ status: "up" });
    }
  );

  fastify.get("/health/alwaysUp", (request, reply) => {
    reply.send({ status: "up" });
  });

  next();
}

export default health;
Enter fullscreen mode Exit fullscreen mode

The onSend hook has been moved to the route options for the /health route, so it only affects the request/response cycle for this route. You can observe the difference by making requests to /health and /health/alwaysUp as shown below. Even though they are in the same plugin context with identical handlers, the content of their responses is different.

GET /health:

HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
server: nodejs
cache-control: no-store
content-length: 17
Date: Sun, 12 Feb 2023 19:08:37 GMT
Connection: keep-alive
Keep-Alive: timeout=72

{"status":"down"}
Enter fullscreen mode Exit fullscreen mode

GET /health/alwaysUp:

HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
server: fastify
content-length: 15
Date: Sun, 12 Feb 2023 19:09:18 GMT
Connection: keep-alive
Keep-Alive: timeout=72

{"status":"up"}
Enter fullscreen mode Exit fullscreen mode

Fastify Application Hooks

Application hooks, on the other hand, are executed outside of the request/reply lifecycle. They are executed upon events emitted by the Fastify instance and can be used to carry out general server or plugin tasks, such as:

  • Connecting to databases and other resources
  • Loading or unloading configuration
  • Closing connections
  • Persisting data
  • Flushing logs

And others.

Here's an example that simulates a graceful shutdown using Fastify's onClose application hook. Such a setup is ideal if you are deploying an update or your server needs to restart for other reasons.

fastify.addHook("onClose", (instance, done) => {
  instance.log.info("Server is shutting down...");
  // Perform any necessary cleanup tasks here
  done();
});

process.on("SIGINT", () => {
  fastify.close(() => {
    fastify.log.info("Server has been shut down");
    process.exit(0);
  });
});
Enter fullscreen mode Exit fullscreen mode

In this example, the onClose hook is registered on the server's root context, and the callback is executed before the server is shut down. The hook function has access to the current Fastify instance and a done callback, which should be called when the hook is finished.

In addition, the process.on() function listens for the SIGINT signal, which is sent to the process when you press CTRL+C or the system shuts down. When the signal is received, the fastify.close() function is called to shut down the server, and a record of the server closure is logged to the console.

After adding the above code to your program, start the server and press Ctrl-C to shut down the process. You will observe the following logs in the console:

{"level":30,"time":1676240333903,"pid":615734,"hostname":"fedora","msg":"Server is shutting down..."}
{"level":30,"time":1676240333903,"pid":615734,"hostname":"fedora","msg":"Server has been shut down"}
Enter fullscreen mode Exit fullscreen mode

Middleware in Fastify

Fastify also supports Express-style middleware but it requires you to install an external plugin such as @fastify/express or @fastify/middie. This eases migration from Express to Fastify, but it should not be used in greenfield projects in favor of hooks. Note that in many cases, you can find a native Fastify plugin that provides the same functionality as Express middleware.

The example below demonstrates how to use a standard Express middleware such as cookie-parser in Fastify, but you should prefer native alternatives — such as @fastify/cookie — where possible, since they are better optimized for use in Fastify.

import Fastify from "fastify";
import middie from "@fastify/middie";
import cookieParser from "cookie-parser";

const fastify = Fastify({
  logger: true,
});

await fastify.register(middie);

fastify.use(cookieParser());

fastify.get("/", function (request, reply) {
  console.log("Cookies: ", request.raw.cookies);
  reply.send("Hello world!");
});

const port = process.env.PORT || 3000;

fastify.listen({ port }, function (err, address) {
  if (err) {
    fastify.log.error(err);
    process.exit(1);
  }

  fastify.log.info(`Fastify is listening on port: ${address}`);
});
Enter fullscreen mode Exit fullscreen mode

Once the @fastify/middie plugin is imported and registered, you can begin to use Express middleware via the use() method provided on the Fastify instance, and it should just work as shown above. Note that every single middleware applied in this manner will be executed on the onRequest hook phase.

Decorators in Fastify

Decorators are a feature that allow you to customize core objects with any type of JavaScript property, such as functions, objects, or any primitive type. For example, you can use decorators to store custom data or register a new method in the Fastify, Request, or Reply objects through the decorate(), decorateRequest(), and decorateReply() methods, respectively.

This example demonstrates using Fastify's decorators to add
functionality to a web application:

import Fastify from 'fastify';
import fastifyCookie from '@fastify/cookie';

const fastify = Fastify({
  logger: true,
});

await fastify.register(fastifyCookie, {
  secret: 'secret',
});

fastify.decorate('authorize', authorize);
fastify.decorate('getUser', getUser);
fastify.decorateRequest('user', null);

async function getUser(token) {
  // imagine the token is used to retrieve a user
  return {
    id: 1234,
    name: 'John Doe',
    email: 'john@example.com',
  };
}

async function authorize(request, reply) {
  const { user_token } = request.cookies;
  if (!user_token) {
    throw new Error('unauthorized: missing session cookie');
  }

  const cookie = request.unsignCookie(user_token);
  if (!cookie.valid) {
    throw new Error('unauthorized: invalid cookie signature');
  }

  let user;
  try {
    user = await fastify.getUser(cookie.value);
  } catch (err) {
    request.log.warn(err);
    throw err;
  }

  request.user = user;
}

fastify.get('/', async function (request, reply) {
  await this.authorize(request, reply);

  reply.send(`Hello ${request.user.name}!`);
});

. . .
Enter fullscreen mode Exit fullscreen mode

The above snippet of code decorates two functions on the fastify instance. The first one is getUser() — it takes a token as a parameter and returns a user object (hardcoded in this example).

The authorize() function is defined next. It checks whether the user_token cookie is present in the request and validates it. If the cookie is invalid or missing, an error is thrown. Otherwise, the cookie value is used to retrieve the corresponding user with the getUser() function, and the result is stored in a user property on the request object. If an error occurs while retrieving the user, the error is logged and re-thrown.

While you can add any property to Fastify, Request, or Reply objects, you need to declare them in advance with decorators. This helps the underlying JavaScript engine to optimize handling of these objects.

curl --cookie "user_token=yes.Y7pzW5FUVuoPD5yXLV8joDdR35gNiZJzITWeURHF5Tg" http://127.0.0.1:3000/
Enter fullscreen mode Exit fullscreen mode
Hello John Doe!
Enter fullscreen mode Exit fullscreen mode

Data Validation in Fastify

Data validation is an essential feature of any web application that relies on client data, as it helps to prevent security vulnerabilities caused by malicious payloads and improves the reliability and robustness of an application.

Fastify uses JSON schema to define the validation rules for each route's input payload, which includes the request body, query string, parameters, and headers. The JSON schema is a standard format for defining the structure and constraints of JSON data, and Fastify uses Ajv, one of the fastest and most efficient JSON schema validators available.

To use JSON validation in Fastify, you need to define a schema for each route that expects a payload. You can specify the schema using the standard JSON schema format or the Fastify JSON schema format, which is a more concise and expressive way of expressing the schema.

Here's an example of how to define a JSON schema for a route in Fastify:

const bodySchema = {
  type: "object",
  properties: {
    name: { type: "string" },
    age: { type: "number" },
    email: { type: "string", format: "email" },
  },
  required: ["name", "email"],
};

fastify.post(
  "/users",
  {
    schema: {
      body: bodySchema,
    },
  },
  async (request, reply) => {
    const { name, age, email } = request.body;
    reply.send({
      name,
      age,
      email,
    });
  }
);
Enter fullscreen mode Exit fullscreen mode

In this example, we define a schema that expects an object with three properties: name, age, and email. The name property should be a string, the age property should be a number, and the email property should be a string in email format. We also specify that name and email are required properties.

When a client sends a POST request to /users with an invalid payload, Fastify automatically returns a '400 Bad Request' response with an error message indicating which property failed the validation. However, if the payload adheres to the schema, the route handler function will be executed with the parsed payload as request.body.

Here's an example of a request with an invalid payload (the email key is in the wrong format):

curl --header "Content-Type: application/json" \
  --request POST \
  --data '{"name":"John","age": 44,"email":"john@example"}' \
  http://localhost:3000/users
Enter fullscreen mode Exit fullscreen mode

And here's the response produced by Fastify:

{"statusCode":400,"error":"Bad Request","message":"body/email must match format \"email\""}
Enter fullscreen mode Exit fullscreen mode

See the docs to learn more about validation in Fastify and how to customize its behavior to suit your needs.

Planning an Incremental Migration from Express to Fastify

Incremental migration is an excellent strategy for those who want to switch to a new framework but cannot afford to make the change all at once. By adopting an incremental approach, you can gradually introduce Fastify into your project while still using Express, giving you time to make any necessary changes and ensure a smooth transition.

The first step is to identify the parts of your project that would benefit most from Fastify's features, such as its built-in support for validation and logging, and improved performance over Express. Once you have identified those areas, you can introduce Fastify alongside your existing Express code.

This might involve setting up a separate server instance that handles certain routes or endpoints using Fastify while still using Express for the rest of an application. But you'll probably find it easier to use the @fastify/express plugin to add full Express compatibility to Fastify so that you can use Express middleware and applications as if they are Fastify plugins.

To use the @fastify/express plugin, you can install it via npm and register it with your Fastify instance:

import Fastify from "fastify";
import expressPlugin from "@fastify/express";

const fastify = Fastify({
  logger: true,
});

await fastify.register(expressPlugin);
Enter fullscreen mode Exit fullscreen mode

You can then use Express middleware or applications just like in an Express application. For example:

import express from "express";
const expressApp = express();

expressApp.use((req, res, next) => {
  console.log("This is an Express middleware");
  next();
});

expressApp.get("/express", (req, res) => {
  res.json({ body: "hello from express" });
});

fastify.use(expressApp);
Enter fullscreen mode Exit fullscreen mode
curl http://localhost:3000/express
Enter fullscreen mode Exit fullscreen mode
{ "body": "hello from express" }
Enter fullscreen mode Exit fullscreen mode

As you become more comfortable with Fastify, you can start to migrate more and more of your code over, eventually replacing all of your Express-specific code with Fastify. By taking the time to plan and execute a thoughtful migration strategy, you can ensure a smooth transition and ultimately end up with a more efficient, high-performing application.

Up Next: Migrate to Fastify from Express

We've covered a lot of ground in this tutorial. We hope you've gained a deeper understanding of how Fastify's novel features like hooks and decorators can help you customize and extend your applications more than Express.

In the next and final part of this series, we'll provide a practical guide for migrating to Fastify from Express. We'll cover common migration scenarios, provide tips for optimizing performance and improving security, and share some best practices along the way.

Thanks for reading, and see you next time!

P.S. If you liked this post, subscribe to our JavaScript Sorcery list for a monthly deep dive into more magical JavaScript tips and tricks.

P.P.S. If you need an APM for your Node.js app, go and check out the AppSignal APM for Node.js.

Top comments (0)