Qu’est-ce que Fastify ?
Fastify est un framework web pour Node.js basé sur un pari simple : si vous décrivez vos données, le framework peut s’occuper d’une plus grande partie du travail. Il associe un routeur très rapide à un système de schémas, un modèle de plugins à portée définie (scoped) et un cycle de vie composé de hooks. Le résultat est un framework à la fois performant et rigoureux sur la sécurité, sans pour autant vous imposer une structure de dossiers spécifique.
Apparu en 2016, il a atteint sa version stable 1.0 en 2018. Aujourd’hui, il propulse des API en production pour des entreprises qui recherchent l’ergonomie d’Express, mais avec des performances nettement supérieures et une véritable stratégie de validation. Si vous connaissez Express, la majeure partie de Fastify vous semblera familière en l’espace d’un après-midi.
Schema-first : une validation compilée
La caractéristique principale est que les schémas sont exécutables. Lorsque vous associez un JSON Schema à une route, Fastify le compile une seule fois au démarrage et utilise le validateur compilé pour chaque requête. Aucune interprétation n’est effectuée à chaque appel, c’est pourquoi la validation est quasiment gratuite.
import { Type, type Static } from "@sinclair/typebox";
const CreateUser = Type.Object({
name: Type.String({ minLength: 1, maxLength: 80 }),
email: Type.String({ format: "email" }),
});
type CreateUser = Static<typeof CreateUser>;
app.post("/users", {
schema: { body: CreateUser },
}, async (request) => {
// request.body is validated and typed as CreateUser
return createUser(request.body);
});
Cela offre deux avantages. Premièrement, les requêtes invalides sont rejetées avec un 400 avant même que votre handler ne soit appelé, ainsi votre logique métier ne traite que des données propres. Deuxièmement, ce même schéma pilote la sérialisation : Fastify compile le schéma de réponse en une fonction fast-json-stringify, ce qui est considérablement plus rapide que JSON.stringify et garantit que vous ne divulguez jamais de champs que vous n’avez pas déclarés.
Routes et options de route
Une route se compose d’une méthode, d’un chemin et d’un handler, mais la partie intéressante réside dans l’objet d’options situé entre les deux. C’est là que se trouvent le schéma, le handler et les métadonnées propres à chaque route.
app.route({
method: "GET",
url: "/health",
config: { public: true },
schema: {
response: {
200: Type.Object({ status: Type.String() }),
},
},
handler: async () => ({ status: "ok" }),
});
Les méthodes raccourcies (app.get, app.post et ainsi de suite) acceptent les mêmes options en second argument. Les paramètres de route arrivent sur request.params, la query string sur request.query, et le corps analysé (parsed body) sur request.body. Comme les structures proviennent du schéma, TypeScript reconnaît ces trois éléments.
reply constitue l’autre moitié du handler. reply.code(404), reply.header(...) et reply.send(...) s’inspirent d’Express, et retourner une valeur depuis un handler async est un raccourci pour reply.send. Fastify propose également des helpers tels que reply.callNotFound() afin que la gestion des erreurs reste cohérente.
Plugins et encapsulation
Fastify ne possède pas de chaîne de middleware au sens d’Express. À la place, tout est un plugin, et chaque plugin dispose de son propre scope. Cette règle unique est ce qui permet aux applications de grande taille de rester prévisibles.
import fp from "fastify-plugin";
const dbPlugin = fp(async (app) => {
app.decorate("users", createUserRepository(app.log));
}, { name: "db" });
await app.register(dbPlugin);
Sans fastify-plugin, les décorateurs et les hooks ajoutés à l’intérieur du plugin ne seraient visibles que pour les routes enregistrées dans ce même plugin. C’est ce qu’on appelle l’encapsulation : une fonctionnalité peut posséder ses propres dépendances et sa configuration sans polluer le reste de l’application. L’utilisation de fp permet de supprimer délibérément cette frontière lorsque vous construisez une infrastructure partagée, comme une base de données ou un logger.
Les décorateurs sont la manière idiomatique d’attacher des fonctionnalités : app.decorate("users", repo) pour l’instance, app.decorateRequest("user", null) pour la requête, et app.decorateReply pour la réponse. Comme ils sont typés via l’augmentation de module, vous bénéficiez de l’autocomplétion au lieu de any.
Le cycle de vie : l’ordre des hooks
Les hooks vous permettent d’exécuter du code à des moments précis sans avoir à encapsuler vos handlers. Ils s’exécutent selon un ordre fixe :
onRequest— le point le plus précoce, idéal pour l’authentification et les IDs de requête.preParsing— avant la lecture du corps de la requête, pour la compression ou la vérification de la taille.preValidation— après le parsing, avant la validation du schéma.preHandler— après la validation, juste avant le handler.preSerializationetonSend— façonnent le payload lors de la réponse.onResponseetonError— observent la requête une fois terminée.
app.addHook("onRequest", async (request) => {
request.start = process.hrtime.bigint();
});
app.addHook("onResponse", async (request, reply) => {
const ms = Number(process.hrtime.bigint() - request.start) / 1e6;
request.log.info({ ms }, "request completed");
});
Les hooks sont scopés comme les plugins ; ainsi, un hook ajouté à l’intérieur d’un plugin ne s’exécutera que pour les routes de ce plugin. onClose est l’équivalent pour l’arrêt du serveur, et c’est là que vous libérez les pools de base de données et les timers. Maîtriser cet ordre est essentiel ; la documentation le liste précisément et il change rarement.
TypeScript sans le superflu
Fastify est écrit en TypeScript et ses types sont traités comme des citoyens de première classe. Vous typez une route en passant des paramètres génériques, et les erreurs de validation apparaissent à la compilation plutôt qu’en production.
app.get<{
Params: { id: string };
Querystring: { fields?: string };
}>("/users/:id", async (request) => {
const { id } = request.params;
const { fields } = request.query;
return findUser(id, fields);
});
Le pattern le plus précieux est celui des type providers. Avec @fastify/type-provider-typebox, le schéma lui-même devient le type ; ainsi, vous n’avez jamais à écrire l’interface deux fois et vous évitez tout risque de désynchronisation entre les deux.
import { TypeBoxTypeProvider } from "@fastify/type-provider-typebox";
const app = Fastify().withTypeProvider<TypeBoxTypeProvider>();
app.post("/users", { schema: { body: CreateUser } }, async (request) => {
return createUser(request.body); // typed as CreateUser
});
L’augmentation de module est la méthode utilisée pour typer les décorateurs : déclarez la propriété sur FastifyInstance ou FastifyRequest, et elle sera disponible partout. Lorsqu’un élément est unknown, c’est généralement le signe qu’un schéma ou une déclaration est manquant.
Logging avec pino, intégré nativement
Fastify est livré avec pino comme logger. Activez-le via une seule option et chaque requête générera une ligne de log JSON structurée comprenant un id, le timing et vos propres champs.
const app = Fastify({
logger: {
level: "info",
redact: ["req.headers.authorization"],
},
});
app.get("/orders", async (request) => {
request.log.info({ userId: request.user?.id }, "listing orders");
return listOrders(request.user?.id);
});
Comme il s’agit de pino, la sortie est du JSON délimité par des sauts de ligne (newline-delimited JSON) qui s’intègre parfaitement à n’importe quel agrégateur de logs, et request.log inclut automatiquement le contexte de la requête. En développement, passez la sortie par pino-pretty pour un affichage lisible ; en production, conservez le format JSON. Les règles de redaction sont le moyen le plus sûr d’empêcher les tokens de se retrouver dans vos logs.
Tester avec app.inject()
Il n’est pas nécessaire d’ouvrir un port pour tester une application Fastify. app.inject() exécute une requête à travers toute la pile, incluant le routage, la validation et les hooks, et renvoie un objet de réponse sur lequel vous pouvez effectuer des assertions.
const app = buildApp();
await app.ready();
const res = await app.inject({
method: "POST",
url: "/users",
payload: { name: "Ada", email: "[email protected]" },
});
assert.equal(res.statusCode, 201);
assert.equal(res.json().name, "Ada");
await app.close();
Deux bonnes pratiques rendent ce processus agréable. Premièrement, exportez une factory buildApp() depuis app.ts et n’appelez listen() que dans server.ts, afin que les tests ne bind jamais de port. Deuxièmement, appelez await app.ready() avant l’injection, ce qui force le chargement complet des plugins et des schémas. Les tests s’exécutent rapidement car il n’y a pas de socket, et ils sont isolés car chaque test construit sa propre instance.
Bonnes pratiques
- Définissez des schémas de requête et de réponse pour chaque route, puis dérivez les types à l’aide d’un type provider.
- Séparez la factory de l’application et le listener dans des fichiers distincts pour que les tests restent in-process.
- Enregistrez l’infrastructure avec
fastify-pluginet le code métier sans, afin que les scopes restent cohérents. - Utilisez
onRequestpour l’authentification etpreHandlerpour l’autorisation nécessitant le corps de la requête analysé. - Centralisez le formatage des erreurs avec
setErrorHandlerau lieu d’ajouter des conditions dans chaque handler. - Loguez des champs structurés, masquez les secrets et ne loguez jamais l’intégralité des corps de requête.
- Validez la configuration avec
@fastify/envet provoquez un arrêt immédiat (fail fast) au démarrage en cas d’erreur. - Appelez
app.close()dans les tests et lors duSIGTERMpour que les connexions soient fermées proprement.
Erreurs courantes
- Enregistrer un plugin sans
fastify-pluginet se demander pourquoi les décorateurs sont indéfinis ailleurs. - Oublier
await app.ready()dans les tests, ce qui fait que les schémas et les plugins ne sont pas encore chargés. - Utiliser
JSON.stringifymanuellement alors qu’un schéma de réponse permettrait une sérialisation plus rapide et plus sûre. - Omettre les schémas de réponse, ce qui signifie que n’importe quelle propriété peut fuiter vers les clients.
- Ajouter des hooks globaux alors qu’un hook de plugin scoped éviterait d’impacter des routes non liées de manière imprévue.
- Traiter les hooks comme du middleware et s’attendre à ce que
preHandlers’exécute avant le parsing du corps de la requête. - Ignorer la structure du gestionnaire d’erreurs par défaut et casser les clients de l’API avec des réponses incohérentes.
Et après ?
Fastify est l’évolution naturelle après Express lorsque le débit et la validation deviennent prioritaires. Si votre équipe recherche une architecture plus directive avec l’injection de dépendances basée sur Fastify, consultez le guide NestJS. Pour une approche similaire basée sur les standards du web pour les runtimes edge, découvrez Hono. Enfin, si le cycle de vie des plugins vous semble encore abstrait, n’hésitez pas à revoir les bases de Node.js sur lesquelles il s’appuie.