javascript

Advanced Fastify: Hooks, Middleware, and Decorators

Damilola Olatunji

Damilola Olatunji on

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:

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

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:

shell
curl -I http://localhost:3000
text
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

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:

javascript
// 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;

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:

shell
curl -i http://localhost:3000/health
text
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"}⏎

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:

javascript
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;

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:

text
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"}

GET /health/alwaysUp:

text
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"}

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.

javascript
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); }); });

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:

json
{"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"}

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.

javascript
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}`); });

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:

javascript
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}!`); }); . . .

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.

shell
curl --cookie "user_token=yes.Y7pzW5FUVuoPD5yXLV8joDdR35gNiZJzITWeURHF5Tg" http://127.0.0.1:3000/
text
Hello John Doe!

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:

javascript
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, }); } );

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):

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

And here's the response produced by Fastify:

json
{"statusCode":400,"error":"Bad Request","message":"body/email must match format \"email\""}⏎

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:

javascript
import Fastify from "fastify"; import expressPlugin from "@fastify/express"; const fastify = Fastify({ logger: true, }); await fastify.register(expressPlugin);

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

javascript
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);
shell
curl http://localhost:3000/express
json
{ "body": "hello from express" }

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.

Damilola Olatunji

Damilola Olatunji

Damilola is a freelance technical writer and software developer based in Lagos, Nigeria. He specializes in JavaScript and Node.js, and aims to deliver concise and practical articles for developers. When not writing or coding, he enjoys reading, playing games, and traveling.

All articles by Damilola Olatunji

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps