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();
});
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
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:
// 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:
curl -i http://localhost:3000/health
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:
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:
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:
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.
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:
{"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.
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:
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.
curl --cookie "user_token=yes.Y7pzW5FUVuoPD5yXLV8joDdR35gNiZJzITWeURHF5Tg" http://127.0.0.1:3000/
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:
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):
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:
{"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:
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:
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);
curl http://localhost:3000/express
{ "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.
Top comments (0)