SKILL DETAIL
fastify-best-practices
mcollina/skills/fastify-best-practices
This skill provides comprehensive guidance for building Node.js backend servers and REST APIs with Fastify, supporting both TypeScript and JavaScript. It covers the full development lifecycle of a Fastify application, including defining routes, implementing plugins, setting up JSON Schema validation, handling errors, optimizing performance, managing authentication, configuring CORS and security headers, integrating databases, working with WebSockets, and deploying to production. The skill details the Fastify request lifecycle (hooks, serialization, logging with Pino) and TypeScript integration via strip types. It offers best practices and code examples to help developers adhere to Fastify's core principles of encapsulation, schema-first design, and performance optimization, enabling the creation of efficient and maintainable server applications.
Installation
npx skills add https://github.com/mcollina/skills --skill fastify-best-practices
스킬 파일
SKILL.md
최근 동기화 · 2026. 8. 29.
rules/authentication.md›
---
name: authentication
description: Authentication and authorization patterns in Fastify
metadata:
tags: auth, jwt, session, oauth, security, authorization
---
# Authentication and Authorization
## Contents
- [JWT Authentication with @fastify/jwt](#jwt-authentication-with-fastifyjwt)
- [Refresh Tokens](#refresh-tokens)
- [Role-Based Access Control](#role-based-access-control)
- [Permission-Based Authorization](#permission-based-authorization)
- [API Key / Bearer Token Authentication](#api-key--bearer-token-authentication)
- [OAuth 2.0 Integration](#oauth-20-integration)
- [Session-Based Authentication](#session-based-authentication)
- [Resource-Based Authorization](#resource-based-authorization)
- [Password Hashing](#password-hashing)
- [Rate Limiting for Auth Endpoints](#rate-limiting-for-auth-endpoints)
## JWT Authentication with @fastify/jwt
Use `@fastify/jwt` for JSON Web Token authentication:
```typescript
import Fastify from 'fastify';
import fastifyJwt from '@fastify/jwt';
const app = Fastify();
app.register(fastifyJwt, {
secret: process.env.JWT_SECRET,
sign: {
expiresIn: '1h',
},
});
// Decorate request with authentication method
app.decorate('authenticate', async function (request, reply) {
try {
await request.jwtVerify();
} catch (err) {
reply.code(401).send({ error: 'Unauthorized' });
}
});
// Login route
app.post('/login', {
schema: {
body: {
type: 'object',
properties: {
email: { type: 'string', format: 'email' },
password: { type: 'string' },
},
required: ['email', 'password'],
},
},
}, async (request, reply) => {
const { email, password } = request.body;
const user = await validateCredentials(email, password);
if (!user) {
return reply.code(401).send({ error: 'Invalid credentials' });
}
const token = app.jwt.sign({
id: user.id,
email: user.email,
role: user.role,
});
return { token };
});
// Protected route
app.get('/profile', {
onRequest: [app.authenticate],
}, async (request) => {
return { user: request.user };
});
```
## Refresh Tokens
Implement refresh token rotation:
```typescript
import fastifyJwt from '@fastify/jwt';
import { randomBytes } from 'node:crypto';
app.register(fastifyJwt, {
secret: process.env.JWT_SECRET,
sign: {
expiresIn: '15m', // Short-lived access tokens
},
});
// Store refresh tokens (use Redis in production)
const refreshTokens = new Map<string, { userId: string; expires: number }>();
app.post('/auth/login', async (request, reply) => {
const { email, password } = request.body;
const user = await validateCredentials(email, password);
if (!user) {
return reply.code(401).send({ error: 'Invalid credentials' });
}
const accessToken = app.jwt.sign({ id: user.id, role: user.role });
const refreshToken = randomBytes(32).toString('hex');
refreshTokens.set(refreshToken, {
userId: user.id,
expires: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 days
});
return { accessToken, refreshToken };
});
app.post('/auth/refresh', async (request, reply) => {
const { refreshToken } = request.body;
const stored = refreshTokens.get(refreshToken);
if (!stored || stored.expires < Date.now()) {
refreshTokens.delete(refreshToken);
return reply.code(401).send({ error: 'Invalid refresh token' });
}
// Delete old token (rotation)
refreshTokens.delete(refreshToken);
const user = await db.users.findById(stored.userId);
const accessToken = app.jwt.sign({ id: user.id, role: user.role });
const newRefreshToken = randomBytes(32).toString('hex');
refreshTokens.set(newRefreshToken, {
userId: user.id,
expires: Date.now() + 7 * 24 * 60 * 60 * 1000,
});
return { accessToken, refreshToken: newRefreshToken };
});
app.post('/auth/logout', async (request, reply) => {
const { refreshToken } = request.body;
refreshTokens.delete(refreshToken);
return { success: true };
});
```
## Role-Based Access Control
Implement RBAC with decorators:
```typescript
type Role = 'admin' | 'user' | 'moderator';
// Create authorization decorator
app.decorate('authorize', function (...allowedRoles: Role[]) {
return async (request, reply) => {
await request.jwtVerify();
const userRole = request.user.role as Role;
if (!allowedRoles.includes(userRole)) {
return reply.code(403).send({
error: 'Forbidden',
message: `Role '${userRole}' is not authorized for this resource`,
});
}
};
});
// Admin only route
app.get('/admin/users', {
onRequest: [app.authorize('admin')],
}, async (request) => {
return db.users.findAll();
});
// Admin or moderator
app.delete('/posts/:id', {
onRequest: [app.authorize('admin', 'moderator')],
}, async (request) => {
await db.posts.delete(request.params.id);
return { deleted: true };
});
```
## Permission-Based Authorization
Fine-grained permission checks:
```typescript
interface Permission {
resource: string;
action: 'create' | 'read' | 'update' | 'delete';
}
const rolePermissions: Record<string, Permission[]> = {
admin: [
{ resource: '*', action: 'create' },
{ resource: '*', action: 'read' },
{ resource: '*', action: 'update' },
{ resource: '*', action: 'delete' },
],
user: [
{ resource: 'posts', action: 'create' },
{ resource: 'posts', action: 'read' },
{ resource: 'comments', action: 'create' },
{ resource: 'comments', action: 'read' },
],
};
function hasPermission(role: string, resource: string, action: string): boolean {
const permissions = rolePermissions[role] || [];
return permissions.some(
(p) =>
(p.resource === '*' || p.resource === resource) &&
p.action === action
);
}
app.decorate('checkPermission', function (resource: string, action: string) {
return async (request, reply) => {
await request.jwtVerify();
if (!hasPermission(request.user.role, resource, action)) {
return reply.code(403).send({
error: 'Forbidden',
message: `Not allowed to ${action} ${resource}`,
});
}
};
});
// Usage
app.post('/posts', {
onRequest: [app.checkPermission('posts', 'create')],
}, createPostHandler);
app.delete('/posts/:id', {
onRequest: [app.checkPermission('posts', 'delete')],
}, deletePostHandler);
```
## API Key / Bearer Token Authentication
Use `@fastify/bearer-auth` for API key and bearer token authentication:
```typescript
import bearerAuth from '@fastify/bearer-auth';
const validKeys = new Set([process.env.API_KEY]);
app.register(bearerAuth, {
keys: validKeys,
errorResponse: (err) => ({
error: 'Unauthorized',
message: 'Invalid API key',
}),
});
// All routes are now protected
app.get('/api/data', async (request) => {
return { data: [] };
});
```
For database-backed API keys with custom validation:
```typescript
import bearerAuth from '@fastify/bearer-auth';
app.register(bearerAuth, {
auth: async (key, request) => {
const apiKey = await db.apiKeys.findByKey(key);
if (!apiKey || !apiKey.active) {
return false;
}
// Track usage (fire and forget)
db.apiKeys.recordUsage(apiKey.id, {
ip: request.ip,
timestamp: new Date(),
});
request.apiKey = apiKey;
return true;
},
errorResponse: (err) => ({
error: 'Unauthorized',
message: 'Invalid API key',
}),
});
```
## OAuth 2.0 Integration
Integrate with OAuth providers using @fastify/oauth2:
```typescript
import fastifyOauth2 from '@fastify/oauth2';
app.register(fastifyOauth2, {
name: 'googleOAuth2',
scope: ['profile', 'email'],
credentials: {
client: {
id: process.env.GOOGLE_CLIENT_ID,
secret: process.env.GOOGLE_CLIENT_SECRET,
},
},
startRedirectPath: '/auth/google',
callbackUri: 'http://localhost:3000/auth/google/callback',
discovery: {
issuer: 'https://accounts.google.com',
},
});
app.get('/auth/google/callback', async (request, reply) => {
const { token } = await app.googleOAuth2.getAccessTokenFromAuthorizationCodeFlow(request);
// Fetch user info from Google
const userInfo = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: { Authorization: `Bearer ${token.access_token}` },
}).then((r) => r.json());
// Find or create user
let user = await db.users.findByEmail(userInfo.email);
if (!user) {
user = await db.users.create({
email: userInfo.email,
name: userInfo.name,
provider: 'google',
providerId: userInfo.id,
});
}
// Generate JWT
const jwt = app.jwt.sign({ id: user.id, role: user.role });
// Redirect to frontend with token
return reply.redirect(`/auth/success?token=${jwt}`);
});
```
## Session-Based Authentication
Use @fastify/session for session management:
```typescript
import fastifyCookie from '@fastify/cookie';
import fastifySession from '@fastify/session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
app.register(fastifyCookie);
app.register(fastifySession, {
secret: process.env.SESSION_SECRET,
store: new RedisStore({ client: redisClient }),
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000, // 1 day
},
});
app.post('/login', async (request, reply) => {
const { email, password } = request.body;
const user = await validateCredentials(email, password);
if (!user) {
return reply.code(401).send({ error: 'Invalid credentials' });
}
request.session.userId = user.id;
request.session.role = user.role;
return { success: true };
});
app.decorate('requireSession', async function (request, reply) {
if (!request.session.userId) {
return reply.code(401).send({ error: 'Not authenticated' });
}
});
app.get('/profile', {
onRequest: [app.requireSession],
}, async (request) => {
const user = await db.users.findById(request.session.userId);
return { user };
});
app.post('/logout', async (request, reply) => {
await request.session.destroy();
return { success: true };
});
```
## Resource-Based Authorization
Check ownership of resources:
```typescript
app.decorate('checkOwnership', function (getResourceOwnerId: (request) => Promise<string>) {
return async (request, reply) => {
const ownerId = await getResourceOwnerId(request);
if (ownerId !== request.user.id && request.user.role !== 'admin') {
return reply.code(403).send({
error: 'Forbidden',
message: 'You do not own this resource',
});
}
};
});
// Check post ownership
app.put('/posts/:id', {
onRequest: [
app.authenticate,
app.checkOwnership(async (request) => {
const post = await db.posts.findById(request.params.id);
return post?.authorId;
}),
],
}, updatePostHandler);
// Alternative: inline check
app.put('/posts/:id', {
onRequest: [app.authenticate],
}, async (request, reply) => {
const post = await db.posts.findById(request.params.id);
if (!post) {
return reply.code(404).send({ error: 'Post not found' });
}
if (post.authorId !== request.user.id && request.user.role !== 'admin') {
return reply.code(403).send({ error: 'Forbidden' });
}
return db.posts.update(post.id, request.body);
});
```
## Password Hashing
Use secure password hashing with argon2:
```typescript
import { hash, verify } from '@node-rs/argon2';
async function hashPassword(password: string): Promise<string> {
return hash(password, {
memoryCost: 65536,
timeCost: 3,
parallelism: 4,
});
}
async function verifyPassword(hash: string, password: string): Promise<boolean> {
return verify(hash, password);
}
app.post('/register', async (request, reply) => {
const { email, password } = request.body;
const hashedPassword = await hashPassword(password);
const user = await db.users.create({
email,
password: hashedPassword,
});
reply.code(201);
return { id: user.id, email: user.email };
});
app.post('/login', async (request, reply) => {
const { email, password } = request.body;
const user = await db.users.findByEmail(email);
if (!user || !(await verifyPassword(user.password, password))) {
return reply.code(401).send({ error: 'Invalid credentials' });
}
const token = app.jwt.sign({ id: user.id, role: user.role });
return { token };
});
```
## Rate Limiting for Auth Endpoints
Protect auth endpoints from brute force. **IMPORTANT: For production security, you MUST configure rate limiting with a Redis backend.** In-memory rate limiting is not safe for distributed deployments and can be bypassed.
```typescript
import fastifyRateLimit from '@fastify/rate-limit';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
// Global rate limit with Redis backend
app.register(fastifyRateLimit, {
max: 100,
timeWindow: '1 minute',
redis, // REQUIRED for production - ensures rate limiting works across all instances
});
// Stricter limit for auth endpoints
app.register(async function authRoutes(fastify) {
await fastify.register(fastifyRateLimit, {
max: 5,
timeWindow: '1 minute',
redis, // REQUIRED for production
keyGenerator: (request) => {
// Rate limit by IP + email combination
const email = request.body?.email || '';
return `${request.ip}:${email}`;
},
});
fastify.post('/login', loginHandler);
fastify.post('/register', registerHandler);
fastify.post('/forgot-password', forgotPasswordHandler);
}, { prefix: '/auth' });
```
rules/configuration.md›
---
name: configuration
description: Application configuration in Fastify using env-schema
metadata:
tags: configuration, environment, env, settings, env-schema
---
# Application Configuration
## Contents
- [Use env-schema for Configuration](#use-env-schema-for-configuration)
- [Configuration as Plugin](#configuration-as-plugin)
- [Secrets Management](#secrets-management)
- [Feature Flags](#feature-flags)
- [Anti-Patterns to Avoid](#anti-patterns-to-avoid)
- [Dynamic Configuration](#dynamic-configuration)
## Use env-schema for Configuration
**Always use `env-schema` for configuration validation.** It provides JSON Schema validation for environment variables with sensible defaults.
```typescript
import Fastify from 'fastify';
import envSchema from 'env-schema';
import { Type, type Static } from '@sinclair/typebox';
const schema = Type.Object({
PORT: Type.Number({ default: 3000 }),
HOST: Type.String({ default: '0.0.0.0' }),
DATABASE_URL: Type.String(),
JWT_SECRET: Type.String({ minLength: 32 }),
LOG_LEVEL: Type.Union([
Type.Literal('trace'),
Type.Literal('debug'),
Type.Literal('info'),
Type.Literal('warn'),
Type.Literal('error'),
Type.Literal('fatal'),
], { default: 'info' }),
});
type Config = Static<typeof schema>;
const config = envSchema<Config>({
schema,
dotenv: true, // Load from .env file
});
const app = Fastify({
logger: { level: config.LOG_LEVEL },
});
app.decorate('config', config);
declare module 'fastify' {
interface FastifyInstance {
config: Config;
}
}
await app.listen({ port: config.PORT, host: config.HOST });
```
## Configuration as Plugin
Encapsulate configuration in a plugin for reuse:
```typescript
import fp from 'fastify-plugin';
import envSchema from 'env-schema';
import { Type, type Static } from '@sinclair/typebox';
const schema = Type.Object({
PORT: Type.Number({ default: 3000 }),
HOST: Type.String({ default: '0.0.0.0' }),
DATABASE_URL: Type.String(),
JWT_SECRET: Type.String({ minLength: 32 }),
LOG_LEVEL: Type.String({ default: 'info' }),
});
type Config = Static<typeof schema>;
declare module 'fastify' {
interface FastifyInstance {
config: Config;
}
}
export default fp(async function configPlugin(fastify) {
const config = envSchema<Config>({
schema,
dotenv: true,
});
fastify.decorate('config', config);
}, {
name: 'config',
});
```
## Secrets Management
Handle secrets securely:
```typescript
// Never log secrets
const app = Fastify({
logger: {
level: config.LOG_LEVEL,
redact: ['req.headers.authorization', '*.password', '*.secret', '*.apiKey'],
},
});
// For production, use secret managers (AWS Secrets Manager, Vault, etc.)
// Pass secrets through environment variables - never commit them
```
## Feature Flags
Implement feature flags via environment variables:
```typescript
import { Type, type Static } from '@sinclair/typebox';
const schema = Type.Object({
// ... other config
FEATURE_NEW_DASHBOARD: Type.Boolean({ default: false }),
FEATURE_BETA_API: Type.Boolean({ default: false }),
});
type Config = Static<typeof schema>;
const config = envSchema<Config>({ schema, dotenv: true });
// Use in routes
app.get('/dashboard', async (request) => {
if (app.config.FEATURE_NEW_DASHBOARD) {
return { version: 'v2', data: await getNewDashboardData() };
}
return { version: 'v1', data: await getOldDashboardData() };
});
```
## Anti-Patterns to Avoid
### NEVER use configuration files
```typescript
// ❌ NEVER DO THIS - configuration files are an antipattern
import config from './config/production.json';
// ❌ NEVER DO THIS - per-environment config files
const env = process.env.NODE_ENV || 'development';
const config = await import(`./config/${env}.js`);
```
Configuration files lead to:
- Security risks (secrets in files)
- Deployment complexity
- Environment drift
- Difficult secret rotation
### NEVER use per-environment configuration
```typescript
// ❌ NEVER DO THIS
const configs = {
development: { logLevel: 'debug' },
production: { logLevel: 'info' },
test: { logLevel: 'silent' },
};
const config = configs[process.env.NODE_ENV];
```
Instead, use a single configuration source (environment variables) with sensible defaults. The environment controls the values, not conditional code.
### Use specific environment variables, not NODE_ENV
```typescript
// ❌ AVOID checking NODE_ENV
if (process.env.NODE_ENV === 'production') {
// do something
}
// ✅ BETTER - use explicit feature flags or configuration
if (app.config.ENABLE_DETAILED_LOGGING) {
// do something
}
```
## Dynamic Configuration
For configuration that needs to change without restart, fetch from an external service:
```typescript
interface DynamicConfig {
rateLimit: number;
maintenanceMode: boolean;
}
let dynamicConfig: DynamicConfig = {
rateLimit: 100,
maintenanceMode: false,
};
async function refreshConfig() {
try {
const newConfig = await fetchConfigFromService();
dynamicConfig = newConfig;
app.log.info('Configuration refreshed');
} catch (error) {
app.log.error({ err: error }, 'Failed to refresh configuration');
}
}
// Refresh periodically
setInterval(refreshConfig, 60000);
// Use in hooks
app.addHook('onRequest', async (request, reply) => {
if (dynamicConfig.maintenanceMode && !request.url.startsWith('/health')) {
reply.code(503).send({ error: 'Service under maintenance' });
}
});
```
rules/content-type.md›
---
name: content-type
description: Content type parsing in Fastify
metadata:
tags: content-type, parsing, body, multipart, json
---
# Content Type Parsing
## Contents
- [Default Content Type Parsers](#default-content-type-parsers)
- [Custom Content Type Parsers](#custom-content-type-parsers)
- [XML Parsing](#xml-parsing)
- [Multipart Form Data](#multipart-form-data)
- [Stream Processing](#stream-processing)
- [Custom JSON Parser](#custom-json-parser)
- [Content Type with Parameters](#content-type-with-parameters)
- [Catch-All Parser](#catch-all-parser)
- [Body Limit Configuration](#body-limit-configuration)
- [Protocol Buffers](#protocol-buffers)
- [Form Data with @fastify/formbody](#form-data-with-fastifyformbody)
- [Content Negotiation](#content-negotiation)
- [Validation After Parsing](#validation-after-parsing)
## Default Content Type Parsers
Fastify includes parsers for common content types:
```typescript
import Fastify from 'fastify';
const app = Fastify();
// Built-in parsers:
// - application/json
// - text/plain
app.post('/json', async (request) => {
// request.body is parsed JSON object
return { received: request.body };
});
app.post('/text', async (request) => {
// request.body is string for text/plain
return { text: request.body };
});
```
## Custom Content Type Parsers
Add parsers for additional content types:
```typescript
// Parse application/x-www-form-urlencoded
app.addContentTypeParser(
'application/x-www-form-urlencoded',
{ parseAs: 'string' },
(request, body, done) => {
const parsed = new URLSearchParams(body);
done(null, Object.fromEntries(parsed));
},
);
// Async parser
app.addContentTypeParser(
'application/x-www-form-urlencoded',
{ parseAs: 'string' },
async (request, body) => {
const parsed = new URLSearchParams(body);
return Object.fromEntries(parsed);
},
);
```
## XML Parsing
Parse XML content:
```typescript
import { XMLParser } from 'fast-xml-parser';
const xmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
});
app.addContentTypeParser(
'application/xml',
{ parseAs: 'string' },
async (request, body) => {
return xmlParser.parse(body);
},
);
app.addContentTypeParser(
'text/xml',
{ parseAs: 'string' },
async (request, body) => {
return xmlParser.parse(body);
},
);
app.post('/xml', async (request) => {
// request.body is parsed XML as JavaScript object
return { data: request.body };
});
```
## Multipart Form Data
Use @fastify/multipart for file uploads. **Configure these critical options:**
```typescript
import fastifyMultipart from '@fastify/multipart';
app.register(fastifyMultipart, {
// CRITICAL: Always set explicit limits
limits: {
fieldNameSize: 100, // Max field name size in bytes
fieldSize: 1024 * 1024, // Max field value size (1MB)
fields: 10, // Max number of non-file fields
fileSize: 10 * 1024 * 1024, // Max file size (10MB)
files: 5, // Max number of files
headerPairs: 2000, // Max number of header pairs
parts: 1000, // Max number of parts (fields + files)
},
// IMPORTANT: Throw on limit exceeded (default is to truncate silently!)
throwFileSizeLimit: true,
// Attach all fields to request.body for easier access
attachFieldsToBody: true,
// Only accept specific file types (security!)
// onFile: async (part) => {
// if (!['image/jpeg', 'image/png'].includes(part.mimetype)) {
// throw new Error('Invalid file type');
// }
// },
});
// Handle file upload
app.post('/upload', async (request, reply) => {
const data = await request.file();
if (!data) {
return reply.code(400).send({ error: 'No file uploaded' });
}
// data.file is a stream
const buffer = await data.toBuffer();
return {
filename: data.filename,
mimetype: data.mimetype,
size: buffer.length,
};
});
// Handle multiple files
app.post('/upload-multiple', async (request) => {
const files = [];
for await (const part of request.files()) {
const buffer = await part.toBuffer();
files.push({
filename: part.filename,
mimetype: part.mimetype,
size: buffer.length,
});
}
return { files };
});
// Handle mixed form data
app.post('/form', async (request) => {
const parts = request.parts();
const fields: Record<string, string> = {};
const files: Array<{ name: string; size: number }> = [];
for await (const part of parts) {
if (part.type === 'file') {
const buffer = await part.toBuffer();
files.push({ name: part.filename, size: buffer.length });
} else {
fields[part.fieldname] = part.value as string;
}
}
return { fields, files };
});
```
## Stream Processing
Process body as stream for large payloads:
```typescript
import { pipeline } from 'node:stream/promises';
import { createWriteStream } from 'node:fs';
// Add parser that returns stream
app.addContentTypeParser(
'application/octet-stream',
async (request, payload) => {
return payload; // Return stream directly
},
);
app.post('/upload-stream', async (request, reply) => {
const destination = createWriteStream('./upload.bin');
await pipeline(request.body, destination);
return { success: true };
});
```
## Custom JSON Parser
Replace the default JSON parser:
```typescript
// Remove default parser
app.removeContentTypeParser('application/json');
// Add custom parser with error handling
app.addContentTypeParser(
'application/json',
{ parseAs: 'string' },
async (request, body) => {
try {
return JSON.parse(body);
} catch (error) {
throw {
statusCode: 400,
code: 'INVALID_JSON',
message: 'Invalid JSON payload',
};
}
},
);
```
## Content Type with Parameters
Handle content types with parameters:
```typescript
// Match content type with any charset
app.addContentTypeParser(
'application/json; charset=utf-8',
{ parseAs: 'string' },
async (request, body) => {
return JSON.parse(body);
},
);
// Use regex for flexible matching
app.addContentTypeParser(
/^application\/.*\+json$/,
{ parseAs: 'string' },
async (request, body) => {
return JSON.parse(body);
},
);
```
## Catch-All Parser
Handle unknown content types:
```typescript
app.addContentTypeParser('*', async (request, payload) => {
const chunks: Buffer[] = [];
for await (const chunk of payload) {
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
// Try to determine content type
const contentType = request.headers['content-type'];
if (contentType?.includes('json')) {
return JSON.parse(buffer.toString('utf-8'));
}
if (contentType?.includes('text')) {
return buffer.toString('utf-8');
}
return buffer;
});
```
## Body Limit Configuration
Configure body size limits:
```typescript
// Global limit
const app = Fastify({
bodyLimit: 1048576, // 1MB
});
// Per-route limit
app.post('/large-upload', {
bodyLimit: 52428800, // 50MB for this route
}, async (request) => {
return { size: JSON.stringify(request.body).length };
});
// Per content type limit
app.addContentTypeParser('application/json', {
parseAs: 'string',
bodyLimit: 2097152, // 2MB for JSON
}, async (request, body) => {
return JSON.parse(body);
});
```
## Protocol Buffers
Parse protobuf content:
```typescript
import protobuf from 'protobufjs';
const root = await protobuf.load('./schema.proto');
const MessageType = root.lookupType('package.MessageType');
app.addContentTypeParser(
'application/x-protobuf',
{ parseAs: 'buffer' },
async (request, body) => {
const message = MessageType.decode(body);
return MessageType.toObject(message);
},
);
```
## Form Data with @fastify/formbody
Simple form parsing:
```typescript
import formbody from '@fastify/formbody';
app.register(formbody);
app.post('/form', async (request) => {
// request.body is parsed form data
const { name, email } = request.body as { name: string; email: string };
return { name, email };
});
```
## Content Negotiation
Handle different request formats:
```typescript
app.post('/data', async (request, reply) => {
const contentType = request.headers['content-type'];
// Body is already parsed by the appropriate parser
const data = request.body;
// Respond based on Accept header
const accept = request.headers.accept;
if (accept?.includes('application/xml')) {
reply.type('application/xml');
return `<data>${JSON.stringify(data)}</data>`;
}
reply.type('application/json');
return data;
});
```
## Validation After Parsing
Validate parsed content:
```typescript
app.post('/users', {
schema: {
body: {
type: 'object',
properties: {
name: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email' },
},
required: ['name', 'email'],
},
},
}, async (request) => {
// Body is parsed AND validated
return request.body;
});
```
rules/cors-security.md›
---
name: cors-security
description: CORS and security headers in Fastify
metadata:
tags: cors, security, headers, helmet, csrf
---
# CORS and Security
## Contents
- [CORS with @fastify/cors](#cors-with-fastifycors)
- [Dynamic CORS Origin](#dynamic-cors-origin)
- [Per-Route CORS](#per-route-cors)
- [Security Headers with @fastify/helmet](#security-headers-with-fastifyhelmet)
- [Configure Individual Headers](#configure-individual-headers)
- [Rate Limiting](#rate-limiting)
- [Redis-Based Rate Limiting](#redis-based-rate-limiting)
- [CSRF Protection](#csrf-protection)
- [Custom Security Headers](#custom-security-headers)
- [Secure Cookies](#secure-cookies)
- [Request Validation Security](#request-validation-security)
- [IP Filtering](#ip-filtering)
- [Trust Proxy](#trust-proxy)
- [HTTPS Redirect](#https-redirect)
- [Security Best Practices Summary](#security-best-practices-summary)
## CORS with @fastify/cors
Enable Cross-Origin Resource Sharing:
```typescript
import Fastify from 'fastify';
import cors from '@fastify/cors';
const app = Fastify();
// Simple CORS - allow all origins
app.register(cors);
// Configured CORS
app.register(cors, {
origin: ['https://example.com', 'https://app.example.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: ['X-Total-Count'],
credentials: true,
maxAge: 86400, // 24 hours
});
```
## Dynamic CORS Origin
Validate origins dynamically:
```typescript
app.register(cors, {
origin: (origin, callback) => {
// Allow requests with no origin (mobile apps, curl, etc.)
if (!origin) {
return callback(null, true);
}
// Check against allowed origins
const allowedOrigins = [
'https://example.com',
'https://app.example.com',
/\.example\.com$/,
];
const isAllowed = allowedOrigins.some((allowed) => {
if (allowed instanceof RegExp) {
return allowed.test(origin);
}
return allowed === origin;
});
if (isAllowed) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'), false);
}
},
credentials: true,
});
```
## Per-Route CORS
Configure CORS for specific routes:
```typescript
app.register(cors, {
origin: true, // Reflect request origin
credentials: true,
});
// Or disable CORS for specific routes
app.route({
method: 'GET',
url: '/internal',
config: {
cors: false,
},
handler: async () => {
return { internal: true };
},
});
```
## Security Headers with @fastify/helmet
Add security headers:
```typescript
import helmet from '@fastify/helmet';
app.register(helmet, {
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'", 'https://api.example.com'],
},
},
crossOriginEmbedderPolicy: false, // Disable if embedding external resources
});
```
## Configure Individual Headers
Fine-tune security headers:
```typescript
app.register(helmet, {
// Strict Transport Security
hsts: {
maxAge: 31536000, // 1 year
includeSubDomains: true,
preload: true,
},
// Content Security Policy
contentSecurityPolicy: {
useDefaults: true,
directives: {
'script-src': ["'self'", 'https://trusted-cdn.com'],
},
},
// X-Frame-Options
frameguard: {
action: 'deny', // or 'sameorigin'
},
// X-Content-Type-Options
noSniff: true,
// X-XSS-Protection (legacy)
xssFilter: true,
// Referrer-Policy
referrerPolicy: {
policy: 'strict-origin-when-cross-origin',
},
// X-Permitted-Cross-Domain-Policies
permittedCrossDomainPolicies: false,
// X-DNS-Prefetch-Control
dnsPrefetchControl: {
allow: false,
},
});
```
## Rate Limiting
Protect against abuse:
```typescript
import rateLimit from '@fastify/rate-limit';
app.register(rateLimit, {
max: 100,
timeWindow: '1 minute',
errorResponseBuilder: (request, context) => ({
statusCode: 429,
error: 'Too Many Requests',
message: `Rate limit exceeded. Retry in ${context.after}`,
retryAfter: context.after,
}),
});
// Per-route rate limit
app.get('/expensive', {
config: {
rateLimit: {
max: 10,
timeWindow: '1 minute',
},
},
}, handler);
// Skip rate limit for certain routes
app.get('/health', {
config: {
rateLimit: false,
},
}, () => ({ status: 'ok' }));
```
## Redis-Based Rate Limiting
Use Redis for distributed rate limiting:
```typescript
import rateLimit from '@fastify/rate-limit';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
app.register(rateLimit, {
max: 100,
timeWindow: '1 minute',
redis,
nameSpace: 'rate-limit:',
keyGenerator: (request) => {
// Rate limit by user ID if authenticated, otherwise by IP
return request.user?.id || request.ip;
},
});
```
## CSRF Protection
Protect against Cross-Site Request Forgery:
```typescript
import fastifyCsrf from '@fastify/csrf-protection';
import fastifyCookie from '@fastify/cookie';
app.register(fastifyCookie);
app.register(fastifyCsrf, {
cookieOpts: {
signed: true,
httpOnly: true,
sameSite: 'strict',
},
});
// Generate token
app.get('/csrf-token', async (request, reply) => {
const token = reply.generateCsrf();
return { token };
});
// Protected route
app.post('/transfer', {
preHandler: app.csrfProtection,
}, async (request) => {
// CSRF token validated
return { success: true };
});
```
## Custom Security Headers
Add custom headers:
```typescript
app.addHook('onSend', async (request, reply) => {
// Custom security headers
reply.header('X-Request-ID', request.id);
reply.header('X-Content-Type-Options', 'nosniff');
reply.header('X-Frame-Options', 'DENY');
reply.header('Permissions-Policy', 'geolocation=(), camera=()');
});
// Per-route headers
app.get('/download', async (request, reply) => {
reply.header('Content-Disposition', 'attachment; filename="file.pdf"');
reply.header('X-Download-Options', 'noopen');
return reply.send(fileStream);
});
```
## Secure Cookies
Configure secure cookies:
```typescript
import cookie from '@fastify/cookie';
app.register(cookie, {
secret: process.env.COOKIE_SECRET,
parseOptions: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
maxAge: 3600, // 1 hour
},
});
// Set secure cookie
app.post('/login', async (request, reply) => {
const token = await createSession(request.body);
reply.setCookie('session', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/',
maxAge: 86400,
signed: true,
});
return { success: true };
});
// Read signed cookie
app.get('/profile', async (request) => {
const session = request.cookies.session;
const unsigned = request.unsignCookie(session);
if (!unsigned.valid) {
throw { statusCode: 401, message: 'Invalid session' };
}
return { sessionId: unsigned.value };
});
```
## Request Validation Security
Validate and sanitize input:
```typescript
// Schema-based validation protects against injection
app.post('/users', {
schema: {
body: {
type: 'object',
properties: {
email: {
type: 'string',
format: 'email',
maxLength: 254,
},
name: {
type: 'string',
minLength: 1,
maxLength: 100,
pattern: '^[a-zA-Z\\s]+$', // Only letters and spaces
},
},
required: ['email', 'name'],
additionalProperties: false,
},
},
}, handler);
```
## IP Filtering
Restrict access by IP:
```typescript
const allowedIps = new Set([
'192.168.1.0/24',
'10.0.0.0/8',
]);
app.addHook('onRequest', async (request, reply) => {
if (request.url.startsWith('/admin')) {
const clientIp = request.ip;
if (!isIpAllowed(clientIp, allowedIps)) {
reply.code(403).send({ error: 'Forbidden' });
}
}
});
function isIpAllowed(ip: string, allowed: Set<string>): boolean {
// Implement IP/CIDR matching
for (const range of allowed) {
if (ipInRange(ip, range)) return true;
}
return false;
}
```
## Trust Proxy
Configure for reverse proxy environments:
```typescript
const app = Fastify({
trustProxy: true, // Trust X-Forwarded-* headers
});
// Or specific proxy configuration
const app = Fastify({
trustProxy: ['127.0.0.1', '10.0.0.0/8'],
});
// Now request.ip returns the real client IP
app.get('/ip', async (request) => {
return {
ip: request.ip,
ips: request.ips, // Array of all IPs in chain
};
});
```
## HTTPS Redirect
Force HTTPS in production:
```typescript
app.addHook('onRequest', async (request, reply) => {
if (
process.env.NODE_ENV === 'production' &&
request.headers['x-forwarded-proto'] !== 'https'
) {
const httpsUrl = `https://${request.hostname}${request.url}`;
reply.redirect(301, httpsUrl);
}
});
```
## Security Best Practices Summary
```typescript
import Fastify from 'fastify';
import cors from '@fastify/cors';
import helmet from '@fastify/helmet';
import rateLimit from '@fastify/rate-limit';
const app = Fastify({
trustProxy: true,
bodyLimit: 1048576, // 1MB max body
});
// Security plugins
app.register(helmet);
app.register(cors, {
origin: process.env.ALLOWED_ORIGINS?.split(','),
credentials: true,
});
app.register(rateLimit, {
max: 100,
timeWindow: '1 minute',
});
// Validate all input with schemas
// Never expose internal errors in production
// Use parameterized queries for database
// Keep dependencies updated
```
rules/database.md›
---
name: database
description: Database integration with Fastify using official adapters
metadata:
tags: database, postgres, mysql, mongodb, redis, sql
---
# Database Integration
## Contents
- [Use Official Fastify Database Adapters](#use-official-fastify-database-adapters)
- [PostgreSQL with @fastify/postgres](#postgresql-with-fastifypostgres)
- [MySQL with @fastify/mysql](#mysql-with-fastifymysql)
- [MongoDB with @fastify/mongodb](#mongodb-with-fastifymongodb)
- [Redis with @fastify/redis](#redis-with-fastifyredis)
- [Database as Plugin](#database-as-plugin)
- [Repository Pattern](#repository-pattern)
- [Testing with Database](#testing-with-database)
- [Connection Pool Configuration](#connection-pool-configuration)
## Use Official Fastify Database Adapters
Always use the official Fastify database plugins from the `@fastify` organization. They provide proper connection pooling, encapsulation, and integration with Fastify's lifecycle.
## PostgreSQL with @fastify/postgres
```typescript
import Fastify from 'fastify';
import fastifyPostgres from '@fastify/postgres';
const app = Fastify({ logger: true });
app.register(fastifyPostgres, {
connectionString: process.env.DATABASE_URL,
});
// Use in routes
app.get('/users', async (request) => {
const client = await app.pg.connect();
try {
const { rows } = await client.query('SELECT * FROM users');
return rows;
} finally {
client.release();
}
});
// Or use the pool directly for simple queries
app.get('/users/:id', async (request) => {
const { id } = request.params;
const { rows } = await app.pg.query(
'SELECT * FROM users WHERE id = $1',
[id],
);
return rows[0];
});
// Transactions
app.post('/transfer', async (request) => {
const { fromId, toId, amount } = request.body;
const client = await app.pg.connect();
try {
await client.query('BEGIN');
await client.query(
'UPDATE accounts SET balance = balance - $1 WHERE id = $2',
[amount, fromId],
);
await client.query(
'UPDATE accounts SET balance = balance + $1 WHERE id = $2',
[amount, toId],
);
await client.query('COMMIT');
return { success: true };
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
});
```
## MySQL with @fastify/mysql
```typescript
import Fastify from 'fastify';
import fastifyMysql from '@fastify/mysql';
const app = Fastify({ logger: true });
app.register(fastifyMysql, {
promise: true,
connectionString: process.env.MYSQL_URL,
});
app.get('/users', async (request) => {
const connection = await app.mysql.getConnection();
try {
const [rows] = await connection.query('SELECT * FROM users');
return rows;
} finally {
connection.release();
}
});
```
## MongoDB with @fastify/mongodb
```typescript
import Fastify from 'fastify';
import fastifyMongo from '@fastify/mongodb';
const app = Fastify({ logger: true });
app.register(fastifyMongo, {
url: process.env.MONGODB_URL,
});
app.get('/users', async (request) => {
const users = await app.mongo.db
.collection('users')
.find({})
.toArray();
return users;
});
app.get('/users/:id', async (request) => {
const { id } = request.params;
const user = await app.mongo.db
.collection('users')
.findOne({ _id: new app.mongo.ObjectId(id) });
return user;
});
app.post('/users', async (request) => {
const result = await app.mongo.db
.collection('users')
.insertOne(request.body);
return { id: result.insertedId };
});
```
## Redis with @fastify/redis
```typescript
import Fastify from 'fastify';
import fastifyRedis from '@fastify/redis';
const app = Fastify({ logger: true });
app.register(fastifyRedis, {
url: process.env.REDIS_URL,
});
// Caching example
app.get('/data/:key', async (request) => {
const { key } = request.params;
// Try cache first
const cached = await app.redis.get(`cache:${key}`);
if (cached) {
return JSON.parse(cached);
}
// Fetch from database
const data = await fetchFromDatabase(key);
// Cache for 5 minutes
await app.redis.setex(`cache:${key}`, 300, JSON.stringify(data));
return data;
});
```
## Database as Plugin
Encapsulate database access in a plugin:
```typescript
// plugins/database.ts
import fp from 'fastify-plugin';
import fastifyPostgres from '@fastify/postgres';
export default fp(async function databasePlugin(fastify) {
await fastify.register(fastifyPostgres, {
connectionString: fastify.config.DATABASE_URL,
});
// Add health check
fastify.decorate('checkDatabaseHealth', async () => {
try {
await fastify.pg.query('SELECT 1');
return true;
} catch {
return false;
}
});
}, {
name: 'database',
dependencies: ['config'],
});
```
## Repository Pattern
Abstract database access with repositories:
```typescript
// repositories/user.repository.ts
import type { FastifyInstance } from 'fastify';
export interface User {
id: string;
email: string;
name: string;
}
export function createUserRepository(app: FastifyInstance) {
return {
async findById(id: string): Promise<User | null> {
const { rows } = await app.pg.query(
'SELECT * FROM users WHERE id = $1',
[id],
);
return rows[0] || null;
},
async findByEmail(email: string): Promise<User | null> {
const { rows } = await app.pg.query(
'SELECT * FROM users WHERE email = $1',
[email],
);
return rows[0] || null;
},
async create(data: Omit<User, 'id'>): Promise<User> {
const { rows } = await app.pg.query(
'INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *',
[data.email, data.name],
);
return rows[0];
},
async update(id: string, data: Partial<User>): Promise<User | null> {
const fields = Object.keys(data);
const values = Object.values(data);
const setClause = fields
.map((f, i) => `${f} = $${i + 2}`)
.join(', ');
const { rows } = await app.pg.query(
`UPDATE users SET ${setClause} WHERE id = $1 RETURNING *`,
[id, ...values],
);
return rows[0] || null;
},
async delete(id: string): Promise<boolean> {
const { rowCount } = await app.pg.query(
'DELETE FROM users WHERE id = $1',
[id],
);
return rowCount > 0;
},
};
}
// Usage in plugin
import fp from 'fastify-plugin';
import { createUserRepository } from './repositories/user.repository.js';
export default fp(async function repositoriesPlugin(fastify) {
fastify.decorate('repositories', {
users: createUserRepository(fastify),
});
}, {
name: 'repositories',
dependencies: ['database'],
});
```
## Testing with Database
Use transactions for test isolation:
```typescript
import { describe, it, beforeEach, afterEach } from 'node:test';
import { build } from './app.js';
describe('User API', () => {
let app;
let client;
beforeEach(async () => {
app = await build();
client = await app.pg.connect();
await client.query('BEGIN');
});
afterEach(async () => {
await client.query('ROLLBACK');
client.release();
await app.close();
});
it('should create a user', async (t) => {
const response = await app.inject({
method: 'POST',
url: '/users',
payload: { email: '[email protected]', name: 'Test' },
});
t.assert.equal(response.statusCode, 201);
});
});
```
## Connection Pool Configuration
Configure connection pools appropriately:
```typescript
app.register(fastifyPostgres, {
connectionString: process.env.DATABASE_URL,
// Pool configuration
max: 20, // Maximum pool size
idleTimeoutMillis: 30000, // Close idle clients after 30s
connectionTimeoutMillis: 5000, // Timeout for new connections
});
```
rules/decorators.md›
---
name: decorators
description: Decorators and request/reply extensions in Fastify
metadata:
tags: decorators, extensions, customization, utilities
---
# Decorators and Extensions
## Contents
- [Understanding Decorators](#understanding-decorators)
- [Decorator Types](#decorator-types)
- [TypeScript Declaration Merging](#typescript-declaration-merging)
- [Decorator Initialization](#decorator-initialization)
- [Dependency Injection with Decorators](#dependency-injection-with-decorators)
- [Request Context Pattern](#request-context-pattern)
- [Reply Helpers](#reply-helpers)
- [Checking Decorators](#checking-decorators)
- [Decorator Encapsulation](#decorator-encapsulation)
- [Functional Decorators](#functional-decorators)
- [Async Decorator Initialization](#async-decorator-initialization)
## Understanding Decorators
Decorators add custom properties and methods to Fastify instances, requests, and replies:
```typescript
import Fastify from 'fastify';
const app = Fastify();
// Decorate the Fastify instance
app.decorate('utility', {
formatDate: (date: Date) => date.toISOString(),
generateId: () => crypto.randomUUID(),
});
// Use in routes
app.get('/example', async function (request, reply) {
const id = this.utility.generateId();
return { id, timestamp: this.utility.formatDate(new Date()) };
});
```
## Decorator Types
Three types of decorators for different contexts:
```typescript
// Instance decorator - available on fastify instance
app.decorate('config', { apiVersion: '1.0.0' });
app.decorate('db', databaseConnection);
app.decorate('cache', cacheClient);
// Request decorator - available on each request
app.decorateRequest('user', null); // Object property
app.decorateRequest('startTime', 0); // Primitive
app.decorateRequest('getData', function() { // Method
return this.body;
});
// Reply decorator - available on each reply
app.decorateReply('sendError', function(code: number, message: string) {
return this.code(code).send({ error: message });
});
app.decorateReply('success', function(data: unknown) {
return this.send({ success: true, data });
});
```
## TypeScript Declaration Merging
Extend Fastify types for type safety:
```typescript
// Declare custom properties
declare module 'fastify' {
interface FastifyInstance {
config: {
apiVersion: string;
environment: string;
};
db: DatabaseClient;
cache: CacheClient;
}
interface FastifyRequest {
user: {
id: string;
email: string;
roles: string[];
} | null;
startTime: number;
requestId: string;
}
interface FastifyReply {
sendError: (code: number, message: string) => void;
success: (data: unknown) => void;
}
}
// Register decorators
app.decorate('config', {
apiVersion: '1.0.0',
environment: process.env.NODE_ENV,
});
app.decorateRequest('user', null);
app.decorateRequest('startTime', 0);
app.decorateReply('sendError', function (code: number, message: string) {
this.code(code).send({ error: message });
});
```
## Decorator Initialization
Initialize request/reply decorators in hooks:
```typescript
// Decorators with primitive defaults are copied
app.decorateRequest('startTime', 0);
// Initialize in hook
app.addHook('onRequest', async (request) => {
request.startTime = Date.now();
});
// Object decorators need getter pattern for proper initialization
app.decorateRequest('context', null);
app.addHook('onRequest', async (request) => {
request.context = {
traceId: request.headers['x-trace-id'] || crypto.randomUUID(),
clientIp: request.ip,
userAgent: request.headers['user-agent'],
};
});
```
## Dependency Injection with Decorators
Use decorators for dependency injection:
```typescript
import fp from 'fastify-plugin';
// Database plugin
export default fp(async function databasePlugin(fastify, options) {
const db = await createDatabaseConnection(options.connectionString);
fastify.decorate('db', db);
fastify.addHook('onClose', async () => {
await db.close();
});
});
// User service plugin
export default fp(async function userServicePlugin(fastify) {
// Depends on db decorator
if (!fastify.hasDecorator('db')) {
throw new Error('Database plugin must be registered first');
}
const userService = {
findById: (id: string) => fastify.db.query('SELECT * FROM users WHERE id = $1', [id]),
create: (data: CreateUserInput) => fastify.db.query(
'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *',
[data.name, data.email]
),
};
fastify.decorate('userService', userService);
}, {
dependencies: ['database-plugin'],
});
// Use in routes
app.get('/users/:id', async function (request) {
const user = await this.userService.findById(request.params.id);
return user;
});
```
## Request Context Pattern
Build rich request context:
```typescript
interface RequestContext {
traceId: string;
user: User | null;
permissions: Set<string>;
startTime: number;
metadata: Map<string, unknown>;
}
declare module 'fastify' {
interface FastifyRequest {
ctx: RequestContext;
}
}
app.decorateRequest('ctx', null);
app.addHook('onRequest', async (request) => {
request.ctx = {
traceId: request.headers['x-trace-id']?.toString() || crypto.randomUUID(),
user: null,
permissions: new Set(),
startTime: Date.now(),
metadata: new Map(),
};
});
// Auth hook populates user
app.addHook('preHandler', async (request) => {
const token = request.headers.authorization;
if (token) {
const user = await verifyToken(token);
request.ctx.user = user;
request.ctx.permissions = new Set(user.permissions);
}
});
// Use in handlers
app.get('/profile', async (request, reply) => {
if (!request.ctx.user) {
return reply.code(401).send({ error: 'Unauthorized' });
}
if (!request.ctx.permissions.has('read:profile')) {
return reply.code(403).send({ error: 'Forbidden' });
}
return request.ctx.user;
});
```
## Reply Helpers
Create consistent response methods:
```typescript
declare module 'fastify' {
interface FastifyReply {
ok: (data?: unknown) => void;
created: (data: unknown) => void;
noContent: () => void;
badRequest: (message: string, details?: unknown) => void;
unauthorized: (message?: string) => void;
forbidden: (message?: string) => void;
notFound: (resource?: string) => void;
conflict: (message: string) => void;
serverError: (message?: string) => void;
}
}
app.decorateReply('ok', function (data?: unknown) {
this.code(200).send(data ?? { success: true });
});
app.decorateReply('created', function (data: unknown) {
this.code(201).send(data);
});
app.decorateReply('noContent', function () {
this.code(204).send();
});
app.decorateReply('badRequest', function (message: string, details?: unknown) {
this.code(400).send({
statusCode: 400,
error: 'Bad Request',
message,
details,
});
});
app.decorateReply('unauthorized', function (message = 'Authentication required') {
this.code(401).send({
statusCode: 401,
error: 'Unauthorized',
message,
});
});
app.decorateReply('notFound', function (resource = 'Resource') {
this.code(404).send({
statusCode: 404,
error: 'Not Found',
message: `${resource} not found`,
});
});
// Usage
app.get('/users/:id', async (request, reply) => {
const user = await db.users.findById(request.params.id);
if (!user) {
return reply.notFound('User');
}
return reply.ok(user);
});
app.post('/users', async (request, reply) => {
const user = await db.users.create(request.body);
return reply.created(user);
});
```
## Checking Decorators
Check if decorators exist before using:
```typescript
// Check at registration time
app.register(async function (fastify) {
if (!fastify.hasDecorator('db')) {
throw new Error('Database decorator required');
}
if (!fastify.hasRequestDecorator('user')) {
throw new Error('User request decorator required');
}
if (!fastify.hasReplyDecorator('sendError')) {
throw new Error('sendError reply decorator required');
}
// Safe to use decorators
});
```
## Decorator Encapsulation
Decorators respect encapsulation by default:
```typescript
app.register(async function pluginA(fastify) {
fastify.decorate('pluginAUtil', () => 'A');
fastify.get('/a', async function () {
return this.pluginAUtil(); // Works
});
});
app.register(async function pluginB(fastify) {
// this.pluginAUtil is NOT available here (encapsulated)
fastify.get('/b', async function () {
// this.pluginAUtil() would be undefined
});
});
```
Use `fastify-plugin` to share decorators:
```typescript
import fp from 'fastify-plugin';
export default fp(async function sharedDecorator(fastify) {
fastify.decorate('sharedUtil', () => 'shared');
});
// Now available to parent and sibling plugins
```
## Functional Decorators
Create decorators that return functions:
```typescript
declare module 'fastify' {
interface FastifyInstance {
createValidator: <T>(schema: object) => (data: unknown) => T;
createRateLimiter: (options: RateLimitOptions) => RateLimiter;
}
}
app.decorate('createValidator', function <T>(schema: object) {
const validate = ajv.compile(schema);
return (data: unknown): T => {
if (!validate(data)) {
throw new ValidationError(validate.errors);
}
return data as T;
};
});
// Usage
const validateUser = app.createValidator<User>(userSchema);
app.post('/users', async (request) => {
const user = validateUser(request.body);
return db.users.create(user);
});
```
## Async Decorator Initialization
Handle async initialization properly:
```typescript
import fp from 'fastify-plugin';
export default fp(async function asyncPlugin(fastify) {
// Async initialization
const connection = await createAsyncConnection();
const cache = await initializeCache();
fastify.decorate('asyncService', {
connection,
cache,
query: async (sql: string) => connection.query(sql),
});
fastify.addHook('onClose', async () => {
await connection.close();
await cache.disconnect();
});
});
// Plugin is fully initialized before routes execute
app.get('/data', async function () {
return this.asyncService.query('SELECT * FROM data');
});
```
rules/deployment.md›
---
name: deployment
description: Production deployment for Fastify applications
metadata:
tags: deployment, production, docker, kubernetes, scaling
---
# Production Deployment
## Contents
- [Graceful Shutdown with close-with-grace](#graceful-shutdown-with-close-with-grace)
- [Health Check Endpoints](#health-check-endpoints)
- [Docker Configuration](#docker-configuration)
- [Kubernetes Deployment](#kubernetes-deployment)
- [Production Logger Configuration](#production-logger-configuration)
- [Request Timeouts](#request-timeouts)
- [Trust Proxy Settings](#trust-proxy-settings)
- [Static File Serving](#static-file-serving)
- [Compression](#compression)
- [Metrics and Monitoring](#metrics-and-monitoring)
- [Zero-Downtime Deployments](#zero-downtime-deployments)
## Graceful Shutdown with close-with-grace
Use `close-with-grace` for proper shutdown handling:
```typescript
import Fastify from 'fastify';
import closeWithGrace from 'close-with-grace';
const app = Fastify({ logger: true });
// Register plugins and routes
await app.register(import('./plugins/index.js'));
await app.register(import('./routes/index.js'));
// Graceful shutdown handler
closeWithGrace({ delay: 10000 }, async ({ signal, err }) => {
if (err) {
app.log.error({ err }, 'Server closing due to error');
} else {
app.log.info({ signal }, 'Server closing due to signal');
}
await app.close();
});
// Start server
await app.listen({
port: parseInt(process.env.PORT || '3000', 10),
host: '0.0.0.0',
});
app.log.info(`Server listening on ${app.server.address()}`);
```
## Health Check Endpoints
Implement comprehensive health checks:
```typescript
app.get('/health', async () => {
return { status: 'ok', timestamp: new Date().toISOString() };
});
app.get('/health/live', async () => {
return { status: 'ok' };
});
app.get('/health/ready', async (request, reply) => {
const checks = {
database: false,
cache: false,
};
try {
await app.db`SELECT 1`;
checks.database = true;
} catch {
// Database not ready
}
try {
await app.cache.ping();
checks.cache = true;
} catch {
// Cache not ready
}
const allHealthy = Object.values(checks).every(Boolean);
if (!allHealthy) {
reply.code(503);
}
return {
status: allHealthy ? 'ok' : 'degraded',
checks,
timestamp: new Date().toISOString(),
};
});
// Detailed health for monitoring
app.get('/health/details', {
preHandler: [app.authenticate, app.requireAdmin],
}, async () => {
const memory = process.memoryUsage();
return {
status: 'ok',
uptime: process.uptime(),
memory: {
heapUsed: Math.round(memory.heapUsed / 1024 / 1024),
heapTotal: Math.round(memory.heapTotal / 1024 / 1024),
rss: Math.round(memory.rss / 1024 / 1024),
},
version: process.env.APP_VERSION,
nodeVersion: process.version,
};
});
```
## Docker Configuration
Create an optimized Dockerfile:
```dockerfile
# Build stage
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
# Production stage
FROM node:22-alpine
WORKDIR /app
# Run as non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# Copy from builder
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/src ./src
COPY --from=builder --chown=nodejs:nodejs /app/package.json ./
USER nodejs
EXPOSE 3000
ENV NODE_ENV=production
ENV PORT=3000
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "src/app.ts"]
```
```yaml
# docker-compose.yml
services:
api:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgres://user:pass@db:5432/app
- JWT_SECRET=${JWT_SECRET}
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=pass
- POSTGRES_DB=app
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user -d app"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:
```
## Kubernetes Deployment
Deploy to Kubernetes:
```yaml
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: fastify-api
spec:
replicas: 3
selector:
matchLabels:
app: fastify-api
template:
metadata:
labels:
app: fastify-api
spec:
containers:
- name: api
image: my-registry/fastify-api:latest
ports:
- containerPort: 3000
env:
- name: NODE_ENV
value: "production"
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: api-secrets
key: database-url
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health/live
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /health/ready
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5"]
---
apiVersion: v1
kind: Service
metadata:
name: fastify-api
spec:
selector:
app: fastify-api
ports:
- port: 80
targetPort: 3000
type: ClusterIP
```
## Production Logger Configuration
Configure logging for production:
```typescript
import Fastify from 'fastify';
const app = Fastify({
logger: {
level: process.env.LOG_LEVEL || 'info',
// JSON output for log aggregation
formatters: {
level: (label) => ({ level: label }),
bindings: (bindings) => ({
pid: bindings.pid,
hostname: bindings.hostname,
service: 'fastify-api',
version: process.env.APP_VERSION,
}),
},
timestamp: () => `,"time":"${new Date().toISOString()}"`,
// Redact sensitive data
redact: {
paths: [
'req.headers.authorization',
'req.headers.cookie',
'*.password',
'*.token',
'*.secret',
],
censor: '[REDACTED]',
},
},
});
```
## Request Timeouts
Configure appropriate timeouts:
```typescript
const app = Fastify({
connectionTimeout: 30000, // 30s connection timeout
keepAliveTimeout: 72000, // 72s keep-alive (longer than ALB 60s)
requestTimeout: 30000, // 30s request timeout
bodyLimit: 1048576, // 1MB body limit
});
// Per-route timeout
app.get('/long-operation', {
config: {
timeout: 60000, // 60s for this route
},
}, longOperationHandler);
```
## Trust Proxy Settings
Configure for load balancers:
```typescript
const app = Fastify({
// Trust first proxy (load balancer)
trustProxy: true,
// Or trust specific proxies
trustProxy: ['127.0.0.1', '10.0.0.0/8'],
// Or number of proxies to trust
trustProxy: 1,
});
// Now request.ip returns real client IP
```
## Static File Serving
Serve static files efficiently. **Always use `import.meta.dirname` as the base path**, never `process.cwd()`:
```typescript
import fastifyStatic from '@fastify/static';
import { join } from 'node:path';
app.register(fastifyStatic, {
root: join(import.meta.dirname, '..', 'public'),
prefix: '/static/',
maxAge: '1d',
immutable: true,
etag: true,
lastModified: true,
});
```
## Compression
Enable response compression:
```typescript
import fastifyCompress from '@fastify/compress';
app.register(fastifyCompress, {
global: true,
threshold: 1024, // Only compress > 1KB
encodings: ['gzip', 'deflate'],
});
```
## Metrics and Monitoring
Expose Prometheus metrics:
```typescript
import { register, collectDefaultMetrics, Counter, Histogram } from 'prom-client';
collectDefaultMetrics();
const httpRequestDuration = new Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status'],
buckets: [0.01, 0.05, 0.1, 0.5, 1, 5],
});
const httpRequestTotal = new Counter({
name: 'http_requests_total',
help: 'Total number of HTTP requests',
labelNames: ['method', 'route', 'status'],
});
app.addHook('onResponse', (request, reply, done) => {
const route = request.routeOptions.url || request.url;
const labels = {
method: request.method,
route,
status: reply.statusCode,
};
httpRequestDuration.observe(labels, reply.elapsedTime / 1000);
httpRequestTotal.inc(labels);
done();
});
app.get('/metrics', async (request, reply) => {
reply.header('Content-Type', register.contentType);
return register.metrics();
});
```
## Zero-Downtime Deployments
Support rolling updates:
```typescript
import closeWithGrace from 'close-with-grace';
// Stop accepting new connections gracefully
closeWithGrace({ delay: 30000 }, async ({ signal }) => {
app.log.info({ signal }, 'Received shutdown signal');
// Stop accepting new connections
// Existing connections continue to be served
// Wait for in-flight requests (handled by close-with-grace delay)
await app.close();
app.log.info('Server closed');
});
```
rules/error-handling.md›
---
name: error-handling
description: Error handling patterns in Fastify
metadata:
tags: errors, exceptions, error-handler, validation
---
# Error Handling in Fastify
## Contents
- [Default Error Handler](#default-error-handler)
- [Custom Error Classes](#custom-error-classes)
- [Custom Error Handler](#custom-error-handler)
- [Error Response Schema](#error-response-schema)
- [Reply Helpers with @fastify/sensible](#reply-helpers-with-fastifysensible)
- [Async Error Handling](#async-error-handling)
- [Hook Error Handling](#hook-error-handling)
- [Not Found Handler](#not-found-handler)
- [Error Wrapping](#error-wrapping)
- [Validation Error Customization](#validation-error-customization)
- [Error Cause Chain](#error-cause-chain)
- [Plugin-Scoped Error Handlers](#plugin-scoped-error-handlers)
- [Graceful Error Recovery](#graceful-error-recovery)
## Default Error Handler
Fastify has a built-in error handler. Thrown errors automatically become HTTP responses:
```typescript
import Fastify from 'fastify';
const app = Fastify({ logger: true });
app.get('/users/:id', async (request) => {
const user = await findUser(request.params.id);
if (!user) {
// Throwing an error with statusCode sets the response status
const error = new Error('User not found');
error.statusCode = 404;
throw error;
}
return user;
});
```
## Custom Error Classes
Use `@fastify/error` for creating typed errors:
```typescript
import createError from '@fastify/error';
const NotFoundError = createError('NOT_FOUND', '%s not found', 404);
const UnauthorizedError = createError('UNAUTHORIZED', 'Authentication required', 401);
const ForbiddenError = createError('FORBIDDEN', 'Access denied: %s', 403);
const ValidationError = createError('VALIDATION_ERROR', '%s', 400);
const ConflictError = createError('CONFLICT', '%s already exists', 409);
// Usage
app.get('/users/:id', async (request) => {
const user = await findUser(request.params.id);
if (!user) {
throw new NotFoundError('User');
}
return user;
});
app.post('/users', async (request) => {
const exists = await userExists(request.body.email);
if (exists) {
throw new ConflictError('Email');
}
return createUser(request.body);
});
```
## Custom Error Handler
Implement a centralized error handler:
```typescript
import Fastify from 'fastify';
import type { FastifyError, FastifyRequest, FastifyReply } from 'fastify';
const app = Fastify({ logger: true });
app.setErrorHandler((error: FastifyError, request: FastifyRequest, reply: FastifyReply) => {
// Log the error
request.log.error({ err: error }, 'Request error');
// Handle validation errors
if (error.validation) {
return reply.code(400).send({
statusCode: 400,
error: 'Bad Request',
message: 'Validation failed',
details: error.validation,
});
}
// Handle known errors with status codes
const statusCode = error.statusCode ?? 500;
const code = error.code ?? 'INTERNAL_ERROR';
// Don't expose internal error details in production
const message = statusCode >= 500 && process.env.NODE_ENV === 'production'
? 'Internal Server Error'
: error.message;
return reply.code(statusCode).send({
statusCode,
error: code,
message,
});
});
```
## Error Response Schema
Define consistent error response schemas:
```typescript
app.addSchema({
$id: 'httpError',
type: 'object',
properties: {
statusCode: { type: 'integer' },
error: { type: 'string' },
message: { type: 'string' },
details: {
type: 'array',
items: {
type: 'object',
properties: {
field: { type: 'string' },
message: { type: 'string' },
},
},
},
},
required: ['statusCode', 'error', 'message'],
});
// Use in route schemas
app.get('/users/:id', {
schema: {
params: {
type: 'object',
properties: { id: { type: 'string' } },
required: ['id'],
},
response: {
200: { $ref: 'user#' },
404: { $ref: 'httpError#' },
500: { $ref: 'httpError#' },
},
},
}, handler);
```
## Reply Helpers with @fastify/sensible
Use `@fastify/sensible` for standard HTTP errors:
```typescript
import fastifySensible from '@fastify/sensible';
app.register(fastifySensible);
app.get('/users/:id', async (request, reply) => {
const user = await findUser(request.params.id);
if (!user) {
return reply.notFound('User not found');
}
if (!hasAccess(request.user, user)) {
return reply.forbidden('You cannot access this user');
}
return user;
});
// Available methods:
// reply.badRequest(message?)
// reply.unauthorized(message?)
// reply.forbidden(message?)
// reply.notFound(message?)
// reply.methodNotAllowed(message?)
// reply.conflict(message?)
// reply.gone(message?)
// reply.unprocessableEntity(message?)
// reply.tooManyRequests(message?)
// reply.internalServerError(message?)
// reply.notImplemented(message?)
// reply.badGateway(message?)
// reply.serviceUnavailable(message?)
// reply.gatewayTimeout(message?)
```
## Async Error Handling
Errors in async handlers are automatically caught:
```typescript
// Errors are automatically caught and passed to error handler
app.get('/users', async (request) => {
const users = await db.users.findAll(); // If this throws, error handler catches it
return users;
});
// Explicit error handling for custom logic
app.get('/users/:id', async (request, reply) => {
try {
const user = await db.users.findById(request.params.id);
if (!user) {
return reply.code(404).send({ error: 'User not found' });
}
return user;
} catch (error) {
// Transform database errors
if (error.code === 'CONNECTION_ERROR') {
request.log.error({ err: error }, 'Database connection failed');
return reply.code(503).send({ error: 'Service temporarily unavailable' });
}
throw error; // Re-throw for error handler
}
});
```
## Hook Error Handling
Errors in hooks are handled the same way:
```typescript
app.addHook('onRequest', async (request, reply) => {
const token = request.headers.authorization;
if (!token) {
// This error goes to the error handler
throw new UnauthorizedError();
}
try {
request.user = await verifyToken(token);
} catch (error) {
throw new UnauthorizedError();
}
});
// Or use reply to send response directly
app.addHook('onRequest', async (request, reply) => {
if (!request.headers.authorization) {
reply.code(401).send({ error: 'Unauthorized' });
return; // Must return to stop processing
}
});
```
## Not Found Handler
Customize the 404 response:
```typescript
app.setNotFoundHandler(async (request, reply) => {
return reply.code(404).send({
statusCode: 404,
error: 'Not Found',
message: `Route ${request.method} ${request.url} not found`,
});
});
// With schema validation
app.setNotFoundHandler({
preValidation: async (request, reply) => {
// Pre-validation hook for 404 handler
},
}, async (request, reply) => {
return reply.code(404).send({ error: 'Not Found' });
});
```
## Error Wrapping
Wrap external errors with context:
```typescript
import createError from '@fastify/error';
const DatabaseError = createError('DATABASE_ERROR', 'Database operation failed: %s', 500);
const ExternalServiceError = createError('EXTERNAL_SERVICE_ERROR', 'External service failed: %s', 502);
app.get('/users/:id', async (request) => {
try {
return await db.users.findById(request.params.id);
} catch (error) {
throw new DatabaseError(error.message, { cause: error });
}
});
app.get('/weather', async (request) => {
try {
return await weatherApi.fetch(request.query.city);
} catch (error) {
throw new ExternalServiceError(error.message, { cause: error });
}
});
```
## Validation Error Customization
Customize validation error format:
```typescript
app.setErrorHandler((error, request, reply) => {
if (error.validation) {
const details = error.validation.map((err) => {
const field = err.instancePath
? err.instancePath.slice(1).replace(/\//g, '.')
: err.params?.missingProperty || 'unknown';
return {
field,
message: err.message,
value: err.data,
};
});
return reply.code(400).send({
statusCode: 400,
error: 'Validation Error',
message: `Invalid ${error.validationContext}: ${details.map(d => d.field).join(', ')}`,
details,
});
}
// Handle other errors...
throw error;
});
```
## Error Cause Chain
Preserve error chains for debugging:
```typescript
app.get('/complex-operation', async (request) => {
try {
await step1();
} catch (error) {
const wrapped = new Error('Step 1 failed', { cause: error });
wrapped.statusCode = 500;
throw wrapped;
}
});
// In error handler, log the full chain
app.setErrorHandler((error, request, reply) => {
// Log error with cause chain
let current = error;
const chain = [];
while (current) {
chain.push({
message: current.message,
code: current.code,
stack: current.stack,
});
current = current.cause;
}
request.log.error({ errorChain: chain }, 'Request failed');
reply.code(error.statusCode || 500).send({
error: error.message,
});
});
```
## Plugin-Scoped Error Handlers
Set error handlers at the plugin level:
```typescript
app.register(async function apiRoutes(fastify) {
// This error handler only applies to routes in this plugin
fastify.setErrorHandler((error, request, reply) => {
request.log.error({ err: error }, 'API error');
reply.code(error.statusCode || 500).send({
error: {
code: error.code || 'API_ERROR',
message: error.message,
},
});
});
fastify.get('/data', async () => {
throw new Error('API-specific error');
});
}, { prefix: '/api' });
```
## Graceful Error Recovery
Handle errors gracefully without crashing:
```typescript
app.get('/resilient', async (request, reply) => {
const results = await Promise.allSettled([
fetchPrimaryData(),
fetchSecondaryData(),
fetchOptionalData(),
]);
const [primary, secondary, optional] = results;
if (primary.status === 'rejected') {
// Primary data is required
throw new Error('Primary data unavailable');
}
return {
data: primary.value,
secondary: secondary.status === 'fulfilled' ? secondary.value : null,
optional: optional.status === 'fulfilled' ? optional.value : null,
warnings: results
.filter((r) => r.status === 'rejected')
.map((r) => r.reason.message),
};
});
```
rules/hooks.md›
---
name: hooks
description: Hooks and request lifecycle in Fastify
metadata:
tags: hooks, lifecycle, middleware, onRequest, preHandler
---
# Hooks and Request Lifecycle
## Contents
- [Request Lifecycle Overview](#request-lifecycle-overview)
- [onRequest Hook](#onrequest-hook)
- [preParsing Hook](#preparsing-hook)
- [preValidation Hook](#prevalidation-hook)
- [preHandler Hook](#prehandler-hook)
- [preSerialization Hook](#preserialization-hook)
- [onSend Hook](#onsend-hook)
- [onResponse Hook](#onresponse-hook)
- [onError Hook](#onerror-hook)
- [onTimeout Hook](#ontimeout-hook)
- [onRequestAbort Hook](#onrequestabort-hook)
- [Application Lifecycle Hooks](#application-lifecycle-hooks)
- [Scoped Hooks](#scoped-hooks)
- [Hook Execution Order](#hook-execution-order)
- [Stopping Hook Execution](#stopping-hook-execution)
- [Route-Level Hooks](#route-level-hooks)
- [Async Hook Patterns](#async-hook-patterns)
## Request Lifecycle Overview
Fastify executes hooks in a specific order:
```
Incoming Request
|
onRequest
|
preParsing
|
preValidation
|
preHandler
|
Handler
|
preSerialization
|
onSend
|
onResponse
```
## onRequest Hook
First hook to execute, before body parsing. Use for authentication, request ID setup:
```typescript
import Fastify from 'fastify';
const app = Fastify();
// Global onRequest hook
app.addHook('onRequest', async (request, reply) => {
request.startTime = Date.now();
request.log.info({ url: request.url, method: request.method }, 'Request started');
});
// Authentication check
app.addHook('onRequest', async (request, reply) => {
// Skip auth for public routes
if (request.url.startsWith('/public')) {
return;
}
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
reply.code(401).send({ error: 'Unauthorized' });
return; // Stop processing
}
try {
request.user = await verifyToken(token);
} catch {
reply.code(401).send({ error: 'Invalid token' });
}
});
```
## preParsing Hook
Execute before body parsing. Can modify the payload stream:
```typescript
app.addHook('preParsing', async (request, reply, payload) => {
// Log raw payload size
request.log.debug({ contentLength: request.headers['content-length'] }, 'Parsing body');
// Return modified payload stream if needed
return payload;
});
// Decompress incoming data
app.addHook('preParsing', async (request, reply, payload) => {
if (request.headers['content-encoding'] === 'gzip') {
return payload.pipe(zlib.createGunzip());
}
return payload;
});
```
## preValidation Hook
Execute after parsing, before schema validation:
```typescript
app.addHook('preValidation', async (request, reply) => {
// Modify body before validation
if (request.body && typeof request.body === 'object') {
// Normalize data
request.body.email = request.body.email?.toLowerCase().trim();
}
});
// Rate limiting check
app.addHook('preValidation', async (request, reply) => {
const key = request.ip;
const count = await redis.incr(`ratelimit:${key}`);
if (count === 1) {
await redis.expire(`ratelimit:${key}`, 60);
}
if (count > 100) {
reply.code(429).send({ error: 'Too many requests' });
}
});
```
## preHandler Hook
Most common hook, execute after validation, before handler:
```typescript
// Authorization check
app.addHook('preHandler', async (request, reply) => {
const { userId } = request.params as { userId: string };
if (request.user.id !== userId && !request.user.isAdmin) {
reply.code(403).send({ error: 'Forbidden' });
}
});
// Load related data
app.addHook('preHandler', async (request, reply) => {
if (request.params?.projectId) {
request.project = await db.projects.findById(request.params.projectId);
if (!request.project) {
reply.code(404).send({ error: 'Project not found' });
}
}
});
// Transaction wrapper
app.addHook('preHandler', async (request) => {
request.transaction = await db.beginTransaction();
});
app.addHook('onResponse', async (request) => {
if (request.transaction) {
await request.transaction.commit();
}
});
app.addHook('onError', async (request, reply, error) => {
if (request.transaction) {
await request.transaction.rollback();
}
});
```
## preSerialization Hook
Modify payload before serialization:
```typescript
app.addHook('preSerialization', async (request, reply, payload) => {
// Add metadata to all responses
if (payload && typeof payload === 'object') {
return {
...payload,
_meta: {
requestId: request.id,
timestamp: new Date().toISOString(),
},
};
}
return payload;
});
// Remove sensitive fields
app.addHook('preSerialization', async (request, reply, payload) => {
if (payload?.user?.password) {
const { password, ...user } = payload.user;
return { ...payload, user };
}
return payload;
});
```
## onSend Hook
Modify response after serialization:
```typescript
app.addHook('onSend', async (request, reply, payload) => {
// Add response headers
reply.header('X-Response-Time', Date.now() - request.startTime);
// Compress response
if (payload && payload.length > 1024) {
const compressed = await gzip(payload);
reply.header('Content-Encoding', 'gzip');
return compressed;
}
return payload;
});
// Transform JSON string response
app.addHook('onSend', async (request, reply, payload) => {
if (reply.getHeader('content-type')?.includes('application/json')) {
// payload is already a string at this point
return payload;
}
return payload;
});
```
## onResponse Hook
Execute after response is sent. Cannot modify response:
```typescript
app.addHook('onResponse', async (request, reply) => {
// Log response time
const responseTime = Date.now() - request.startTime;
request.log.info({
method: request.method,
url: request.url,
statusCode: reply.statusCode,
responseTime,
}, 'Request completed');
// Track metrics
metrics.histogram('http_request_duration', responseTime, {
method: request.method,
route: request.routeOptions.url,
status: reply.statusCode,
});
});
```
## onError Hook
Execute when an error is thrown:
```typescript
app.addHook('onError', async (request, reply, error) => {
// Log error details
request.log.error({
err: error,
url: request.url,
method: request.method,
body: request.body,
}, 'Request error');
// Track error metrics
metrics.increment('http_errors', {
error: error.code || 'UNKNOWN',
route: request.routeOptions.url,
});
// Cleanup resources
if (request.tempFile) {
await fs.unlink(request.tempFile).catch(() => {});
}
});
```
## onTimeout Hook
Execute when request times out:
```typescript
const app = Fastify({
connectionTimeout: 30000, // 30 seconds
});
app.addHook('onTimeout', async (request, reply) => {
request.log.warn({
url: request.url,
method: request.method,
}, 'Request timeout');
// Cleanup
if (request.abortController) {
request.abortController.abort();
}
});
```
## onRequestAbort Hook
Execute when client closes connection:
```typescript
app.addHook('onRequestAbort', async (request) => {
request.log.info('Client aborted request');
// Cancel ongoing operations
if (request.abortController) {
request.abortController.abort();
}
// Cleanup uploaded files
if (request.uploadedFiles) {
for (const file of request.uploadedFiles) {
await fs.unlink(file.path).catch(() => {});
}
}
});
```
## Application Lifecycle Hooks
Hooks that run at application startup/shutdown:
```typescript
// After all plugins are loaded
app.addHook('onReady', async function () {
this.log.info('Server is ready');
// Initialize connections
await this.db.connect();
await this.redis.connect();
// Warm caches
await this.cache.warmup();
});
// When server is closing
app.addHook('onClose', async function () {
this.log.info('Server is closing');
// Cleanup connections
await this.db.close();
await this.redis.disconnect();
});
// After routes are registered
app.addHook('onRoute', (routeOptions) => {
console.log(`Route registered: ${routeOptions.method} ${routeOptions.url}`);
// Track all routes
routes.push({
method: routeOptions.method,
url: routeOptions.url,
schema: routeOptions.schema,
});
});
// After plugin is registered
app.addHook('onRegister', (instance, options) => {
console.log(`Plugin registered with prefix: ${options.prefix}`);
});
```
## Scoped Hooks
Hooks are scoped to their encapsulation context:
```typescript
app.addHook('onRequest', async (request) => {
// Runs for ALL routes
request.log.info('Global hook');
});
app.register(async function adminRoutes(fastify) {
// Only runs for routes in this plugin
fastify.addHook('onRequest', async (request, reply) => {
if (!request.user?.isAdmin) {
reply.code(403).send({ error: 'Admin only' });
}
});
fastify.get('/admin/users', async () => {
return { users: [] };
});
}, { prefix: '/admin' });
```
## Hook Execution Order
Multiple hooks of the same type execute in registration order:
```typescript
app.addHook('onRequest', async () => {
console.log('First');
});
app.addHook('onRequest', async () => {
console.log('Second');
});
app.addHook('onRequest', async () => {
console.log('Third');
});
// Output: First, Second, Third
```
## Stopping Hook Execution
Return early from hooks to stop processing:
```typescript
app.addHook('preHandler', async (request, reply) => {
if (!request.user) {
// Send response and return to stop further processing
reply.code(401).send({ error: 'Unauthorized' });
return;
}
// Continue to next hook and handler
});
```
## Route-Level Hooks
Add hooks to specific routes:
```typescript
const adminOnlyHook = async (request, reply) => {
if (!request.user?.isAdmin) {
reply.code(403).send({ error: 'Forbidden' });
}
};
app.get('/admin/settings', {
preHandler: [adminOnlyHook],
handler: async (request) => {
return { settings: {} };
},
});
// Multiple hooks
app.post('/orders', {
preValidation: [validateApiKey],
preHandler: [loadUser, checkQuota, logOrder],
handler: createOrderHandler,
});
```
## Async Hook Patterns
Always use async/await in hooks:
```typescript
// GOOD - async hook
app.addHook('preHandler', async (request, reply) => {
const user = await loadUser(request.headers.authorization);
request.user = user;
});
// AVOID - callback style (deprecated)
app.addHook('preHandler', (request, reply, done) => {
loadUser(request.headers.authorization)
.then((user) => {
request.user = user;
done();
})
.catch(done);
});
```
rules/http-proxy.md›
---
name: http-proxy
description: HTTP proxying and reply.from() in Fastify
metadata:
tags: proxy, gateway, reverse-proxy, microservices
---
# HTTP Proxy and Reply.from()
## Contents
- [@fastify/http-proxy](#fastifyhttp-proxy)
- [@fastify/reply-from](#fastifyreply-from)
- [API Gateway Pattern](#api-gateway-pattern)
- [Request Body Handling](#request-body-handling)
- [Error Handling](#error-handling)
- [WebSocket Proxying](#websocket-proxying)
- [Timeout Configuration](#timeout-configuration)
- [Caching Proxied Responses](#caching-proxied-responses)
## @fastify/http-proxy
Use `@fastify/http-proxy` for simple reverse proxy scenarios:
```typescript
import Fastify from 'fastify';
import httpProxy from '@fastify/http-proxy';
const app = Fastify({ logger: true });
// Proxy all requests to /api/* to another service
app.register(httpProxy, {
upstream: 'http://backend-service:3001',
prefix: '/api',
rewritePrefix: '/v1',
http2: false,
});
// With authentication
app.register(httpProxy, {
upstream: 'http://internal-api:3002',
prefix: '/internal',
preHandler: async (request, reply) => {
// Verify authentication before proxying
if (!request.headers.authorization) {
reply.code(401).send({ error: 'Unauthorized' });
}
},
});
await app.listen({ port: 3000 });
```
## @fastify/reply-from
For more control over proxying, use `@fastify/reply-from` with `reply.from()`:
```typescript
import Fastify from 'fastify';
import replyFrom from '@fastify/reply-from';
const app = Fastify({ logger: true });
app.register(replyFrom, {
base: 'http://backend-service:3001',
http2: false,
});
// Proxy with request/response manipulation
app.get('/users/:id', async (request, reply) => {
const { id } = request.params;
return reply.from(`/api/users/${id}`, {
// Modify request before forwarding
rewriteRequestHeaders: (originalReq, headers) => ({
...headers,
'x-request-id': request.id,
'x-forwarded-for': request.ip,
}),
// Modify response before sending
onResponse: (request, reply, res) => {
reply.header('x-proxy', 'fastify');
reply.send(res);
},
});
});
// Conditional routing
app.all('/api/*', async (request, reply) => {
const upstream = selectUpstream(request);
return reply.from(request.url, {
base: upstream,
});
});
function selectUpstream(request) {
// Route to different backends based on request
if (request.headers['x-beta']) {
return 'http://beta-backend:3001';
}
return 'http://stable-backend:3001';
}
```
## API Gateway Pattern
Build an API gateway with multiple backends:
```typescript
import Fastify from 'fastify';
import replyFrom from '@fastify/reply-from';
const app = Fastify({ logger: true });
// Configure multiple upstreams
const services = {
users: 'http://users-service:3001',
orders: 'http://orders-service:3002',
products: 'http://products-service:3003',
};
app.register(replyFrom);
// Route to user service
app.register(async function (fastify) {
fastify.all('/*', async (request, reply) => {
return reply.from(request.url.replace('/users', ''), {
base: services.users,
});
});
}, { prefix: '/users' });
// Route to orders service
app.register(async function (fastify) {
fastify.all('/*', async (request, reply) => {
return reply.from(request.url.replace('/orders', ''), {
base: services.orders,
});
});
}, { prefix: '/orders' });
// Route to products service
app.register(async function (fastify) {
fastify.all('/*', async (request, reply) => {
return reply.from(request.url.replace('/products', ''), {
base: services.products,
});
});
}, { prefix: '/products' });
```
## Request Body Handling
Handle request bodies when proxying:
```typescript
app.post('/api/data', async (request, reply) => {
return reply.from('/data', {
body: request.body,
contentType: request.headers['content-type'],
});
});
// Stream large bodies
app.post('/upload', async (request, reply) => {
return reply.from('/upload', {
body: request.raw,
contentType: request.headers['content-type'],
});
});
```
## Error Handling
Handle upstream errors gracefully:
```typescript
app.register(replyFrom, {
base: 'http://backend:3001',
// Called when upstream returns an error
onError: (reply, error) => {
reply.log.error({ err: error }, 'Proxy error');
reply.code(502).send({
error: 'Bad Gateway',
message: 'Upstream service unavailable',
});
},
});
// Custom error handling per route
app.get('/data', async (request, reply) => {
try {
return await reply.from('/data');
} catch (error) {
request.log.error({ err: error }, 'Failed to proxy request');
return reply.code(503).send({
error: 'Service Unavailable',
retryAfter: 30,
});
}
});
```
## WebSocket Proxying
Proxy WebSocket connections:
```typescript
import Fastify from 'fastify';
import httpProxy from '@fastify/http-proxy';
const app = Fastify({ logger: true });
app.register(httpProxy, {
upstream: 'http://ws-backend:3001',
prefix: '/ws',
websocket: true,
});
```
## Timeout Configuration
Configure proxy timeouts:
```typescript
app.register(replyFrom, {
base: 'http://backend:3001',
http: {
requestOptions: {
timeout: 30000, // 30 seconds
},
},
});
```
## Caching Proxied Responses
Add caching to proxied responses:
```typescript
import { createCache } from 'async-cache-dedupe';
const cache = createCache({
ttl: 60,
storage: { type: 'memory' },
});
cache.define('proxyGet', async (url: string) => {
const response = await fetch(`http://backend:3001${url}`);
return response.json();
});
app.get('/cached/*', async (request, reply) => {
const data = await cache.proxyGet(request.url);
return data;
});
```
rules/logging.md›
---
name: logging
description: Logging with Pino in Fastify
metadata:
tags: logging, pino, debugging, observability
---
# Logging with Pino
## Contents
- [Built-in Pino Integration](#built-in-pino-integration)
- [Log Levels](#log-levels)
- [Request-Scoped Logging](#request-scoped-logging)
- [Structured Logging](#structured-logging)
- [Logging Configuration by Environment](#logging-configuration-by-environment)
- [Custom Serializers](#custom-serializers)
- [Redacting Sensitive Data](#redacting-sensitive-data)
- [Child Loggers](#child-loggers)
- [Request Logging Configuration](#request-logging-configuration)
- [Logging Errors](#logging-errors)
- [Log Destinations](#log-destinations)
- [Log Rotation](#log-rotation)
- [Log Aggregation](#log-aggregation)
- [Request ID Tracking](#request-id-tracking)
- [Performance Considerations](#performance-considerations)
## Built-in Pino Integration
Fastify uses Pino for high-performance logging:
```typescript
import Fastify from 'fastify';
const app = Fastify({
logger: true, // Enable default logging
});
// Or with configuration
const app = Fastify({
logger: {
level: 'info',
transport: {
target: 'pino-pretty',
options: {
colorize: true,
},
},
},
});
```
## Log Levels
Available log levels (in order of severity):
```typescript
app.log.trace('Detailed debugging');
app.log.debug('Debugging information');
app.log.info('General information');
app.log.warn('Warning messages');
app.log.error('Error messages');
app.log.fatal('Fatal errors');
```
## Request-Scoped Logging
Each request has its own logger with request context:
```typescript
app.get('/users/:id', async (request) => {
// Logs include request ID automatically
request.log.info('Fetching user');
const user = await db.users.findById(request.params.id);
if (!user) {
request.log.warn({ userId: request.params.id }, 'User not found');
return { error: 'Not found' };
}
request.log.info({ userId: user.id }, 'User fetched');
return user;
});
```
## Structured Logging
Always use structured logging with objects:
```typescript
// GOOD - structured, searchable
request.log.info({
action: 'user_created',
userId: user.id,
email: user.email,
}, 'User created successfully');
request.log.error({
err: error,
userId: request.params.id,
operation: 'fetch_user',
}, 'Failed to fetch user');
// BAD - unstructured, hard to parse
request.log.info(`User ${user.id} created with email ${user.email}`);
request.log.error(`Failed to fetch user: ${error.message}`);
```
## Logging Configuration by Environment
```typescript
function getLoggerConfig() {
if (process.env.NODE_ENV === 'production') {
return {
level: 'info',
// JSON output for log aggregation
};
}
if (process.env.NODE_ENV === 'test') {
return false; // Disable logging in tests
}
// Development
return {
level: 'debug',
transport: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'HH:MM:ss Z',
ignore: 'pid,hostname',
},
},
};
}
const app = Fastify({
logger: getLoggerConfig(),
});
```
## Custom Serializers
Customize how objects are serialized:
```typescript
const app = Fastify({
logger: {
level: 'info',
serializers: {
// Customize request serialization
req: (request) => ({
method: request.method,
url: request.url,
headers: {
host: request.headers.host,
'user-agent': request.headers['user-agent'],
},
remoteAddress: request.ip,
}),
// Customize response serialization
res: (response) => ({
statusCode: response.statusCode,
}),
// Custom serializer for users
user: (user) => ({
id: user.id,
email: user.email,
// Exclude sensitive fields
}),
},
},
});
// Use custom serializer
request.log.info({ user: request.user }, 'User action');
```
## Redacting Sensitive Data
Prevent logging sensitive information:
```typescript
import Fastify from 'fastify';
const app = Fastify({
logger: {
level: 'info',
redact: {
paths: [
'req.headers.authorization',
'req.headers.cookie',
'body.password',
'body.creditCard',
'*.password',
'*.secret',
'*.token',
],
censor: '[REDACTED]',
},
},
});
```
## Child Loggers
Create child loggers with additional context:
```typescript
app.addHook('onRequest', async (request) => {
// Add user context to all logs for this request
if (request.user) {
request.log = request.log.child({
userId: request.user.id,
userRole: request.user.role,
});
}
});
// Service-level child logger
const userService = {
log: app.log.child({ service: 'UserService' }),
async create(data) {
this.log.info({ email: data.email }, 'Creating user');
// ...
},
};
```
## Request Logging Configuration
Customize automatic request logging:
```typescript
const app = Fastify({
logger: true,
disableRequestLogging: true, // Disable default request/response logs
});
// Custom request logging
app.addHook('onRequest', async (request) => {
request.log.info({
method: request.method,
url: request.url,
query: request.query,
}, 'Request received');
});
app.addHook('onResponse', async (request, reply) => {
request.log.info({
statusCode: reply.statusCode,
responseTime: reply.elapsedTime,
}, 'Request completed');
});
```
## Logging Errors
Properly log errors with stack traces:
```typescript
app.setErrorHandler((error, request, reply) => {
// Log error with full details
request.log.error({
err: error, // Pino serializes error objects properly
url: request.url,
method: request.method,
body: request.body,
query: request.query,
}, 'Request error');
reply.code(error.statusCode || 500).send({
error: error.message,
});
});
// In handlers
app.get('/data', async (request) => {
try {
return await fetchData();
} catch (error) {
request.log.error({ err: error }, 'Failed to fetch data');
throw error;
}
});
```
## Log Destinations
Configure where logs are sent:
```typescript
import { createWriteStream } from 'node:fs';
// File output
const app = Fastify({
logger: {
level: 'info',
stream: createWriteStream('./app.log'),
},
});
// Multiple destinations with pino.multistream
import pino from 'pino';
const streams = [
{ stream: process.stdout },
{ stream: createWriteStream('./app.log') },
{ level: 'error', stream: createWriteStream('./error.log') },
];
const app = Fastify({
logger: pino({ level: 'info' }, pino.multistream(streams)),
});
```
## Log Rotation
Use pino-roll for log rotation:
```bash
node app.js | pino-roll --frequency daily --extension .log
```
Or configure programmatically:
```typescript
import { createStream } from 'rotating-file-stream';
const stream = createStream('app.log', {
size: '10M', // Rotate every 10MB
interval: '1d', // Rotate daily
compress: 'gzip',
path: './logs',
});
const app = Fastify({
logger: {
level: 'info',
stream,
},
});
```
## Log Aggregation
Format logs for aggregation services:
```typescript
// For ELK Stack, Datadog, etc. - use default JSON format
const app = Fastify({
logger: {
level: 'info',
// Default JSON output works with most log aggregators
},
});
// Add service metadata
const app = Fastify({
logger: {
level: 'info',
base: {
service: 'user-api',
version: process.env.APP_VERSION,
environment: process.env.NODE_ENV,
},
},
});
```
## Request ID Tracking
Use request IDs for distributed tracing:
```typescript
const app = Fastify({
logger: true,
requestIdHeader: 'x-request-id', // Use incoming header
genReqId: (request) => {
// Generate ID if not provided
return request.headers['x-request-id'] || crypto.randomUUID();
},
});
// Forward request ID to downstream services
app.addHook('onRequest', async (request) => {
request.requestId = request.id;
});
// Include in outgoing requests
const response = await fetch('http://other-service/api', {
headers: {
'x-request-id': request.id,
},
});
```
## Performance Considerations
Pino is fast, but consider:
```typescript
// Avoid string concatenation in log calls
// BAD
request.log.info('User ' + user.id + ' did ' + action);
// GOOD
request.log.info({ userId: user.id, action }, 'User action');
// Use appropriate log levels
// Don't log at info level in hot paths
if (app.log.isLevelEnabled('debug')) {
request.log.debug({ details: expensiveToCompute() }, 'Debug info');
}
```
rules/performance.md›
---
name: performance
description: Performance optimization for Fastify applications
metadata:
tags: performance, optimization, speed, benchmarking
---
# Performance Optimization
## Contents
- [Fastify is Fast by Default](#fastify-is-fast-by-default)
- [Use @fastify/under-pressure for Load Shedding](#use-fastifyunder-pressure-for-load-shedding)
- [Always Define Response Schemas](#always-define-response-schemas)
- [Avoid Dynamic Schema Compilation](#avoid-dynamic-schema-compilation)
- [Use Logger Wisely](#use-logger-wisely)
- [Connection Pooling](#connection-pooling)
- [Avoid Blocking the Event Loop](#avoid-blocking-the-event-loop)
- [Stream Large Responses](#stream-large-responses)
- [Caching Strategies](#caching-strategies)
- [Request Coalescing with async-cache-dedupe](#request-coalescing-with-async-cache-dedupe)
- [Payload Limits](#payload-limits)
- [Compression](#compression)
- [Connection Timeouts](#connection-timeouts)
- [Disable Unnecessary Features](#disable-unnecessary-features)
- [Benchmarking](#benchmarking)
- [Profiling](#profiling)
- [Memory Management](#memory-management)
## Fastify is Fast by Default
Fastify is designed for performance. Key optimizations are built-in:
- Fast JSON serialization with `fast-json-stringify`
- Efficient routing with `find-my-way`
- Schema-based validation with `ajv` (compiled validators)
- Low overhead request/response handling
## Use @fastify/under-pressure for Load Shedding
Protect your application from overload with `@fastify/under-pressure`:
```typescript
import underPressure from '@fastify/under-pressure';
app.register(underPressure, {
maxEventLoopDelay: 1000, // Max event loop delay in ms
maxHeapUsedBytes: 1000000000, // Max heap used (~1GB)
maxRssBytes: 1500000000, // Max RSS (~1.5GB)
maxEventLoopUtilization: 0.98, // Max event loop utilization
pressureHandler: (request, reply, type, value) => {
reply.code(503).send({
error: 'Service Unavailable',
message: `Server under pressure: ${type}`,
});
},
});
// Health check that respects pressure
app.get('/health', async (request, reply) => {
return { status: 'ok' };
});
```
## Always Define Response Schemas
Response schemas enable fast-json-stringify, which is significantly faster than JSON.stringify:
```typescript
// FAST - uses fast-json-stringify
app.get('/users', {
schema: {
response: {
200: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
email: { type: 'string' },
},
},
},
},
},
}, async () => {
return db.users.findAll();
});
// SLOW - uses JSON.stringify
app.get('/users-slow', async () => {
return db.users.findAll();
});
```
## Avoid Dynamic Schema Compilation
Add schemas at startup, not at request time:
```typescript
// GOOD - schemas compiled at startup
app.addSchema({ $id: 'user', ... });
app.get('/users', {
schema: { response: { 200: { $ref: 'user#' } } },
}, handler);
// BAD - schema compiled per request
app.get('/users', async (request, reply) => {
const schema = getSchemaForUser(request.user);
// This is slow!
});
```
## Use Logger Wisely
Pino is fast, but excessive logging has overhead:
```typescript
import Fastify from 'fastify';
// Set log level via environment variable
const app = Fastify({
logger: {
level: process.env.LOG_LEVEL || 'info',
},
});
// Avoid logging large objects
app.get('/data', async (request) => {
// BAD - logs entire payload
request.log.info({ data: largeObject }, 'Processing');
// GOOD - log only what's needed
request.log.info({ id: largeObject.id }, 'Processing');
return largeObject;
});
```
## Connection Pooling
Use connection pools for databases:
```typescript
import postgres from 'postgres';
// Create pool at startup
const sql = postgres(process.env.DATABASE_URL, {
max: 20, // Maximum pool size
idle_timeout: 20,
connect_timeout: 10,
});
app.decorate('db', sql);
// Connections are reused
app.get('/users', async () => {
return app.db`SELECT * FROM users LIMIT 100`;
});
```
## Avoid Blocking the Event Loop
Use `piscina` for CPU-intensive operations. It provides a robust worker thread pool:
```typescript
import Piscina from 'piscina';
import { join } from 'node:path';
const piscina = new Piscina({
filename: join(import.meta.dirname, 'workers', 'compute.js'),
});
app.post('/compute', async (request) => {
const result = await piscina.run(request.body);
return result;
});
```
```typescript
// workers/compute.js
export default function compute(data) {
// CPU-intensive work here
return processedResult;
}
```
## Stream Large Responses
Stream large payloads instead of buffering:
```typescript
import { createReadStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
// GOOD - stream file
app.get('/large-file', async (request, reply) => {
const stream = createReadStream('./large-file.json');
reply.type('application/json');
return reply.send(stream);
});
// BAD - load entire file into memory
app.get('/large-file-bad', async () => {
const content = await fs.readFile('./large-file.json', 'utf-8');
return JSON.parse(content);
});
// Stream database results
app.get('/export', async (request, reply) => {
reply.type('application/json');
const cursor = db.users.findCursor();
reply.raw.write('[');
let first = true;
for await (const user of cursor) {
if (!first) reply.raw.write(',');
reply.raw.write(JSON.stringify(user));
first = false;
}
reply.raw.write(']');
reply.raw.end();
});
```
## Caching Strategies
Implement caching for expensive operations:
```typescript
import { LRUCache } from 'lru-cache';
const cache = new LRUCache<string, unknown>({
max: 1000,
ttl: 60000, // 1 minute
});
app.get('/expensive/:id', async (request) => {
const { id } = request.params;
const cacheKey = `expensive:${id}`;
const cached = cache.get(cacheKey);
if (cached) {
return cached;
}
const result = await expensiveOperation(id);
cache.set(cacheKey, result);
return result;
});
// Cache control headers
app.get('/static-data', async (request, reply) => {
reply.header('Cache-Control', 'public, max-age=3600');
return { data: 'static' };
});
```
## Request Coalescing with async-cache-dedupe
Use `async-cache-dedupe` for deduplicating concurrent identical requests and caching:
```typescript
import { createCache } from 'async-cache-dedupe';
const cache = createCache({
ttl: 60, // seconds
stale: 5, // serve stale while revalidating
storage: { type: 'memory' },
});
cache.define('fetchData', async (id: string) => {
return db.findById(id);
});
app.get('/data/:id', async (request) => {
const { id } = request.params;
// Automatically deduplicates concurrent requests for the same id
// and caches the result
return cache.fetchData(id);
});
```
For distributed caching, use Redis storage:
```typescript
import { createCache } from 'async-cache-dedupe';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
const cache = createCache({
ttl: 60,
storage: { type: 'redis', options: { client: redis } },
});
```
## Payload Limits
Set appropriate payload limits:
```typescript
import Fastify from 'fastify';
const app = Fastify({
bodyLimit: 1048576, // 1MB default
});
// Per-route limit for file uploads
app.post('/upload', {
bodyLimit: 10485760, // 10MB for this route
}, uploadHandler);
```
## Compression
Use compression for responses:
```typescript
import fastifyCompress from '@fastify/compress';
app.register(fastifyCompress, {
global: true,
threshold: 1024, // Only compress responses > 1KB
encodings: ['gzip', 'deflate'],
});
// Disable for specific route
app.get('/already-compressed', {
compress: false,
}, handler);
```
## Connection Timeouts
Configure appropriate timeouts:
```typescript
import Fastify from 'fastify';
const app = Fastify({
connectionTimeout: 30000, // 30 seconds
keepAliveTimeout: 5000, // 5 seconds
});
// Per-route timeout
app.get('/long-operation', {
config: {
timeout: 60000, // 60 seconds
},
}, async (request) => {
return longOperation();
});
```
## Disable Unnecessary Features
Disable features you don't need:
```typescript
import Fastify from 'fastify';
const app = Fastify({
disableRequestLogging: true, // If you don't need request logs
trustProxy: false, // If not behind proxy
caseSensitive: true, // Enable for slight performance gain
ignoreDuplicateSlashes: false,
});
```
## Benchmarking
Use autocannon for load testing:
```bash
# Install
npm install -g autocannon
# Basic benchmark
autocannon http://localhost:3000/api/users
# With options
autocannon -c 100 -d 30 -p 10 http://localhost:3000/api/users
# -c: connections
# -d: duration in seconds
# -p: pipelining factor
```
```typescript
// Programmatic benchmarking
import autocannon from 'autocannon';
const result = await autocannon({
url: 'http://localhost:3000/api/users',
connections: 100,
duration: 30,
pipelining: 10,
});
console.log(autocannon.printResult(result));
```
## Profiling
Use `@platformatic/flame` for flame graph profiling:
```bash
npx @platformatic/flame app.js
```
This generates an interactive flame graph to identify performance bottlenecks.
## Memory Management
Monitor and optimize memory usage:
```typescript
// Add health endpoint with memory info
app.get('/health', async () => {
const memory = process.memoryUsage();
return {
status: 'ok',
memory: {
heapUsed: Math.round(memory.heapUsed / 1024 / 1024) + 'MB',
heapTotal: Math.round(memory.heapTotal / 1024 / 1024) + 'MB',
rss: Math.round(memory.rss / 1024 / 1024) + 'MB',
},
};
});
// Avoid memory leaks in closures
app.addHook('onRequest', async (request) => {
// BAD - holding reference to large object
const largeData = await loadLargeData();
request.getData = () => largeData;
// GOOD - load on demand
request.getData = () => loadLargeData();
});
```
rules/plugins.md›
---
name: plugins
description: Plugin development and encapsulation in Fastify
metadata:
tags: plugins, encapsulation, modules, architecture
---
# Plugin Development and Encapsulation
## Contents
- [Understanding Encapsulation](#understanding-encapsulation)
- [Breaking Encapsulation with fastify-plugin](#breaking-encapsulation-with-fastify-plugin)
- [Plugin Registration Order](#plugin-registration-order)
- [Plugin Options](#plugin-options)
- [Plugin Factory Pattern](#plugin-factory-pattern)
- [Plugin Dependencies](#plugin-dependencies)
- [Scoped Plugins for Route Groups](#scoped-plugins-for-route-groups)
- [Prefix Routes with Register](#prefix-routes-with-register)
- [Plugin Metadata](#plugin-metadata)
- [Autoload Plugins](#autoload-plugins)
- [Testing Plugins in Isolation](#testing-plugins-in-isolation)
## Understanding Encapsulation
Fastify's plugin system provides automatic encapsulation. Each plugin creates its own context, isolating decorators, hooks, and plugins registered within it:
```typescript
import Fastify from 'fastify';
import fp from 'fastify-plugin';
const app = Fastify();
// This plugin is encapsulated - its decorators are NOT available to siblings
app.register(async function childPlugin(fastify) {
fastify.decorate('privateUtil', () => 'only available here');
// This decorator is only available within this plugin and its children
fastify.get('/child', async function (request, reply) {
return this.privateUtil();
});
});
// This route CANNOT access privateUtil - it's in a different context
app.get('/parent', async function (request, reply) {
// this.privateUtil is undefined here
return { status: 'ok' };
});
```
## Breaking Encapsulation with fastify-plugin
Use `fastify-plugin` when you need to share decorators, hooks, or plugins with the parent context:
```typescript
import fp from 'fastify-plugin';
// This plugin's decorators will be available to the parent and siblings
export default fp(async function databasePlugin(fastify, options) {
const db = await createConnection(options.connectionString);
fastify.decorate('db', db);
fastify.addHook('onClose', async () => {
await db.close();
});
}, {
name: 'database-plugin',
dependencies: [], // List plugin dependencies
});
```
## Plugin Registration Order
Plugins are registered in order, but loading is asynchronous. Use `after()` for sequential dependencies:
```typescript
import Fastify from 'fastify';
import databasePlugin from './plugins/database.js';
import authPlugin from './plugins/auth.js';
import routesPlugin from './routes/index.js';
const app = Fastify();
// Database must be ready before auth
app.register(databasePlugin);
// Auth depends on database
app.register(authPlugin);
// Routes depend on both
app.register(routesPlugin);
// Or use after() for explicit sequencing
app.register(databasePlugin).after(() => {
app.register(authPlugin).after(() => {
app.register(routesPlugin);
});
});
await app.ready();
```
## Plugin Options
Always validate and document plugin options:
```typescript
import fp from 'fastify-plugin';
interface CachePluginOptions {
ttl: number;
maxSize?: number;
prefix?: string;
}
export default fp<CachePluginOptions>(async function cachePlugin(fastify, options) {
const { ttl, maxSize = 1000, prefix = 'cache:' } = options;
if (typeof ttl !== 'number' || ttl <= 0) {
throw new Error('Cache plugin requires a positive ttl option');
}
const cache = new Map<string, { value: unknown; expires: number }>();
fastify.decorate('cache', {
get(key: string): unknown | undefined {
const item = cache.get(prefix + key);
if (!item) return undefined;
if (Date.now() > item.expires) {
cache.delete(prefix + key);
return undefined;
}
return item.value;
},
set(key: string, value: unknown): void {
if (cache.size >= maxSize) {
const firstKey = cache.keys().next().value;
cache.delete(firstKey);
}
cache.set(prefix + key, { value, expires: Date.now() + ttl });
},
});
}, {
name: 'cache-plugin',
});
```
## Plugin Factory Pattern
Create configurable plugins using factory functions:
```typescript
import fp from 'fastify-plugin';
interface RateLimitOptions {
max: number;
timeWindow: number;
}
function createRateLimiter(defaults: Partial<RateLimitOptions> = {}) {
return fp<RateLimitOptions>(async function rateLimitPlugin(fastify, options) {
const config = { ...defaults, ...options };
// Implementation
fastify.decorate('rateLimit', config);
}, {
name: 'rate-limiter',
});
}
// Usage
app.register(createRateLimiter({ max: 100 }), { timeWindow: 60000 });
```
## Plugin Dependencies
Declare dependencies to ensure proper load order:
```typescript
import fp from 'fastify-plugin';
export default fp(async function authPlugin(fastify) {
// This plugin requires 'database-plugin' to be loaded first
if (!fastify.hasDecorator('db')) {
throw new Error('Auth plugin requires database plugin');
}
fastify.decorate('authenticate', async (request) => {
const user = await fastify.db.users.findByToken(request.headers.authorization);
return user;
});
}, {
name: 'auth-plugin',
dependencies: ['database-plugin'],
});
```
## Scoped Plugins for Route Groups
Use encapsulation to scope plugins to specific routes:
```typescript
import Fastify from 'fastify';
const app = Fastify();
// Public routes - no auth required
app.register(async function publicRoutes(fastify) {
fastify.get('/health', async () => ({ status: 'ok' }));
fastify.get('/docs', async () => ({ version: '1.0.0' }));
});
// Protected routes - auth required
app.register(async function protectedRoutes(fastify) {
// Auth hook only applies to routes in this plugin
fastify.addHook('onRequest', async (request, reply) => {
const token = request.headers.authorization;
if (!token) {
reply.code(401).send({ error: 'Unauthorized' });
return;
}
request.user = await verifyToken(token);
});
fastify.get('/profile', async (request) => {
return { user: request.user };
});
fastify.get('/settings', async (request) => {
return { settings: await getSettings(request.user.id) };
});
});
```
## Prefix Routes with Register
Use the `prefix` option to namespace routes:
```typescript
app.register(import('./routes/users.js'), { prefix: '/api/v1/users' });
app.register(import('./routes/posts.js'), { prefix: '/api/v1/posts' });
// In routes/users.js
export default async function userRoutes(fastify) {
// Becomes /api/v1/users
fastify.get('/', async () => {
return { users: [] };
});
// Becomes /api/v1/users/:id
fastify.get('/:id', async (request) => {
return { user: { id: request.params.id } };
});
}
```
## Plugin Metadata
Add metadata for documentation and tooling:
```typescript
import fp from 'fastify-plugin';
async function metricsPlugin(fastify) {
// Implementation
}
export default fp(metricsPlugin, {
name: 'metrics-plugin',
fastify: '5.x', // Fastify version compatibility
dependencies: ['pino-plugin'],
decorators: {
fastify: ['db'], // Required decorators
request: [],
reply: [],
},
});
```
## Autoload Plugins
Use `@fastify/autoload` for automatic plugin loading:
```typescript
import Fastify from 'fastify';
import autoload from '@fastify/autoload';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = Fastify();
// Load all plugins from the plugins directory
app.register(autoload, {
dir: join(__dirname, 'plugins'),
options: { prefix: '/api' },
});
// Load all routes from the routes directory
app.register(autoload, {
dir: join(__dirname, 'routes'),
options: { prefix: '/api' },
});
```
## Testing Plugins in Isolation
Test plugins independently:
```typescript
import { describe, it, before, after } from 'node:test';
import Fastify from 'fastify';
import myPlugin from './my-plugin.js';
describe('MyPlugin', () => {
let app;
before(async () => {
app = Fastify();
app.register(myPlugin, { option: 'value' });
await app.ready();
});
after(async () => {
await app.close();
});
it('should decorate fastify instance', (t) => {
t.assert.ok(app.hasDecorator('myDecorator'));
});
});
```
rules/routes.md›
---
name: routes
description: Route organization and handlers in Fastify
metadata:
tags: routes, handlers, http, rest, api
---
# Route Organization and Handlers
## Contents
- [Basic Route Definition](#basic-route-definition)
- [Route Parameters](#route-parameters)
- [Query String Parameters](#query-string-parameters)
- [Request Body](#request-body)
- [Headers](#headers)
- [Reply Methods](#reply-methods)
- [Route Organization by Feature](#route-organization-by-feature)
- [Route Constraints](#route-constraints)
- [Route Prefixing](#route-prefixing)
- [Multiple Methods](#multiple-methods)
- [404 Handler](#404-handler)
- [Method Not Allowed](#method-not-allowed)
- [Route-Level Configuration](#route-level-configuration)
- [Async Route Registration](#async-route-registration)
- [Auto-loading Routes with @fastify/autoload](#auto-loading-routes-with-fastifyautoload)
## Basic Route Definition
Define routes with the shorthand methods or the full route method:
```typescript
import Fastify from 'fastify';
const app = Fastify();
// Shorthand methods
app.get('/users', async (request, reply) => {
return { users: [] };
});
app.post('/users', async (request, reply) => {
return { created: true };
});
// Full route method with all options
app.route({
method: 'GET',
url: '/users/:id',
schema: {
params: {
type: 'object',
properties: {
id: { type: 'string' },
},
required: ['id'],
},
},
handler: async (request, reply) => {
return { id: request.params.id };
},
});
```
## Route Parameters
Access URL parameters through `request.params`:
```typescript
// Single parameter
app.get('/users/:id', async (request) => {
const { id } = request.params as { id: string };
return { userId: id };
});
// Multiple parameters
app.get('/users/:userId/posts/:postId', async (request) => {
const { userId, postId } = request.params as { userId: string; postId: string };
return { userId, postId };
});
// Wildcard parameter (captures everything after)
app.get('/files/*', async (request) => {
const path = (request.params as { '*': string })['*'];
return { filePath: path };
});
// Regex parameters (Fastify uses find-my-way)
app.get('/orders/:id(\\d+)', async (request) => {
// Only matches numeric IDs
const { id } = request.params as { id: string };
return { orderId: parseInt(id, 10) };
});
```
## Query String Parameters
Access query parameters through `request.query`:
```typescript
app.get('/search', {
schema: {
querystring: {
type: 'object',
properties: {
q: { type: 'string' },
page: { type: 'integer', default: 1 },
limit: { type: 'integer', default: 10, maximum: 100 },
},
required: ['q'],
},
},
handler: async (request) => {
const { q, page, limit } = request.query as {
q: string;
page: number;
limit: number;
};
return { query: q, page, limit };
},
});
```
## Request Body
Access the request body through `request.body`:
```typescript
app.post('/users', {
schema: {
body: {
type: 'object',
properties: {
name: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email' },
age: { type: 'integer', minimum: 0 },
},
required: ['name', 'email'],
},
},
handler: async (request, reply) => {
const user = request.body as { name: string; email: string; age?: number };
// Create user...
reply.code(201);
return { user };
},
});
```
## Headers
Access request headers through `request.headers`:
```typescript
app.get('/protected', {
schema: {
headers: {
type: 'object',
properties: {
authorization: { type: 'string' },
},
required: ['authorization'],
},
},
handler: async (request) => {
const token = request.headers.authorization;
return { authenticated: true };
},
});
```
## Reply Methods
Use reply methods to control the response:
```typescript
app.get('/examples', async (request, reply) => {
// Set status code
reply.code(201);
// Set headers
reply.header('X-Custom-Header', 'value');
reply.headers({ 'X-Another': 'value', 'X-Third': 'value' });
// Set content type
reply.type('application/json');
// Redirect
// reply.redirect('/other-url');
// reply.redirect(301, '/permanent-redirect');
// Return response (automatic serialization)
return { status: 'ok' };
});
// Explicit send (useful in non-async handlers)
app.get('/explicit', (request, reply) => {
reply.send({ status: 'ok' });
});
// Stream response
app.get('/stream', async (request, reply) => {
const stream = fs.createReadStream('./large-file.txt');
reply.type('text/plain');
return reply.send(stream);
});
```
## Route Organization by Feature
Organize routes by feature/domain in separate files:
```
src/
routes/
users/
index.ts # Route definitions
handlers.ts # Handler functions
schemas.ts # JSON schemas
posts/
index.ts
handlers.ts
schemas.ts
```
```typescript
// routes/users/schemas.ts
export const userSchema = {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string' },
email: { type: 'string', format: 'email' },
},
};
export const createUserSchema = {
body: {
type: 'object',
properties: {
name: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email' },
},
required: ['name', 'email'],
},
response: {
201: userSchema,
},
};
// routes/users/handlers.ts
import type { FastifyRequest, FastifyReply } from 'fastify';
export async function createUser(
request: FastifyRequest<{ Body: { name: string; email: string } }>,
reply: FastifyReply,
) {
const { name, email } = request.body;
const user = await request.server.db.users.create({ name, email });
reply.code(201);
return user;
}
export async function getUsers(request: FastifyRequest) {
return request.server.db.users.findAll();
}
// routes/users/index.ts
import type { FastifyInstance } from 'fastify';
import { createUser, getUsers } from './handlers.js';
import { createUserSchema } from './schemas.js';
export default async function userRoutes(fastify: FastifyInstance) {
fastify.get('/', getUsers);
fastify.post('/', { schema: createUserSchema }, createUser);
}
```
## Route Constraints
Add constraints to routes for versioning or host-based routing:
```typescript
// Version constraint
app.get('/users', {
constraints: { version: '1.0.0' },
handler: async () => ({ version: '1.0.0', users: [] }),
});
app.get('/users', {
constraints: { version: '2.0.0' },
handler: async () => ({ version: '2.0.0', data: { users: [] } }),
});
// Client sends: Accept-Version: 1.0.0
// Host constraint
app.get('/', {
constraints: { host: 'api.example.com' },
handler: async () => ({ api: true }),
});
app.get('/', {
constraints: { host: 'www.example.com' },
handler: async () => ({ web: true }),
});
```
## Route Prefixing
Use prefixes to namespace routes:
```typescript
// Using register
app.register(async function (fastify) {
fastify.get('/list', async () => ({ users: [] }));
fastify.get('/:id', async (request) => ({ id: request.params.id }));
}, { prefix: '/users' });
// Results in:
// GET /users/list
// GET /users/:id
```
## Multiple Methods
Handle multiple HTTP methods with one handler:
```typescript
app.route({
method: ['GET', 'HEAD'],
url: '/resource',
handler: async (request) => {
return { data: 'resource' };
},
});
```
## 404 Handler
Customize the not found handler:
```typescript
app.setNotFoundHandler({
preValidation: async (request, reply) => {
// Optional pre-validation hook
},
preHandler: async (request, reply) => {
// Optional pre-handler hook
},
}, async (request, reply) => {
reply.code(404);
return {
error: 'Not Found',
message: `Route ${request.method} ${request.url} not found`,
statusCode: 404,
};
});
```
## Method Not Allowed
Handle method not allowed responses:
```typescript
// Fastify doesn't have built-in 405 handling
// Implement with a custom not found handler that checks allowed methods
app.setNotFoundHandler(async (request, reply) => {
// Check if the URL exists with a different method
const route = app.hasRoute({
url: request.url,
method: 'GET', // Check other methods
});
if (route) {
reply.code(405);
return { error: 'Method Not Allowed' };
}
reply.code(404);
return { error: 'Not Found' };
});
```
## Route-Level Configuration
Apply configuration to specific routes:
```typescript
app.get('/slow-operation', {
config: {
rateLimit: { max: 10, timeWindow: '1 minute' },
},
handler: async (request) => {
return { result: await slowOperation() };
},
});
// Access config in hooks
app.addHook('onRequest', async (request, reply) => {
const config = request.routeOptions.config;
if (config.rateLimit) {
// Apply rate limiting
}
});
```
## Async Route Registration
Register routes from async sources:
```typescript
app.register(async function (fastify) {
const routeConfigs = await loadRoutesFromDatabase();
for (const config of routeConfigs) {
fastify.route({
method: config.method,
url: config.path,
handler: createDynamicHandler(config),
});
}
});
```
## Auto-loading Routes with @fastify/autoload
Use `@fastify/autoload` to automatically load routes from a directory structure:
```typescript
import Fastify from 'fastify';
import autoload from '@fastify/autoload';
import { join } from 'node:path';
const app = Fastify({ logger: true });
// Auto-load plugins
app.register(autoload, {
dir: join(import.meta.dirname, 'plugins'),
options: { prefix: '' },
});
// Auto-load routes
app.register(autoload, {
dir: join(import.meta.dirname, 'routes'),
options: { prefix: '/api' },
});
await app.listen({ port: 3000 });
```
Directory structure:
```
src/
plugins/
database.ts # Loaded automatically
auth.ts # Loaded automatically
routes/
users/
index.ts # GET/POST /api/users
_id/
index.ts # GET/PUT/DELETE /api/users/:id
posts/
index.ts # GET/POST /api/posts
```
Route file example:
```typescript
// routes/users/index.ts
import type { FastifyPluginAsync } from 'fastify';
const users: FastifyPluginAsync = async (fastify) => {
fastify.get('/', async () => {
return fastify.repositories.users.findAll();
});
fastify.post('/', async (request) => {
return fastify.repositories.users.create(request.body);
});
};
export default users;
```
rules/schemas.md›
---
name: schemas
description: JSON Schema validation in Fastify with TypeBox
metadata:
tags: validation, json-schema, schemas, ajv, typebox
---
# JSON Schema Validation
## Contents
- [Use TypeBox for Type-Safe Schemas](#use-typebox-for-type-safe-schemas)
- [TypeBox Common Patterns](#typebox-common-patterns)
- [Register TypeBox Schemas Globally](#register-typebox-schemas-globally)
- [Plain JSON Schema (Alternative)](#plain-json-schema-alternative)
- [Request Validation Parts](#request-validation-parts)
- [Shared Schemas with $id](#shared-schemas-with-id)
- [Array Schemas](#array-schemas)
- [Custom Formats](#custom-formats)
- [Custom Keywords](#custom-keywords)
- [Coercion](#coercion)
- [Validation Error Handling](#validation-error-handling)
- [Schema Compiler Options](#schema-compiler-options)
- [Nullable Fields](#nullable-fields)
- [Conditional Validation](#conditional-validation)
- [Schema Organization](#schema-organization)
- [OpenAPI/Swagger Integration](#openapiswagger-integration)
- [Performance Considerations](#performance-considerations)
## Use TypeBox for Type-Safe Schemas
**Prefer TypeBox for defining schemas.** It provides TypeScript types automatically and compiles to JSON Schema:
```typescript
import Fastify from 'fastify';
import { Type, type Static } from '@sinclair/typebox';
const app = Fastify();
// Define schema with TypeBox - get TypeScript types for free
const CreateUserBody = Type.Object({
name: Type.String({ minLength: 1, maxLength: 100 }),
email: Type.String({ format: 'email' }),
age: Type.Optional(Type.Integer({ minimum: 0, maximum: 150 })),
});
const UserResponse = Type.Object({
id: Type.String({ format: 'uuid' }),
name: Type.String(),
email: Type.String(),
createdAt: Type.String({ format: 'date-time' }),
});
// TypeScript types are derived automatically
type CreateUserBodyType = Static<typeof CreateUserBody>;
type UserResponseType = Static<typeof UserResponse>;
app.post<{
Body: CreateUserBodyType;
Reply: UserResponseType;
}>('/users', {
schema: {
body: CreateUserBody,
response: {
201: UserResponse,
},
},
}, async (request, reply) => {
// request.body is fully typed as CreateUserBodyType
const user = await createUser(request.body);
reply.code(201);
return user;
});
```
## TypeBox Common Patterns
```typescript
import { Type, type Static } from '@sinclair/typebox';
// Enums
const Status = Type.Union([
Type.Literal('active'),
Type.Literal('inactive'),
Type.Literal('pending'),
]);
// Arrays
const Tags = Type.Array(Type.String(), { minItems: 1, maxItems: 10 });
// Nested objects
const Address = Type.Object({
street: Type.String(),
city: Type.String(),
country: Type.String(),
zip: Type.Optional(Type.String()),
});
// References (reusable schemas)
const User = Type.Object({
id: Type.String({ format: 'uuid' }),
name: Type.String(),
address: Address,
tags: Tags,
status: Status,
});
// Nullable
const NullableString = Type.Union([Type.String(), Type.Null()]);
// Record/Map
const Metadata = Type.Record(Type.String(), Type.Unknown());
```
## Register TypeBox Schemas Globally
```typescript
import { Type, type Static } from '@sinclair/typebox';
// Define shared schemas
const ErrorResponse = Type.Object({
error: Type.String(),
message: Type.String(),
statusCode: Type.Integer(),
});
const PaginationQuery = Type.Object({
page: Type.Integer({ minimum: 1, default: 1 }),
limit: Type.Integer({ minimum: 1, maximum: 100, default: 20 }),
});
// Register globally
app.addSchema(Type.Object({ $id: 'ErrorResponse', ...ErrorResponse }));
app.addSchema(Type.Object({ $id: 'PaginationQuery', ...PaginationQuery }));
// Reference in routes
app.get('/items', {
schema: {
querystring: { $ref: 'PaginationQuery#' },
response: {
400: { $ref: 'ErrorResponse#' },
},
},
}, handler);
```
## Plain JSON Schema (Alternative)
You can also use plain JSON Schema directly:
```typescript
import Fastify from 'fastify';
const app = Fastify();
const createUserSchema = {
body: {
type: 'object',
properties: {
name: { type: 'string', minLength: 1, maxLength: 100 },
email: { type: 'string', format: 'email' },
age: { type: 'integer', minimum: 0, maximum: 150 },
},
required: ['name', 'email'],
additionalProperties: false,
},
response: {
201: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string' },
email: { type: 'string' },
createdAt: { type: 'string', format: 'date-time' },
},
},
},
};
app.post('/users', { schema: createUserSchema }, async (request, reply) => {
const user = await createUser(request.body);
reply.code(201);
return user;
});
```
## Request Validation Parts
Validate different parts of the request:
```typescript
const fullRequestSchema = {
// URL parameters
params: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
},
required: ['id'],
},
// Query string
querystring: {
type: 'object',
properties: {
include: { type: 'string', enum: ['posts', 'comments', 'all'] },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
},
},
// Request headers
headers: {
type: 'object',
properties: {
'x-api-key': { type: 'string', minLength: 32 },
},
required: ['x-api-key'],
},
// Request body
body: {
type: 'object',
properties: {
data: { type: 'object' },
},
required: ['data'],
},
};
app.put('/resources/:id', { schema: fullRequestSchema }, handler);
```
## Shared Schemas with $id
Define reusable schemas with `$id` and reference them with `$ref`:
```typescript
// Add shared schemas to Fastify
app.addSchema({
$id: 'user',
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string' },
email: { type: 'string', format: 'email' },
createdAt: { type: 'string', format: 'date-time' },
},
required: ['id', 'name', 'email'],
});
app.addSchema({
$id: 'userCreate',
type: 'object',
properties: {
name: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email' },
},
required: ['name', 'email'],
additionalProperties: false,
});
app.addSchema({
$id: 'error',
type: 'object',
properties: {
statusCode: { type: 'integer' },
error: { type: 'string' },
message: { type: 'string' },
},
});
// Reference shared schemas
app.post('/users', {
schema: {
body: { $ref: 'userCreate#' },
response: {
201: { $ref: 'user#' },
400: { $ref: 'error#' },
},
},
}, handler);
app.get('/users/:id', {
schema: {
params: {
type: 'object',
properties: { id: { type: 'string', format: 'uuid' } },
required: ['id'],
},
response: {
200: { $ref: 'user#' },
404: { $ref: 'error#' },
},
},
}, handler);
```
## Array Schemas
Define schemas for array responses:
```typescript
app.addSchema({
$id: 'userList',
type: 'object',
properties: {
users: {
type: 'array',
items: { $ref: 'user#' },
},
total: { type: 'integer' },
page: { type: 'integer' },
pageSize: { type: 'integer' },
},
});
app.get('/users', {
schema: {
querystring: {
type: 'object',
properties: {
page: { type: 'integer', minimum: 1, default: 1 },
pageSize: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
},
},
response: {
200: { $ref: 'userList#' },
},
},
}, handler);
```
## Custom Formats
Add custom validation formats:
```typescript
import Fastify from 'fastify';
const app = Fastify({
ajv: {
customOptions: {
formats: {
'iso-country': /^[A-Z]{2}$/,
'phone': /^\+?[1-9]\d{1,14}$/,
},
},
},
});
// Or add formats dynamically
app.addSchema({
$id: 'address',
type: 'object',
properties: {
street: { type: 'string' },
country: { type: 'string', format: 'iso-country' },
phone: { type: 'string', format: 'phone' },
},
});
```
## Custom Keywords
Add custom validation keywords:
```typescript
import Fastify from 'fastify';
import Ajv from 'ajv';
const app = Fastify({
ajv: {
customOptions: {
keywords: [
{
keyword: 'isEven',
type: 'number',
validate: (schema: boolean, data: number) => {
if (schema) {
return data % 2 === 0;
}
return true;
},
errors: false,
},
],
},
},
});
// Use custom keyword
app.post('/numbers', {
schema: {
body: {
type: 'object',
properties: {
value: { type: 'integer', isEven: true },
},
},
},
}, handler);
```
## Coercion
Fastify coerces types by default for query strings and params:
```typescript
// Query string "?page=5&active=true" becomes:
// { page: 5, active: true } (number and boolean, not strings)
app.get('/items', {
schema: {
querystring: {
type: 'object',
properties: {
page: { type: 'integer' }, // "5" -> 5
active: { type: 'boolean' }, // "true" -> true
tags: {
type: 'array',
items: { type: 'string' }, // "a,b,c" -> ["a", "b", "c"]
},
},
},
},
}, handler);
```
## Validation Error Handling
Customize validation error responses:
```typescript
app.setErrorHandler((error, request, reply) => {
if (error.validation) {
reply.code(400).send({
error: 'Validation Error',
message: 'Request validation failed',
details: error.validation.map((err) => ({
field: err.instancePath || err.params?.missingProperty,
message: err.message,
keyword: err.keyword,
})),
});
return;
}
// Handle other errors
reply.code(error.statusCode || 500).send({
error: error.name,
message: error.message,
});
});
```
## Schema Compiler Options
Configure the Ajv schema compiler:
```typescript
import Fastify from 'fastify';
const app = Fastify({
ajv: {
customOptions: {
removeAdditional: 'all', // Remove extra properties
useDefaults: true, // Apply default values
coerceTypes: true, // Coerce types
allErrors: true, // Report all errors, not just first
},
plugins: [
require('ajv-formats'), // Add format validators
],
},
});
```
## Nullable Fields
Handle nullable fields properly:
```typescript
app.addSchema({
$id: 'profile',
type: 'object',
properties: {
name: { type: 'string' },
bio: { type: ['string', 'null'] }, // Can be string or null
avatar: {
oneOf: [
{ type: 'string', format: 'uri' },
{ type: 'null' },
],
},
},
});
```
## Conditional Validation
Use if/then/else for conditional validation:
```typescript
app.addSchema({
$id: 'payment',
type: 'object',
properties: {
method: { type: 'string', enum: ['card', 'bank'] },
cardNumber: { type: 'string' },
bankAccount: { type: 'string' },
},
required: ['method'],
if: {
properties: { method: { const: 'card' } },
},
then: {
required: ['cardNumber'],
},
else: {
required: ['bankAccount'],
},
});
```
## Schema Organization
Organize schemas in a dedicated file:
```typescript
// schemas/index.ts
export const schemas = [
{
$id: 'user',
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string' },
email: { type: 'string', format: 'email' },
},
},
{
$id: 'error',
type: 'object',
properties: {
statusCode: { type: 'integer' },
error: { type: 'string' },
message: { type: 'string' },
},
},
];
// app.ts
import { schemas } from './schemas/index.js';
for (const schema of schemas) {
app.addSchema(schema);
}
```
## OpenAPI/Swagger Integration
Schemas work directly with @fastify/swagger:
```typescript
import fastifySwagger from '@fastify/swagger';
import fastifySwaggerUi from '@fastify/swagger-ui';
app.register(fastifySwagger, {
openapi: {
info: {
title: 'My API',
version: '1.0.0',
},
},
});
app.register(fastifySwaggerUi, {
routePrefix: '/docs',
});
// Schemas are automatically converted to OpenAPI definitions
```
## Performance Considerations
Response schemas enable fast-json-stringify for serialization:
```typescript
// With response schema - uses fast-json-stringify (faster)
app.get('/users', {
schema: {
response: {
200: {
type: 'array',
items: { $ref: 'user#' },
},
},
},
}, handler);
// Without response schema - uses JSON.stringify (slower)
app.get('/users-slow', handler);
```
Always define response schemas for production APIs to benefit from optimized serialization.
rules/serialization.md›
---
name: serialization
description: Response serialization in Fastify with TypeBox
metadata:
tags: serialization, response, json, fast-json-stringify, typebox
---
# Response Serialization
## Contents
- [Use TypeBox for Type-Safe Response Schemas](#use-typebox-for-type-safe-response-schemas)
- [Fast JSON Stringify](#fast-json-stringify)
- [Response Schema Benefits](#response-schema-benefits)
- [Multiple Status Code Schemas](#multiple-status-code-schemas)
- [Default Response Schema](#default-response-schema)
- [Custom Serializers](#custom-serializers)
- [Shared Serializers](#shared-serializers)
- [Serialization with Type Coercion](#serialization-with-type-coercion)
- [Nullable Fields](#nullable-fields)
- [Additional Properties](#additional-properties)
- [Nested Objects](#nested-objects)
- [Date Serialization](#date-serialization)
- [BigInt Serialization](#bigint-serialization)
- [Stream Responses](#stream-responses)
- [Pre-Serialization Hook](#pre-serialization-hook)
- [Disable Serialization](#disable-serialization)
## Use TypeBox for Type-Safe Response Schemas
Define response schemas with TypeBox for automatic TypeScript types and fast serialization:
```typescript
import Fastify from 'fastify';
import { Type, type Static } from '@sinclair/typebox';
const app = Fastify();
// Define response schema with TypeBox
const UserResponse = Type.Object({
id: Type.String(),
name: Type.String(),
email: Type.String(),
});
const UsersResponse = Type.Array(UserResponse);
type UserResponseType = Static<typeof UserResponse>;
// With TypeBox schema - uses fast-json-stringify (faster) + TypeScript types
app.get<{ Reply: Static<typeof UsersResponse> }>('/users', {
schema: {
response: {
200: UsersResponse,
},
},
}, async () => {
return db.users.findAll();
});
// Without schema - uses JSON.stringify (slower), no type safety
app.get('/users-slow', async () => {
return db.users.findAll();
});
```
## Fast JSON Stringify
Fastify uses `fast-json-stringify` when response schemas are defined. This provides:
1. **Performance**: 2-3x faster serialization than JSON.stringify
2. **Security**: Only defined properties are serialized (strips sensitive data)
3. **Type coercion**: Ensures output matches the schema
4. **TypeScript**: Full type inference with TypeBox
## Response Schema Benefits
1. **Performance**: 2-3x faster serialization
2. **Security**: Only defined properties are included
3. **Documentation**: OpenAPI/Swagger integration
4. **Type coercion**: Ensures correct output types
```typescript
app.get('/user/:id', {
schema: {
response: {
200: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
// password is NOT in schema, so it's stripped
},
},
},
},
}, async (request) => {
const user = await db.users.findById(request.params.id);
// Even if user has password field, it won't be serialized
return user;
});
```
## Multiple Status Code Schemas
Define schemas for different response codes:
```typescript
app.get('/users/:id', {
schema: {
response: {
200: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
email: { type: 'string' },
},
},
404: {
type: 'object',
properties: {
statusCode: { type: 'integer' },
error: { type: 'string' },
message: { type: 'string' },
},
},
},
},
}, async (request, reply) => {
const user = await db.users.findById(request.params.id);
if (!user) {
reply.code(404);
return { statusCode: 404, error: 'Not Found', message: 'User not found' };
}
return user;
});
```
## Default Response Schema
Use 'default' for common error responses:
```typescript
app.get('/resource', {
schema: {
response: {
200: { $ref: 'resource#' },
'4xx': {
type: 'object',
properties: {
statusCode: { type: 'integer' },
error: { type: 'string' },
message: { type: 'string' },
},
},
'5xx': {
type: 'object',
properties: {
statusCode: { type: 'integer' },
error: { type: 'string' },
},
},
},
},
}, handler);
```
## Custom Serializers
Create custom serialization functions:
```typescript
// Per-route serializer
app.get('/custom', {
schema: {
response: {
200: {
type: 'object',
properties: {
value: { type: 'string' },
},
},
},
},
serializerCompiler: ({ schema }) => {
return (data) => {
// Custom serialization logic
return JSON.stringify({
value: String(data.value).toUpperCase(),
serializedAt: new Date().toISOString(),
});
};
},
}, async () => {
return { value: 'hello' };
});
```
## Shared Serializers
Use the global serializer compiler:
```typescript
import Fastify from 'fastify';
const app = Fastify({
serializerCompiler: ({ schema, method, url, httpStatus }) => {
// Custom compilation logic
const stringify = fastJson(schema);
return (data) => stringify(data);
},
});
```
## Serialization with Type Coercion
fast-json-stringify coerces types:
```typescript
app.get('/data', {
schema: {
response: {
200: {
type: 'object',
properties: {
count: { type: 'integer' }, // '5' -> 5
active: { type: 'boolean' }, // 'true' -> true
tags: {
type: 'array',
items: { type: 'string' }, // [1, 2] -> ['1', '2']
},
},
},
},
},
}, async () => {
return {
count: '5', // Coerced to integer
active: 'true', // Coerced to boolean
tags: [1, 2, 3], // Coerced to strings
};
});
```
## Nullable Fields
Handle nullable fields properly:
```typescript
app.get('/profile', {
schema: {
response: {
200: {
type: 'object',
properties: {
name: { type: 'string' },
bio: { type: ['string', 'null'] },
avatar: {
oneOf: [
{ type: 'string', format: 'uri' },
{ type: 'null' },
],
},
},
},
},
},
}, async () => {
return {
name: 'John',
bio: null,
avatar: null,
};
});
```
## Additional Properties
Control extra properties in response:
```typescript
// Strip additional properties (default)
app.get('/strict', {
schema: {
response: {
200: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
},
additionalProperties: false,
},
},
},
}, async () => {
return { id: '1', name: 'John', secret: 'hidden' };
// Output: { "id": "1", "name": "John" }
});
// Allow additional properties
app.get('/flexible', {
schema: {
response: {
200: {
type: 'object',
properties: {
id: { type: 'string' },
},
additionalProperties: true,
},
},
},
}, async () => {
return { id: '1', extra: 'included' };
// Output: { "id": "1", "extra": "included" }
});
```
## Nested Objects
Serialize nested structures:
```typescript
app.addSchema({
$id: 'address',
type: 'object',
properties: {
street: { type: 'string' },
city: { type: 'string' },
country: { type: 'string' },
},
});
app.get('/user', {
schema: {
response: {
200: {
type: 'object',
properties: {
name: { type: 'string' },
address: { $ref: 'address#' },
contacts: {
type: 'array',
items: {
type: 'object',
properties: {
type: { type: 'string' },
value: { type: 'string' },
},
},
},
},
},
},
},
}, async () => {
return {
name: 'John',
address: { street: '123 Main', city: 'Boston', country: 'USA' },
contacts: [
{ type: 'email', value: '[email protected]' },
{ type: 'phone', value: '+1234567890' },
],
};
});
```
## Date Serialization
Handle dates consistently:
```typescript
app.get('/events', {
schema: {
response: {
200: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
date: { type: 'string', format: 'date-time' },
},
},
},
},
},
}, async () => {
const events = await db.events.findAll();
// Convert Date objects to ISO strings
return events.map((e) => ({
...e,
date: e.date.toISOString(),
}));
});
```
## BigInt Serialization
Handle BigInt values:
```typescript
// BigInt is not JSON serializable by default
app.get('/large-number', {
schema: {
response: {
200: {
type: 'object',
properties: {
id: { type: 'string' }, // Serialize as string
count: { type: 'integer' },
},
},
},
},
}, async () => {
const bigValue = 9007199254740993n;
return {
id: bigValue.toString(), // Convert to string
count: Number(bigValue), // Or number if safe
};
});
```
## Stream Responses
Stream responses bypass serialization:
```typescript
import { createReadStream } from 'node:fs';
app.get('/file', async (request, reply) => {
const stream = createReadStream('./data.json');
reply.type('application/json');
return reply.send(stream);
});
// Streaming JSON array
app.get('/stream', async (request, reply) => {
reply.type('application/json');
const cursor = db.users.findCursor();
reply.raw.write('[');
let first = true;
for await (const user of cursor) {
if (!first) reply.raw.write(',');
reply.raw.write(JSON.stringify(user));
first = false;
}
reply.raw.write(']');
reply.raw.end();
});
```
## Pre-Serialization Hook
Modify data before serialization:
```typescript
app.addHook('preSerialization', async (request, reply, payload) => {
// Add metadata to responses
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
return {
...payload,
_links: {
self: request.url,
},
};
}
return payload;
});
```
## Disable Serialization
Skip serialization for specific routes:
```typescript
app.get('/raw', async (request, reply) => {
const data = JSON.stringify({ raw: true });
reply.type('application/json');
reply.serializer((payload) => payload); // Pass through
return data;
});
```
rules/testing.md›
---
name: testing
description: Testing Fastify applications with inject()
metadata:
tags: testing, inject, node-test, integration, unit
---
# Testing Fastify Applications
## Contents
- [Using inject() for Request Testing](#using-inject-for-request-testing)
- [Testing with Headers and Authentication](#testing-with-headers-and-authentication)
- [Testing Query Parameters](#testing-query-parameters)
- [Testing URL Parameters](#testing-url-parameters)
- [Testing Validation Errors](#testing-validation-errors)
- [Testing File Uploads](#testing-file-uploads)
- [Testing Streams](#testing-streams)
- [Mocking Dependencies](#mocking-dependencies)
- [Testing Plugins in Isolation](#testing-plugins-in-isolation)
- [Testing Hooks](#testing-hooks)
- [Test Factory Pattern](#test-factory-pattern)
- [Database Testing with Transactions](#database-testing-with-transactions)
- [Parallel Test Execution](#parallel-test-execution)
- [Running Tests](#running-tests)
## Using inject() for Request Testing
Fastify's `inject()` method simulates HTTP requests without network overhead:
```typescript
import { describe, it, before, after } from 'node:test';
import Fastify from 'fastify';
import { buildApp } from './app.js';
describe('User API', () => {
let app;
before(async () => {
app = await buildApp();
await app.ready();
});
after(async () => {
await app.close();
});
it('should return users list', async (t) => {
const response = await app.inject({
method: 'GET',
url: '/users',
});
t.assert.equal(response.statusCode, 200);
t.assert.equal(response.headers['content-type'], 'application/json; charset=utf-8');
const body = response.json();
t.assert.ok(Array.isArray(body.users));
});
it('should create a user', async (t) => {
const response = await app.inject({
method: 'POST',
url: '/users',
payload: {
name: 'John Doe',
email: '[email protected]',
},
});
t.assert.equal(response.statusCode, 201);
const body = response.json();
t.assert.equal(body.name, 'John Doe');
t.assert.ok(body.id);
});
});
```
## Testing with Headers and Authentication
Test authenticated endpoints:
```typescript
describe('Protected Routes', () => {
let app;
let authToken;
before(async () => {
app = await buildApp();
await app.ready();
// Get auth token
const loginResponse = await app.inject({
method: 'POST',
url: '/auth/login',
payload: {
email: '[email protected]',
password: 'password123',
},
});
authToken = loginResponse.json().token;
});
after(async () => {
await app.close();
});
it('should reject unauthenticated requests', async (t) => {
const response = await app.inject({
method: 'GET',
url: '/profile',
});
t.assert.equal(response.statusCode, 401);
});
it('should return profile for authenticated user', async (t) => {
const response = await app.inject({
method: 'GET',
url: '/profile',
headers: {
authorization: `Bearer ${authToken}`,
},
});
t.assert.equal(response.statusCode, 200);
t.assert.equal(response.json().email, '[email protected]');
});
});
```
## Testing Query Parameters
Test routes with query strings:
```typescript
it('should filter users by status', async (t) => {
const response = await app.inject({
method: 'GET',
url: '/users',
query: {
status: 'active',
page: '1',
limit: '10',
},
});
t.assert.equal(response.statusCode, 200);
const body = response.json();
t.assert.ok(body.users.every((u) => u.status === 'active'));
});
// Or use URL with query string
it('should search users', async (t) => {
const response = await app.inject({
method: 'GET',
url: '/users?q=john&sort=name',
});
t.assert.equal(response.statusCode, 200);
});
```
## Testing URL Parameters
Test routes with path parameters:
```typescript
it('should return user by id', async (t) => {
const userId = 'user-123';
const response = await app.inject({
method: 'GET',
url: `/users/${userId}`,
});
t.assert.equal(response.statusCode, 200);
t.assert.equal(response.json().id, userId);
});
it('should return 404 for non-existent user', async (t) => {
const response = await app.inject({
method: 'GET',
url: '/users/non-existent',
});
t.assert.equal(response.statusCode, 404);
});
```
## Testing Validation Errors
Test schema validation:
```typescript
describe('Validation', () => {
it('should reject invalid email', async (t) => {
const response = await app.inject({
method: 'POST',
url: '/users',
payload: {
name: 'John',
email: 'not-an-email',
},
});
t.assert.equal(response.statusCode, 400);
const body = response.json();
t.assert.ok(body.message.includes('email'));
});
it('should reject missing required fields', async (t) => {
const response = await app.inject({
method: 'POST',
url: '/users',
payload: {
name: 'John',
// missing email
},
});
t.assert.equal(response.statusCode, 400);
});
it('should coerce query parameters', async (t) => {
const response = await app.inject({
method: 'GET',
url: '/items?limit=10&active=true',
});
t.assert.equal(response.statusCode, 200);
// limit is coerced to number, active to boolean
});
});
```
## Testing File Uploads
Test multipart form data:
```typescript
import { createReadStream } from 'node:fs';
import FormData from 'form-data';
it('should upload file', async (t) => {
const form = new FormData();
form.append('file', createReadStream('./test/fixtures/test.pdf'));
form.append('name', 'test-document');
const response = await app.inject({
method: 'POST',
url: '/upload',
payload: form,
headers: form.getHeaders(),
});
t.assert.equal(response.statusCode, 200);
t.assert.ok(response.json().fileId);
});
```
## Testing Streams
Test streaming responses:
```typescript
it('should stream large file', async (t) => {
const response = await app.inject({
method: 'GET',
url: '/files/large-file',
});
t.assert.equal(response.statusCode, 200);
t.assert.ok(response.rawPayload.length > 0);
});
```
## Mocking Dependencies
Mock external services and databases:
```typescript
import { describe, it, before, after, mock } from 'node:test';
describe('User Service', () => {
let app;
before(async () => {
// Create app with mocked dependencies
const mockDb = {
users: {
findAll: mock.fn(async () => [
{ id: '1', name: 'User 1' },
{ id: '2', name: 'User 2' },
]),
findById: mock.fn(async (id) => {
if (id === '1') return { id: '1', name: 'User 1' };
return null;
}),
create: mock.fn(async (data) => ({ id: 'new-id', ...data })),
},
};
app = Fastify();
app.decorate('db', mockDb);
app.register(import('./routes/users.js'));
await app.ready();
});
after(async () => {
await app.close();
});
it('should call findAll', async (t) => {
const response = await app.inject({
method: 'GET',
url: '/users',
});
t.assert.equal(response.statusCode, 200);
t.assert.equal(app.db.users.findAll.mock.calls.length, 1);
});
});
```
## Testing Plugins in Isolation
Test plugins independently:
```typescript
import { describe, it, before, after } from 'node:test';
import Fastify from 'fastify';
import cachePlugin from './plugins/cache.js';
describe('Cache Plugin', () => {
let app;
before(async () => {
app = Fastify();
app.register(cachePlugin, { ttl: 1000 });
await app.ready();
});
after(async () => {
await app.close();
});
it('should decorate fastify with cache', (t) => {
t.assert.ok(app.hasDecorator('cache'));
t.assert.equal(typeof app.cache.get, 'function');
t.assert.equal(typeof app.cache.set, 'function');
});
it('should cache and retrieve values', (t) => {
app.cache.set('key', 'value');
t.assert.equal(app.cache.get('key'), 'value');
});
});
```
## Testing Hooks
Test hook behavior:
```typescript
describe('Hooks', () => {
it('should add request id header', async (t) => {
const response = await app.inject({
method: 'GET',
url: '/health',
});
t.assert.ok(response.headers['x-request-id']);
});
it('should log request timing', async (t) => {
const logs = [];
const app = Fastify({
logger: {
level: 'info',
stream: {
write: (msg) => logs.push(JSON.parse(msg)),
},
},
});
app.register(import('./app.js'));
await app.ready();
await app.inject({ method: 'GET', url: '/health' });
const responseLog = logs.find((l) => l.msg?.includes('completed'));
t.assert.ok(responseLog);
t.assert.ok(responseLog.responseTime);
await app.close();
});
});
```
## Test Factory Pattern
Create a reusable test app builder:
```typescript
// test/helper.ts
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
interface TestContext {
app: FastifyInstance;
inject: FastifyInstance['inject'];
}
export async function buildTestApp(options = {}): Promise<TestContext> {
const app = Fastify({
logger: false, // Disable logging in tests
...options,
});
// Register plugins
app.register(import('../src/plugins/database.js'), {
connectionString: process.env.TEST_DATABASE_URL,
});
app.register(import('../src/routes/index.js'));
await app.ready();
return {
app,
inject: app.inject.bind(app),
};
}
// Usage in tests
describe('API Tests', () => {
let ctx: TestContext;
before(async () => {
ctx = await buildTestApp();
});
after(async () => {
await ctx.app.close();
});
it('should work', async (t) => {
const response = await ctx.inject({
method: 'GET',
url: '/health',
});
t.assert.equal(response.statusCode, 200);
});
});
```
## Database Testing with Transactions
Use transactions for test isolation:
```typescript
describe('Database Integration', () => {
let app;
let transaction;
before(async () => {
app = await buildApp();
await app.ready();
});
after(async () => {
await app.close();
});
beforeEach(async () => {
transaction = await app.db.beginTransaction();
app.db.setTransaction(transaction);
});
afterEach(async () => {
await transaction.rollback();
});
it('should create user', async (t) => {
const response = await app.inject({
method: 'POST',
url: '/users',
payload: { name: 'Test', email: '[email protected]' },
});
t.assert.equal(response.statusCode, 201);
// Transaction is rolled back after test
});
});
```
## Parallel Test Execution
Structure tests for parallel execution:
```typescript
// Tests run in parallel by default with node:test
// Use separate app instances or proper isolation
import { describe, it } from 'node:test';
describe('User API', async () => {
// Each test suite gets its own app instance
const app = await buildTestApp();
it('test 1', async (t) => {
// ...
});
it('test 2', async (t) => {
// ...
});
// Cleanup after all tests in this suite
after(() => app.close());
});
describe('Post API', async () => {
const app = await buildTestApp();
it('test 1', async (t) => {
// ...
});
after(() => app.close());
});
```
## Running Tests
```bash
# Run all tests
node --test
# Run with TypeScript
node --test src/**/*.test.ts
# Run specific file
node --test src/routes/users.test.ts
# With coverage
node --test --experimental-test-coverage
# Watch mode
node --test --watch
```
rules/typescript.md›
---
name: typescript
description: TypeScript integration with Fastify
metadata:
tags: typescript, types, generics, type-safety
---
# TypeScript Integration
## Contents
- [Type Stripping with Node.js](#type-stripping-with-nodejs)
- [Basic Type Safety](#basic-type-safety)
- [Typing Route Handlers](#typing-route-handlers)
- [Type Providers](#type-providers)
- [Typing Decorators](#typing-decorators)
- [Typing Plugins](#typing-plugins)
- [Typing Hooks](#typing-hooks)
- [Typing Schema Objects](#typing-schema-objects)
- [Shared Types](#shared-types)
- [Type-Safe Route Registration](#type-safe-route-registration)
- [Avoiding Type Gymnastics](#avoiding-type-gymnastics)
- [Type Checking Without Compilation](#type-checking-without-compilation)
## Type Stripping with Node.js
Use Node.js built-in type stripping (Node.js 22.6+):
```bash
# Run TypeScript directly
node --experimental-strip-types app.ts
# In Node.js 23+
node app.ts
```
```json
// package.json
{
"type": "module",
"scripts": {
"start": "node app.ts",
"dev": "node --watch app.ts"
}
}
```
```typescript
// tsconfig.json for type stripping
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"noEmit": true,
"strict": true
}
}
```
## Basic Type Safety
Type your Fastify application:
```typescript
import Fastify, { type FastifyInstance, type FastifyRequest, type FastifyReply } from 'fastify';
const app: FastifyInstance = Fastify({ logger: true });
app.get('/health', async (request: FastifyRequest, reply: FastifyReply) => {
return { status: 'ok' };
});
await app.listen({ port: 3000 });
```
## Typing Route Handlers
Use generics to type request parts:
```typescript
import type { FastifyRequest, FastifyReply } from 'fastify';
interface CreateUserBody {
name: string;
email: string;
}
interface UserParams {
id: string;
}
interface UserQuery {
include?: string;
}
// Type the request with generics
app.post<{
Body: CreateUserBody;
}>('/users', async (request, reply) => {
const { name, email } = request.body; // Fully typed
return { name, email };
});
app.get<{
Params: UserParams;
Querystring: UserQuery;
}>('/users/:id', async (request) => {
const { id } = request.params; // string
const { include } = request.query; // string | undefined
return { id, include };
});
// Full route options typing
app.route<{
Params: UserParams;
Querystring: UserQuery;
Body: CreateUserBody;
Reply: { user: { id: string; name: string } };
}>({
method: 'PUT',
url: '/users/:id',
handler: async (request, reply) => {
return { user: { id: request.params.id, name: request.body.name } };
},
});
```
## Type Providers
Use @fastify/type-provider-typebox for runtime + compile-time safety:
```typescript
import Fastify from 'fastify';
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
import { Type } from '@sinclair/typebox';
const app = Fastify().withTypeProvider<TypeBoxTypeProvider>();
const UserSchema = Type.Object({
id: Type.String(),
name: Type.String(),
email: Type.String({ format: 'email' }),
});
const CreateUserSchema = Type.Object({
name: Type.String({ minLength: 1 }),
email: Type.String({ format: 'email' }),
});
app.post('/users', {
schema: {
body: CreateUserSchema,
response: {
201: UserSchema,
},
},
}, async (request, reply) => {
// request.body is typed as { name: string; email: string }
const { name, email } = request.body;
reply.code(201);
return { id: 'generated', name, email };
});
```
## Typing Decorators
Extend Fastify types with declaration merging:
```typescript
import Fastify from 'fastify';
// Declare types for decorators
declare module 'fastify' {
interface FastifyInstance {
config: {
port: number;
host: string;
};
db: Database;
}
interface FastifyRequest {
user?: {
id: string;
email: string;
role: string;
};
startTime: number;
}
interface FastifyReply {
sendSuccess: (data: unknown) => void;
}
}
const app = Fastify();
// Add decorators
app.decorate('config', { port: 3000, host: 'localhost' });
app.decorate('db', new Database());
app.decorateRequest('user', null);
app.decorateRequest('startTime', 0);
app.decorateReply('sendSuccess', function (data: unknown) {
this.send({ success: true, data });
});
// Now fully typed
app.get('/profile', async (request, reply) => {
const user = request.user; // { id: string; email: string; role: string } | undefined
const config = app.config; // { port: number; host: string }
reply.sendSuccess({ user });
});
```
## Typing Plugins
Type plugin options and exports:
```typescript
import fp from 'fastify-plugin';
import type { FastifyPluginAsync } from 'fastify';
interface DatabasePluginOptions {
connectionString: string;
poolSize?: number;
}
declare module 'fastify' {
interface FastifyInstance {
db: {
query: (sql: string, params?: unknown[]) => Promise<unknown[]>;
close: () => Promise<void>;
};
}
}
const databasePlugin: FastifyPluginAsync<DatabasePluginOptions> = async (
fastify,
options,
) => {
const { connectionString, poolSize = 10 } = options;
const db = await createConnection(connectionString, poolSize);
fastify.decorate('db', {
query: (sql: string, params?: unknown[]) => db.query(sql, params),
close: () => db.end(),
});
fastify.addHook('onClose', async () => {
await db.end();
});
};
export default fp(databasePlugin, {
name: 'database',
});
```
## Typing Hooks
Type hook functions:
```typescript
import type {
FastifyRequest,
FastifyReply,
onRequestHookHandler,
preHandlerHookHandler,
} from 'fastify';
const authHook: preHandlerHookHandler = async (
request: FastifyRequest,
reply: FastifyReply,
) => {
const token = request.headers.authorization;
if (!token) {
reply.code(401).send({ error: 'Unauthorized' });
return;
}
request.user = await verifyToken(token);
};
const timingHook: onRequestHookHandler = async (request) => {
request.startTime = Date.now();
};
app.addHook('onRequest', timingHook);
app.addHook('preHandler', authHook);
```
## Typing Schema Objects
Create reusable typed schemas:
```typescript
import type { JSONSchema7 } from 'json-schema';
// Define schema with const assertion for type inference
const userSchema = {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
email: { type: 'string', format: 'email' },
},
required: ['id', 'name', 'email'],
} as const satisfies JSONSchema7;
// Infer TypeScript type from schema
type User = {
id: string;
name: string;
email: string;
};
app.get<{ Reply: User }>('/users/:id', {
schema: {
response: {
200: userSchema,
},
},
}, async (request) => {
return { id: '1', name: 'John', email: '[email protected]' };
});
```
## Shared Types
Organize types in dedicated files:
```typescript
// types/index.ts
export interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
}
export interface CreateUserInput {
name: string;
email: string;
}
export interface PaginationQuery {
page?: number;
limit?: number;
sort?: string;
}
// routes/users.ts
import type { FastifyInstance } from 'fastify';
import type { User, CreateUserInput, PaginationQuery } from '../types/index.js';
export default async function userRoutes(fastify: FastifyInstance) {
fastify.get<{
Querystring: PaginationQuery;
Reply: { users: User[]; total: number };
}>('/', async (request) => {
const { page = 1, limit = 10 } = request.query;
// ...
});
fastify.post<{
Body: CreateUserInput;
Reply: User;
}>('/', async (request, reply) => {
reply.code(201);
// ...
});
}
```
## Type-Safe Route Registration
Create typed route factories:
```typescript
import type { FastifyInstance, RouteOptions } from 'fastify';
function createCrudRoutes<T extends { id: string }>(
fastify: FastifyInstance,
options: {
prefix: string;
schema: {
item: object;
create: object;
update: object;
};
handlers: {
list: () => Promise<T[]>;
get: (id: string) => Promise<T | null>;
create: (data: unknown) => Promise<T>;
update: (id: string, data: unknown) => Promise<T>;
delete: (id: string) => Promise<void>;
};
},
) {
const { prefix, schema, handlers } = options;
fastify.get(`${prefix}`, {
schema: { response: { 200: { type: 'array', items: schema.item } } },
}, async () => handlers.list());
fastify.get(`${prefix}/:id`, {
schema: { response: { 200: schema.item } },
}, async (request) => {
const item = await handlers.get((request.params as { id: string }).id);
if (!item) throw { statusCode: 404, message: 'Not found' };
return item;
});
// ... more routes
}
```
## Avoiding Type Gymnastics
Keep types simple and practical:
```typescript
// GOOD - simple, readable types
interface UserRequest {
Params: { id: string };
Body: { name: string };
}
app.put<UserRequest>('/users/:id', handler);
// AVOID - overly complex generic types
type DeepPartial<T> = T extends object ? {
[P in keyof T]?: DeepPartial<T[P]>;
} : T;
// AVOID - excessive type inference
type InferSchemaType<T> = T extends { properties: infer P }
? { [K in keyof P]: InferPropertyType<P[K]> }
: never;
```
## Type Checking Without Compilation
Use TypeScript for type checking only:
```bash
# Type check without emitting
npx tsc --noEmit
# Watch mode
npx tsc --noEmit --watch
# In CI
npm run typecheck
```
```json
// package.json
{
"scripts": {
"start": "node app.ts",
"typecheck": "tsc --noEmit",
"test": "npm run typecheck && node --test"
}
}
```
rules/websockets.md›
---
name: websockets
description: WebSocket support in Fastify
metadata:
tags: websockets, realtime, ws, socket
---
# WebSocket Support
## Contents
- [Using @fastify/websocket](#using-fastifywebsocket)
- [WebSocket with Hooks](#websocket-with-hooks)
- [Connection Options](#connection-options)
- [Broadcast to All Clients](#broadcast-to-all-clients)
- [Rooms/Channels Pattern](#roomschannels-pattern)
- [Structured Message Protocol](#structured-message-protocol)
- [Heartbeat/Ping-Pong](#heartbeatping-pong)
- [Authentication](#authentication)
- [Error Handling](#error-handling)
- [Rate Limiting WebSocket Messages](#rate-limiting-websocket-messages)
- [Graceful Shutdown](#graceful-shutdown)
- [Full-Duplex Stream Pattern](#full-duplex-stream-pattern)
## Using @fastify/websocket
Add WebSocket support to Fastify:
```typescript
import Fastify from 'fastify';
import websocket from '@fastify/websocket';
const app = Fastify();
app.register(websocket);
app.get('/ws', { websocket: true }, (socket, request) => {
socket.on('message', (message) => {
const data = message.toString();
console.log('Received:', data);
// Echo back
socket.send(`Echo: ${data}`);
});
socket.on('close', () => {
console.log('Client disconnected');
});
socket.on('error', (error) => {
console.error('WebSocket error:', error);
});
});
await app.listen({ port: 3000 });
```
## WebSocket with Hooks
Use Fastify hooks with WebSocket routes:
```typescript
app.register(async function wsRoutes(fastify) {
// This hook runs before WebSocket upgrade
fastify.addHook('preValidation', async (request, reply) => {
const token = request.headers.authorization;
if (!token) {
reply.code(401).send({ error: 'Unauthorized' });
return;
}
request.user = await verifyToken(token);
});
fastify.get('/ws', { websocket: true }, (socket, request) => {
console.log('Connected user:', request.user.id);
socket.on('message', (message) => {
// Handle authenticated messages
});
});
});
```
## Connection Options
Configure WebSocket server options:
```typescript
app.register(websocket, {
options: {
maxPayload: 1048576, // 1MB max message size
clientTracking: true,
perMessageDeflate: {
zlibDeflateOptions: {
chunkSize: 1024,
memLevel: 7,
level: 3,
},
zlibInflateOptions: {
chunkSize: 10 * 1024,
},
},
},
});
```
## Broadcast to All Clients
Broadcast messages to connected clients:
```typescript
const clients = new Set<WebSocket>();
app.get('/ws', { websocket: true }, (socket, request) => {
clients.add(socket);
socket.on('close', () => {
clients.delete(socket);
});
socket.on('message', (message) => {
// Broadcast to all other clients
for (const client of clients) {
if (client !== socket && client.readyState === WebSocket.OPEN) {
client.send(message);
}
}
});
});
// Broadcast from HTTP route
app.post('/broadcast', async (request) => {
const { message } = request.body;
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({ type: 'broadcast', message }));
}
}
return { sent: clients.size };
});
```
## Rooms/Channels Pattern
Organize connections into rooms:
```typescript
const rooms = new Map<string, Set<WebSocket>>();
function joinRoom(socket: WebSocket, roomId: string) {
if (!rooms.has(roomId)) {
rooms.set(roomId, new Set());
}
rooms.get(roomId)!.add(socket);
}
function leaveRoom(socket: WebSocket, roomId: string) {
rooms.get(roomId)?.delete(socket);
if (rooms.get(roomId)?.size === 0) {
rooms.delete(roomId);
}
}
function broadcastToRoom(roomId: string, message: string, exclude?: WebSocket) {
const room = rooms.get(roomId);
if (!room) return;
for (const client of room) {
if (client !== exclude && client.readyState === WebSocket.OPEN) {
client.send(message);
}
}
}
app.get('/ws/:roomId', { websocket: true }, (socket, request) => {
const { roomId } = request.params as { roomId: string };
joinRoom(socket, roomId);
socket.on('message', (message) => {
broadcastToRoom(roomId, message.toString(), socket);
});
socket.on('close', () => {
leaveRoom(socket, roomId);
});
});
```
## Structured Message Protocol
Use JSON for structured messages:
```typescript
interface WSMessage {
type: string;
payload?: unknown;
id?: string;
}
app.get('/ws', { websocket: true }, (socket, request) => {
function send(message: WSMessage) {
socket.send(JSON.stringify(message));
}
socket.on('message', (raw) => {
let message: WSMessage;
try {
message = JSON.parse(raw.toString());
} catch {
send({ type: 'error', payload: 'Invalid JSON' });
return;
}
switch (message.type) {
case 'ping':
send({ type: 'pong', id: message.id });
break;
case 'subscribe':
handleSubscribe(socket, message.payload);
send({ type: 'subscribed', payload: message.payload, id: message.id });
break;
case 'message':
handleMessage(socket, message.payload);
break;
default:
send({ type: 'error', payload: 'Unknown message type' });
}
});
});
```
## Heartbeat/Ping-Pong
Keep connections alive:
```typescript
const HEARTBEAT_INTERVAL = 30000;
const clients = new Map<WebSocket, { isAlive: boolean }>();
app.get('/ws', { websocket: true }, (socket, request) => {
clients.set(socket, { isAlive: true });
socket.on('pong', () => {
const client = clients.get(socket);
if (client) client.isAlive = true;
});
socket.on('close', () => {
clients.delete(socket);
});
});
// Heartbeat interval
setInterval(() => {
for (const [socket, state] of clients) {
if (!state.isAlive) {
socket.terminate();
clients.delete(socket);
continue;
}
state.isAlive = false;
socket.ping();
}
}, HEARTBEAT_INTERVAL);
```
## Authentication
Authenticate WebSocket connections:
```typescript
app.get('/ws', {
websocket: true,
preValidation: async (request, reply) => {
// Authenticate via query parameter or header
const token = request.query.token || request.headers.authorization?.replace('Bearer ', '');
if (!token) {
reply.code(401).send({ error: 'Token required' });
return;
}
try {
request.user = await verifyToken(token);
} catch {
reply.code(401).send({ error: 'Invalid token' });
}
},
}, (socket, request) => {
console.log('Authenticated user:', request.user);
socket.on('message', (message) => {
// Handle authenticated messages
});
});
```
## Error Handling
Handle WebSocket errors properly:
```typescript
app.get('/ws', { websocket: true }, (socket, request) => {
socket.on('error', (error) => {
request.log.error({ err: error }, 'WebSocket error');
});
socket.on('message', async (raw) => {
try {
const message = JSON.parse(raw.toString());
const result = await processMessage(message);
socket.send(JSON.stringify({ success: true, result }));
} catch (error) {
request.log.error({ err: error }, 'Message processing error');
socket.send(JSON.stringify({
success: false,
error: error.message,
}));
}
});
});
```
## Rate Limiting WebSocket Messages
Limit message frequency:
```typescript
const rateLimits = new Map<WebSocket, { count: number; resetAt: number }>();
function checkRateLimit(socket: WebSocket, limit: number, window: number): boolean {
const now = Date.now();
let state = rateLimits.get(socket);
if (!state || now > state.resetAt) {
state = { count: 0, resetAt: now + window };
rateLimits.set(socket, state);
}
state.count++;
if (state.count > limit) {
return false;
}
return true;
}
app.get('/ws', { websocket: true }, (socket, request) => {
socket.on('message', (message) => {
if (!checkRateLimit(socket, 100, 60000)) {
socket.send(JSON.stringify({ error: 'Rate limit exceeded' }));
return;
}
// Process message
});
socket.on('close', () => {
rateLimits.delete(socket);
});
});
```
## Graceful Shutdown
Close WebSocket connections on shutdown:
```typescript
import closeWithGrace from 'close-with-grace';
const connections = new Set<WebSocket>();
app.get('/ws', { websocket: true }, (socket, request) => {
connections.add(socket);
socket.on('close', () => {
connections.delete(socket);
});
});
closeWithGrace({ delay: 5000 }, async ({ signal }) => {
// Notify clients
for (const socket of connections) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'shutdown', message: 'Server is shutting down' }));
socket.close(1001, 'Server shutdown');
}
}
await app.close();
});
```
## Full-Duplex Stream Pattern
Use WebSocket for streaming data:
```typescript
app.get('/ws/stream', { websocket: true }, async (socket, request) => {
const stream = createDataStream();
stream.on('data', (data) => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'data', payload: data }));
}
});
stream.on('end', () => {
socket.send(JSON.stringify({ type: 'end' }));
socket.close();
});
socket.on('message', (message) => {
const { type, payload } = JSON.parse(message.toString());
if (type === 'pause') {
stream.pause();
} else if (type === 'resume') {
stream.resume();
}
});
socket.on('close', () => {
stream.destroy();
});
});
```
SKILL.md›
---
name: fastify-best-practices
description: "Guides development of Fastify Node.js backend servers and REST APIs using TypeScript or JavaScript. Use when building, configuring, or debugging a Fastify application — including defining routes, implementing plugins, setting up JSON Schema validation, handling errors, optimising performance, managing authentication, configuring CORS and security headers, integrating databases, working with WebSockets, and deploying to production. Covers the full Fastify request lifecycle (hooks, serialization, logging with Pino) and TypeScript integration via strip types. Trigger terms: Fastify, Node.js server, REST API, API routes, backend framework, fastify.config, server.ts, app.ts."
metadata:
tags: fastify, nodejs, typescript, backend, api, server, http
---
## When to use
Use this skill when you need to:
- Develop backend applications using Fastify
- Implement Fastify plugins and route handlers
- Get guidance on Fastify architecture and patterns
- Use TypeScript with Fastify (strip types)
- Implement testing with Fastify's inject method
- Configure validation, serialization, and error handling
## Quick Start
A minimal, runnable Fastify server to get started immediately:
```ts
import Fastify from 'fastify'
const app = Fastify({ logger: true })
app.get('/health', async (request, reply) => {
return { status: 'ok' }
})
const start = async () => {
await app.listen({ port: 3000, host: '0.0.0.0' })
}
start()
```
## Recommended Reading Order for Common Scenarios
- **New to Fastify?** Start with `plugins.md` → `routes.md` → `schemas.md`
- **Adding authentication:** `plugins.md` → `hooks.md` → `authentication.md`
- **Improving performance:** `schemas.md` → `serialization.md` → `performance.md`
- **Setting up testing:** `routes.md` → `testing.md`
- **Going to production:** `logging.md` → `configuration.md` → `deployment.md`
## How to use
Read individual rule files for detailed explanations and code examples:
- [rules/plugins.md](rules/plugins.md) - Plugin development and encapsulation
- [rules/routes.md](rules/routes.md) - Route organization and handlers
- [rules/schemas.md](rules/schemas.md) - JSON Schema validation
- [rules/error-handling.md](rules/error-handling.md) - Error handling patterns
- [rules/hooks.md](rules/hooks.md) - Hooks and request lifecycle
- [rules/authentication.md](rules/authentication.md) - Authentication and authorization
- [rules/testing.md](rules/testing.md) - Testing with inject()
- [rules/performance.md](rules/performance.md) - Performance optimization
- [rules/logging.md](rules/logging.md) - Logging with Pino
- [rules/typescript.md](rules/typescript.md) - TypeScript integration
- [rules/decorators.md](rules/decorators.md) - Decorators and extensions
- [rules/content-type.md](rules/content-type.md) - Content type parsing
- [rules/serialization.md](rules/serialization.md) - Response serialization
- [rules/cors-security.md](rules/cors-security.md) - CORS and security headers
- [rules/websockets.md](rules/websockets.md) - WebSocket support
- [rules/database.md](rules/database.md) - Database integration patterns
- [rules/configuration.md](rules/configuration.md) - Application configuration
- [rules/deployment.md](rules/deployment.md) - Production deployment
- [rules/http-proxy.md](rules/http-proxy.md) - HTTP proxying and reply.from()
## Core Principles
- **Encapsulation**: Fastify's plugin system provides automatic encapsulation
- **Schema-first**: Define schemas for validation and serialization
- **Performance**: Fastify is optimized for speed; use its features correctly
- **Async/await**: All handlers and hooks support async functions
- **Minimal dependencies**: Prefer Fastify's built-in features and official plugins
tile.json›
{
"name": "mcollina/fastify-best-practices",
"version": "0.1.0",
"private": false,
"summary": "Guides development of Fastify Node.js backend servers and REST APIs using TypeScript or JavaScript. Use when building, configuring, or debugging a Fastify application — including defining routes, implementing plugins, setting up JSON Schema validation, handling errors, optimising performance, managing authentication, configuring CORS and security headers, integrating databases, working with WebSockets, and deploying to production. Covers the full Fastify request lifecycle (hooks, serialization, logging with Pino) and TypeScript integration via strip types. Trigger terms: Fastify, Node.js server, REST API, API routes, backend framework, fastify.config, server.ts, app.ts.",
"skills": {
"fastify-best-practices": {
"path": "SKILL.md"
}
}
}