zeratus-api
v1.1.5
Published
A powerful, production-ready Fastify boilerplate library built by Zeratus for rapid API development
Maintainers
Readme
Zeratus API
A powerful, production-ready Fastify boilerplate library built by Zeratus for rapid API development. This library provides a comprehensive set of tools and abstractions for building scalable, maintainable APIs with TypeScript.
Features
- Fastify-based: Built on Fastify ^5.2 — the fastest Node.js web framework
- TypeScript First: Full TypeScript support with comprehensive type definitions and interfaces
- Modular Architecture: Interface-driven design with pluggable, swappable implementations
- ServiceAdapter Pattern: Every service implements a common lifecycle interface — connect, disconnect, and health check out of the box
- Swappable Services: Pass config for built-in defaults, or pass your own implementation of any service
- Structured Logging: Pino-based logging with request tracking, redaction, and slow response detection
- Queue Management: RabbitMQ integration with automatic reconnection and state restoration
- Caching Layer: Redis integration with key prefixing, pattern-based TTL, batch operations, and Pub/Sub
- Security: Rate limiting (memory or Redis-backed), CORS, Helmet security headers, and compression
- Real-time: Socket.IO integration for WebSocket communication
- Database: Prisma integration for type-safe database operations with transactions
- Observability: Sentry error reporting, Prometheus metrics endpoint, and built-in health checks
- Graceful Shutdown: LIFO ordered service teardown with 30-second force-shutdown timeout
Installation
npm install zeratus-apizeratus-api uses peer dependencies for packages where the consumer owns the version. Install the ones you need:
# Required — Fastify core
npm install fastify
# Pick what you use
npm install @fastify/compress # response compression
npm install @fastify/cors # CORS headers
npm install @fastify/helmet # security headers
npm install @fastify/rate-limit # rate limiting
npm install @prisma/client # database (Prisma)
npm install socket.io # WebSocket (Socket.IO)Requirements: Node.js >= 18, npm >= 8
Quick Start
import { Api } from "zeratus-api";
const api = new Api({
logger: {
format: { timestamp: true, requestId: true },
},
database: {
url: process.env.DATABASE_URL,
},
cache: {
url: process.env.REDIS_URL,
keyPrefix: "myapp",
keyDelimiter: ":",
defaultTTL: 3600,
},
errorReporter: {
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
},
});
// Register your routes
api.registerRoutes((app) => {
app.get("/users/:id", async (request, reply) => {
const { id } = request.params as { id: string };
const user = await api.database?.findUnique("user", { id });
return user;
});
});
// Initialize and start
async function start() {
await api.initialize();
await api.start(3000);
}
start().catch(console.error);Note: A
GET /healthendpoint is automatically registered duringinitialize(). You do not need to define it manually.
Walkthrough: Building a Production API
This walkthrough takes you from an empty project to a fully configured production API, step by step. Each section builds on the previous one — start with the basics and add capabilities as you need them.
Step 1: Minimal API (just Fastify + logging)
Start with the simplest possible setup. No database, no cache — just a server that runs:
import { Api } from "zeratus-api";
const api = new Api();
api.registerRoutes((app) => {
app.get("/ping", async () => ({ pong: true }));
});
async function main() {
await api.initialize();
await api.start(3000);
}
main().catch(console.error);What you get for free:
- Structured Pino logging on every request
x-request-idheader on every response (auto-generated UUID or forwarded from incoming request)GET /healthendpoint returning{ status: "OK" }- Graceful shutdown on SIGTERM/SIGINT
- Global error handler that catches unhandled route errors
Step 2: Add a database
Connect to PostgreSQL via Prisma. Make sure you have a Prisma schema set up in your project first.
const api = new Api({
database: {
url: process.env.DATABASE_URL, // e.g., postgresql://user:pass@localhost:5432/mydb
},
});
api.registerRoutes((app) => {
app.get("/users", async () => {
return api.database!.findMany("user", {
where: { active: true },
take: 20,
});
});
app.post("/users", async (request) => {
const body = request.body as { email: string; name: string };
return api.database!.create("user", body);
});
});
await api.initialize(); // Connects to the database
await api.start(3000);What changed:
api.databaseis now available — aPrismaDatabaseinstanceinitialize()callsdatabase.connect()automaticallyGET /healthnow reports database status:"database": "healthy"or"unhealthy"shutdown()disconnects the database last (after all other services)
Using an existing PrismaClient
If you already have a configured PrismaClient (e.g., with custom pool settings, extensions, or logging), you can pass it directly to PrismaDatabase:
import { PrismaClient } from "@prisma/client";
import { Api, PrismaDatabase } from "zeratus-api";
const prisma = new PrismaClient({
datasources: { db: { url: process.env.DATABASE_URL } },
log: ["query", "warn", "error"],
// Custom pool settings, extensions, etc.
});
const api = new Api({
database: new PrismaDatabase(prisma, api.logger),
});When wrapping an existing client:
- Connection lifecycle is yours —
initialize()andshutdown()skip$connect()/$disconnect()since you manage the client - Logging is yours — zeratus-api won't register
$onevent handlers; configure logging on your own client - Health checks still work —
GET /healthrunsSELECT 1through your client to verify the connection - All database operations work normally —
create,findMany,transaction, etc. all delegate to your client
Step 3: Add caching
Layer Redis caching on top. The cache handles key prefixing and TTL automatically.
const api = new Api({
database: {
url: process.env.DATABASE_URL,
},
cache: {
url: process.env.REDIS_URL, // e.g., redis://localhost:6379
keyPrefix: "myapp", // All keys become "myapp:yourkey"
keyDelimiter: ":",
defaultTTL: 3600, // 1 hour default
ttlByPattern: {
"session:.*": 900, // Sessions expire in 15 minutes
"user:.*": 1800, // User data expires in 30 minutes
},
},
});
api.registerRoutes((app) => {
app.get("/users/:id", async (request) => {
const { id } = request.params as { id: string };
const cacheKey = `user:${id}`;
// Check cache first
const cached = await api.cache!.get(cacheKey);
if (cached) return JSON.parse(cached);
// Cache miss — query database
const user = await api.database!.findUnique("user", { id });
if (user) {
await api.cache!.set(cacheKey, JSON.stringify(user));
// TTL is automatic — "user:.*" pattern matches → 1800 seconds
}
return user;
});
});Key features to know:
- Key prefixing:
api.cache.set("user:123", ...)actually storesmyapp:user:123in Redis. You never see the prefix — it's transparent. - Pattern TTL: Keys matching
user:.*get 1800s TTL automatically. Keys matchingsession:.*get 900s. Everything else gets thedefaultTTL(3600s). - Pub/Sub: The cache also supports
subscribe(),publish(), andunsubscribe()using a dedicated subscriber connection.
Step 4: Add security and rate limiting
Protect your API with CORS, security headers, compression, and rate limiting:
const api = new Api({
database: { url: process.env.DATABASE_URL },
cache: {
url: process.env.REDIS_URL,
keyPrefix: "myapp",
keyDelimiter: ":",
},
rateLimit: {
defaultLimit: 100, // 100 requests per window
windowMs: 60_000, // 1 minute window
storage: {
type: "redis", // Use Redis for distributed rate limiting
prefix: "rate-limit",
},
},
});
// Enable security features BEFORE registering routes
await api.enableCors({
origin: process.env.CORS_ORIGIN || "http://localhost:3000",
credentials: true,
});
api.enableSecurityHeaders(); // Helmet with sensible CSP defaults
api.enableCompression(); // Gzip/Brotli response compression
// Optional: Prometheus metrics at GET /metrics
api.enableMetrics();
api.registerRoutes((app) => {
// Your routes here
});
await api.initialize();
await api.start(3000);Tip: Rate limiting with
type: "redis"shares limits across all instances of your API. Usetype: "memory"for single-instance deployments.
Step 5: Add error reporting
Capture and report 500 errors to Sentry automatically:
const api = new Api({
database: { url: process.env.DATABASE_URL },
cache: { url: process.env.REDIS_URL, keyPrefix: "myapp", keyDelimiter: ":" },
errorReporter: {
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV, // "production", "staging", etc.
release: process.env.APP_VERSION, // e.g., "1.2.3"
tracesSampleRate: 0.1, // Sample 10% of transactions
errorTypes: {
VALIDATION_ERROR: "Validation Error",
AUTH_ERROR: "Authentication Error",
RATE_LIMIT_ERROR: "Rate Limit Exceeded",
NOT_FOUND_ERROR: "Resource Not Found",
SERVER_ERROR: "Internal Server Error",
},
responseFormat: {
includeErrorCode: true, // Adds "code" field to error responses
includeTimestamp: true, // Adds "timestamp" field
includeStack: process.env.NODE_ENV !== "production",
},
},
});What happens automatically:
- Any unhandled 500 error in a route is captured by Sentry with full request context (URL, method, headers, params)
- The error response includes a Sentry
eventIdso support can look up the exact error - 4xx errors are logged but NOT sent to Sentry (they're expected client errors)
- In production, 500 error messages are replaced with "Internal Server Error" to avoid leaking internals
Step 6: Add background job processing
Set up RabbitMQ queues for async work:
const api = new Api({
// ... other config
queues: {
configs: [
{
url: process.env.RABBITMQ_URL, // e.g., amqp://localhost:5672
name: "email-queue",
prefetchCount: 10, // Process 10 messages at a time
},
{
url: process.env.RABBITMQ_URL,
name: "webhook-queue",
prefetchCount: 5,
},
],
},
});
await api.initialize();
// Set up the email queue
const emailQueue = api.getQueue("email-queue")!;
await emailQueue.assertExchange("notifications", "topic");
await emailQueue.assertQueue("email.send");
await emailQueue.bindQueue("email.send", "notifications", "email.#");
// Consume messages
await emailQueue.consume("email.send", async (msg) => {
if (!msg) return;
try {
const data = JSON.parse(msg.content.toString());
await sendEmail(data.to, data.subject, data.body);
emailQueue.ack(msg);
} catch (error) {
// Negative acknowledge — message goes back to the queue
emailQueue.nack(msg, false, true);
}
});
// Publish from a route
api.registerRoutes((app) => {
app.post("/invite", async (request) => {
const { email } = request.body as { email: string };
await emailQueue.publish(
"notifications",
"email.invite",
Buffer.from(JSON.stringify({
to: email,
subject: "You're invited!",
body: "Click here to join...",
}))
);
return { sent: true };
});
});
await api.start(3000);Resilience features:
- Queues auto-reconnect up to 5 times on connection loss
- Consumer state is restored after reconnection — your consumers don't need to re-register
- Publish handles backpressure — if the channel buffer is full, it waits for
drainbefore retrying
Step 7: Add real-time communication
Add WebSocket support via Socket.IO:
const api = new Api({
// ... other config
socket: {
cors: {
origin: process.env.CORS_ORIGIN || "*",
credentials: true,
},
transports: ["websocket", "polling"],
},
});
await api.initialize();
// Handle WebSocket connections
api.socket!.onConnection((socket) => {
console.log(`Client connected: ${socket.id}`);
socket.on("chat:message", (data) => {
// Broadcast to all other clients
api.socket!.emit("chat:message", {
from: socket.id,
message: data.message,
timestamp: Date.now(),
});
});
socket.on("join:room", (room) => {
socket.join(room);
});
});
api.socket!.onDisconnection((socket) => {
console.log(`Client disconnected: ${socket.id}`);
});
await api.start(3000);Step 8: Add a global route prefix
If your API is versioned or behind a path prefix:
api.setGlobalPrefix("/api/v1");
api.registerRoutes((app) => {
// This route is now accessible at GET /api/v1/users
app.get("/users", async () => {
return api.database!.findMany("user");
});
});Step 9: Add custom services
Integrate any external service into the lifecycle — it gets health checks and graceful shutdown for free:
import { ServiceAdapter, HealthResult, HealthStatus } from "zeratus-api";
import { S3Client, HeadBucketCommand } from "@aws-sdk/client-s3";
class S3Storage implements ServiceAdapter {
readonly name = "s3";
private client!: S3Client;
private bucket: string;
constructor(bucket: string) {
this.bucket = bucket;
}
async connect() {
this.client = new S3Client({ region: "us-east-1" });
}
async disconnect() {
this.client.destroy();
}
async healthCheck(): Promise<HealthResult> {
try {
await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }));
return { status: HealthStatus.HEALTHY };
} catch {
return {
status: HealthStatus.UNHEALTHY,
details: { bucket: this.bucket },
};
}
}
// Your own methods
async upload(key: string, body: Buffer) { /* ... */ }
async download(key: string) { /* ... */ }
}
// Register it
const s3 = new S3Storage("my-uploads-bucket");
const api = new Api({
database: { url: process.env.DATABASE_URL },
services: [s3],
});
await api.initialize();
// GET /health now includes: "s3": "healthy"Step 10: Swap a built-in service
Don't want Redis? Use your own cache implementation:
import { Cache, ServiceAdapter, HealthResult, HealthStatus } from "zeratus-api";
class InMemoryCache implements Cache, ServiceAdapter {
readonly name = "cache";
private store = new Map<string, { value: string; expiry: number }>();
async connect() {}
async disconnect() { this.store.clear(); }
async healthCheck(): Promise<HealthResult> {
return { status: HealthStatus.HEALTHY, details: { size: this.store.size } };
}
async set(key: string, value: string, options?: { ttl?: number }) {
const expiry = Date.now() + (options?.ttl ?? 3600) * 1000;
this.store.set(key, { value, expiry });
}
async get(key: string) {
const entry = this.store.get(key);
if (!entry) return null;
if (Date.now() > entry.expiry) { this.store.delete(key); return null; }
return entry.value;
}
async del(key: string) { this.store.delete(key); }
async exists(key: string) { return this.store.has(key); }
async ping() {}
// Implement remaining Cache interface methods...
async expire(key: string, seconds: number) { return true; }
async ttl(key: string) { return -1; }
async incr(key: string) { return 0; }
async decr(key: string) { return 0; }
async hset() {}
async hget() { return null; }
async hdel() {}
async hgetall() { return {}; }
async publish() { return 0; }
async subscribe() {}
async unsubscribe() {}
}
// Pass the instance instead of a config object
const api = new Api({
cache: new InMemoryCache(), // Your implementation — not Redis
database: { url: process.env.DATABASE_URL },
});
// api.cache works exactly the same — consumers don't know the difference
await api.cache!.set("key", "value");Step 11: Full production setup
Here's everything together — a real-world production configuration:
import { Api } from "zeratus-api";
const api = new Api({
logger: {
redactFields: ["password", "token", "authorization", "cookie"],
requestTracking: {
headers: { enabled: true, fields: ["user-agent", "x-forwarded-for"] },
query: { enabled: true },
body: { enabled: false }, // Don't log request bodies in production
},
timing: { slow_threshold: 2000 },
},
database: { url: process.env.DATABASE_URL },
cache: {
url: process.env.REDIS_URL,
keyPrefix: process.env.APP_NAME || "myapp",
keyDelimiter: ":",
defaultTTL: 3600,
},
queues: {
configs: [{
url: process.env.RABBITMQ_URL,
name: "jobs",
prefetchCount: 10,
}],
},
errorReporter: {
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
release: process.env.APP_VERSION,
responseFormat: {
includeErrorCode: true,
includeTimestamp: true,
},
},
rateLimit: {
defaultLimit: 100,
windowMs: 60_000,
storage: { type: "redis" },
},
fastify: {
bodyLimit: 5 * 1024 * 1024, // 5MB for file uploads
connectionTimeout: 60_000,
keepAliveTimeout: 30_000,
},
});
// Security
await api.enableCors({ origin: process.env.CORS_ORIGIN, credentials: true });
api.enableSecurityHeaders();
api.enableCompression();
api.enableMetrics();
// Route prefix
api.setGlobalPrefix("/api/v1");
// Routes
api.registerRoutes((app) => {
app.get("/users", async () => api.database!.findMany("user"));
app.get("/users/:id", async (req) => {
const { id } = req.params as { id: string };
return api.database!.findUnique("user", { id });
});
});
// Start
await api.initialize();
await api.start(parseInt(process.env.PORT || "3000"));What's running now:
- Fastify server with structured logging and request ID tracking
- PostgreSQL via Prisma with automatic health checks
- Redis caching with key prefixing and pattern-based TTLs
- RabbitMQ job queue with auto-reconnection
- Sentry error reporting for 500 errors
- Redis-backed distributed rate limiting (100 req/min)
- CORS, Helmet security headers, Gzip compression
- Prometheus metrics at
/api/v1/metrics - Health check at
/api/v1/healthwith all service statuses - Graceful shutdown: database disconnects last, 30s force timeout
API Reference
Api Class
The central orchestrator that wires up all subsystems.
Constructor
const api = new Api(config?: Partial<ApiConfig>);All configuration sections are optional. Only the services you configure will be instantiated.
Public Properties
| Property | Type | Description |
|---|---|---|
| app | FastifyInstance | The underlying Fastify instance |
| logger | Logger | Always created (PinoLogger) |
| queueManager | QueueManager \| undefined | Created when queues config is provided |
| cache | Cache \| undefined | Created when cache config is provided |
| socket | Socket \| undefined | Created when socket config is provided |
| errorReporter | ErrorReporter \| undefined | Created when errorReporter config is provided |
| database | Database \| undefined | Created when database config is provided |
Lifecycle Methods
| Method | Signature | Description |
|---|---|---|
| initialize() | () => Promise<void> | Connects all services, sets up middleware, error handling, and health checks |
| start() | (port?: number, host?: string) => Promise<void> | Starts the HTTP server. Defaults: port 3000, host 0.0.0.0 |
| shutdown() | () => Promise<void> | Gracefully shuts down all services in LIFO order |
Route & Plugin Methods
| Method | Signature | Description |
|---|---|---|
| registerRoutes() | (routes: (app: FastifyInstance) => void) => void | Register routes via callback with the Fastify instance |
| registerPlugin() | (plugin, opts?) => Promise<void> | Register any Fastify plugin |
| setGlobalPrefix() | (prefix: string) => void | Prepend a prefix to all route URLs (e.g., /api/v1) |
Feature Toggle Methods
| Method | Signature | Description |
|---|---|---|
| enableRateLimit() | (options: { windowMs, max, keyGenerator? }) => void | Enable rate limiting programmatically |
| enableCompression() | (options?: FastifyCompressOptions) => void | Enable response compression |
| enableCors() | (options?: FastifyCorsOptions) => Promise<void> | Enable CORS with sensible defaults |
| enableSecurityHeaders() | (options?: FastifyHelmetOptions) => void | Enable Helmet security headers with CSP defaults |
| enableMetrics() | () => void | Enable Prometheus metrics at GET /metrics |
Service Methods
| Method | Signature | Description |
|---|---|---|
| getQueue() | (name: string) => RabbitMQQueue \| undefined | Get a named queue from the QueueManager |
| registerService() | (service: ServiceAdapter) => void | Register a custom service for lifecycle management |
| getService() | (name: string) => ServiceAdapter \| undefined | Retrieve a registered service by name |
Swappable Implementations
Every service slot accepts either a config object (uses the built-in default) or a pre-built instance implementing the interface. This lets you swap Redis for Memcached, RabbitMQ for BullMQ, or Sentry for any error reporter — without modifying the library.
// Level 1: Just give me defaults (same as always)
const api = new Api({
cache: { url: "redis://localhost:6379" }, // Creates RedisCache automatically
});
// Level 2: Swap one service
const api = new Api({
cache: new MemcachedCache({ url: "..." }), // Your implementation
database: { url: DATABASE_URL }, // Default PrismaDatabase
});
// Level 3: Wrap the built-in with your own client
const prisma = new PrismaClient({ /* custom pool, extensions, etc. */ });
const api = new Api({
database: new PrismaDatabase(prisma, api.logger), // Your PrismaClient, zeratus-api's wrapper
});
// Level 4: Full control — entirely custom implementations
const api = new Api({
cache: new MemcachedCache({ url: "..." }),
database: new DrizzleDatabase({ url: "..." }),
errorReporter: new DatadogReporter({ apiKey: "..." }),
});Custom Services
Register any service that implements ServiceAdapter to participate in health checks and graceful shutdown automatically:
import { ServiceAdapter, HealthResult, HealthStatus } from "zeratus-api";
class S3Storage implements ServiceAdapter {
readonly name = "s3";
async connect() { /* initialize S3 client */ }
async disconnect() { /* close connections */ }
async healthCheck(): Promise<HealthResult> {
return { status: HealthStatus.HEALTHY };
}
// Your own methods
async upload(key: string, body: Buffer) { /* ... */ }
}
const s3 = new S3Storage();
const api = new Api({
database: { url: DATABASE_URL },
services: [s3], // Registered at construction time
});
// Or register later
api.registerService(s3);
// Now s3 automatically:
// - Connects during api.initialize()
// - Appears in GET /health
// - Disconnects during api.shutdown()Configuration
ApiConfig
interface ApiConfig {
logger?: LogConfig;
database?: { url: string } | Database; // Config or instance
cache?: CacheConfig | Cache; // Config or instance
queues?: { configs: RabbitMQConfig[] };
socket?: (ServerOptions & SSEConfig) | Socket; // Config or instance
errorReporter?: ErrorConfig | ErrorReporter; // Config or instance
rateLimit?: RateLimitConfig;
fastify?: FastifyServerOptions & {
bodyLimit?: number; // Default: 1048576 (1MB)
connectionTimeout?: number; // Default: 30000ms
keepAliveTimeout?: number; // Default: 5000ms
};
services?: ServiceAdapter[]; // Additional custom services
}Logger Configuration
interface LogConfig {
format?: {
timestamp?: boolean;
requestId?: boolean;
correlationId?: boolean;
environment?: boolean;
};
redactFields?: string[]; // Field names replaced with "[REDACTED]" in logs
redactPatterns?: RegExp[];
requestTracking?: {
headers?: { enabled: boolean; fields?: string[] };
body?: { enabled: boolean; fields?: string[]; maxDepth?: number };
query?: { enabled: boolean; fields?: string[] };
};
timing?: {
slow_threshold?: number; // Default: 1000ms — logs a warning when exceeded
include_db_time?: boolean;
};
}Cache Configuration
interface CacheConfig {
url: string; // Required — Redis connection URL
keyPrefix?: string; // Automatic key prefixing
keyDelimiter?: string; // Required when keyPrefix is set (e.g., ":")
defaultTTL?: number; // Default: 3600 (1 hour)
ttlByPattern?: Record<string, number>; // Regex pattern -> TTL mapping
batchSize?: number; // Default: 100 — for pipeline batch operations
maxReconnectAttempts?: number; // Default: 5
reconnectInterval?: number; // Default: 5000ms
hideDebugLogs?: boolean; // Suppress debug-level cache logs
}Queue Configuration
type RabbitMQConfig = {
url: string; // RabbitMQ connection URL
name: string; // Unique queue identifier
maxReconnectAttempts?: number; // Default: 5
reconnectInterval?: number; // Default: 5000ms
prefetchCount?: number; // Default: 10
hideDebugLogs?: boolean;
};Error Reporter Configuration
interface ErrorConfig {
dsn: string; // Required — Sentry DSN
environment?: string;
release?: string;
errorTypes?: {
VALIDATION_ERROR: string; // Mapped from HTTP 400
AUTH_ERROR: string; // Mapped from HTTP 401/403
RATE_LIMIT_ERROR: string; // Mapped from HTTP 429
NOT_FOUND_ERROR: string; // Mapped from HTTP 404
SERVER_ERROR: string; // Mapped from HTTP 500+
};
responseFormat?: {
includeStack?: boolean; // Stack traces in error responses (non-production only)
includeErrorCode?: boolean; // Include error code in responses
includeTimestamp?: boolean; // Include timestamp in responses
};
reporting?: {
enabled?: boolean;
batchSize?: number;
flushInterval?: number;
};
}Rate Limit Configuration
interface RateLimitConfig {
defaultLimit: number; // Required — max requests per window
windowMs: number; // Required — time window in milliseconds
storage?: {
type: "memory" | "redis"; // Redis requires cache to be configured
prefix?: string;
ttl?: number;
};
headers?: boolean;
statusCode?: number;
message?: string;
errorResponseBuilder?: ( // Custom 429 response shape
req: FastifyRequest,
context: RateLimitErrorContext
) => object;
}
interface RateLimitErrorContext {
statusCode: number;
ban: boolean;
after: string; // Human-readable retry time, e.g. "1 second"
max: number; // The max requests allowed
ttl: number; // Remaining window in milliseconds
}Custom error response example:
import { Api } from "zeratus-api";
const api = new Api({
rateLimit: {
defaultLimit: 100,
windowMs: 60_000,
errorResponseBuilder: (req, context) => ({
code: context.statusCode,
error: "Too Many Requests",
message: `Rate limit exceeded, retry in ${context.after}`,
retryAfter: context.ttl,
}),
},
});Complete Configuration Example
import { Api } from "zeratus-api";
const api = new Api({
logger: {
format: {
timestamp: true,
requestId: true,
environment: true,
},
redactFields: ["password", "token", "authorization"],
requestTracking: {
headers: { enabled: true, fields: ["user-agent", "x-forwarded-for"] },
body: { enabled: true, maxDepth: 2 },
query: { enabled: true },
},
timing: {
slow_threshold: 1000,
include_db_time: true,
},
},
database: {
url: process.env.DATABASE_URL,
},
cache: {
url: process.env.REDIS_URL,
keyPrefix: "myapp",
keyDelimiter: ":",
defaultTTL: 3600,
ttlByPattern: {
"user:.*": 1800,
"session:.*": 900,
},
batchSize: 100,
},
queues: {
configs: [
{
url: process.env.RABBITMQ_URL,
name: "email-queue",
prefetchCount: 10,
},
{
url: process.env.RABBITMQ_URL,
name: "notification-queue",
prefetchCount: 5,
},
],
},
socket: {
cors: {
origin: process.env.CORS_ORIGIN || "*",
methods: ["GET", "POST"],
credentials: true,
},
transports: ["websocket", "polling"],
pingTimeout: 20000,
pingInterval: 25000,
},
errorReporter: {
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
release: process.env.APP_VERSION,
errorTypes: {
VALIDATION_ERROR: "Validation Error",
AUTH_ERROR: "Authentication Error",
RATE_LIMIT_ERROR: "Rate Limit Exceeded",
NOT_FOUND_ERROR: "Resource Not Found",
SERVER_ERROR: "Internal Server Error",
},
responseFormat: {
includeStack: process.env.NODE_ENV !== "production",
includeErrorCode: true,
includeTimestamp: true,
},
},
rateLimit: {
defaultLimit: 100,
windowMs: 60000,
storage: {
type: "redis",
prefix: "rate-limit",
ttl: 60,
},
},
fastify: {
bodyLimit: 1048576,
connectionTimeout: 30000,
keepAliveTimeout: 5000,
},
});Component Usage
Database Operations
Powered by Prisma. Models are accessed dynamically by name.
// Create
const user = await api.database?.create("user", {
email: "[email protected]",
name: "John Doe",
});
// Find
const user = await api.database?.findUnique("user", { id: "123" });
const users = await api.database?.findMany("user", {
where: { active: true },
take: 10,
skip: 0,
});
// Update
const updated = await api.database?.update("user", { id: "123" }, { name: "Jane Doe" });
// Delete
await api.database?.delete("user", { id: "123" });
// Transactions
await api.database?.transaction(async (tx) => {
await tx.create("user", userData);
await tx.create("profile", profileData);
});
// Raw queries
await api.database?.executeRaw("DELETE FROM sessions WHERE expired_at < NOW()");
// Aggregation
const stats = await api.database?.aggregate("order", {
where: { status: "completed" },
_count: true,
_sum: { total: true },
_avg: { total: true },
});
// Group by
const grouped = await api.database?.groupBy("order", {
by: ["status"],
where: { createdAt: { gte: new Date("2024-01-01") } },
});Caching
Redis-based with automatic key prefixing, pattern-based TTLs, and Pub/Sub.
// Basic operations
await api.cache?.set("user:123", JSON.stringify(user)); // Uses pattern TTL or defaultTTL
await api.cache?.set("user:123", JSON.stringify(user), { ttl: 1800 }); // Custom TTL
const cached = await api.cache?.get("user:123");
await api.cache?.del("user:123");
// Key utilities
const exists = await api.cache?.exists("user:123");
await api.cache?.expire("user:123", 600);
const remaining = await api.cache?.ttl("user:123");
// Counters
await api.cache?.incr("page:views");
await api.cache?.decr("stock:item:42");
// Hash operations
await api.cache?.hset("user:123", "name", "John Doe");
const name = await api.cache?.hget("user:123", "name");
await api.cache?.hdel("user:123", "name");
const all = await api.cache?.hgetall("user:123");
// Pub/Sub (uses a dedicated subscriber client internally)
await api.cache?.subscribe("notifications", (message) => {
console.log("Received:", message);
});
await api.cache?.publish("notifications", "Hello World");
await api.cache?.unsubscribe("notifications");Queue Management
RabbitMQ-based with automatic reconnection and consumer state restoration.
const emailQueue = api.getQueue("email-queue");
// Assert exchanges and bind queues
await emailQueue?.assertExchange("emails", "topic");
await emailQueue?.assertQueue("email.send");
await emailQueue?.bindQueue("email.send", "emails", "email.send");
// Publish messages (handles backpressure automatically)
await emailQueue?.publish(
"emails",
"email.send",
Buffer.from(JSON.stringify({
to: "[email protected]",
subject: "Welcome!",
body: "Welcome to our platform!",
}))
);
// Consume messages
await emailQueue?.consume("email.send", async (msg) => {
if (msg) {
const data = JSON.parse(msg.content.toString());
await sendEmail(data);
emailQueue.ack(msg); // Acknowledge
// emailQueue.nack(msg); // Negative acknowledge (requeue)
}
});Real-time Communication
Socket.IO-based WebSocket support.
// Emit to all clients
api.socket?.emit("notification", { message: "Hello everyone!" });
// Handle connections
api.socket?.onConnection((socket) => {
console.log("Client connected:", socket.id);
socket.on("message", (data) => {
console.log("Received:", data);
});
});
// Handle disconnections
api.socket?.onDisconnection((socket) => {
console.log("Client disconnected:", socket.id);
});
// Get connected client count
const count = api.socket?.getConnectedClients();
// Disconnect all clients
api.socket?.disconnectAll();Error Reporting
Sentry-based with automatic rate-limit back-off.
// Capture errors
const eventId = await api.errorReporter?.captureException(new Error("Something broke"));
// Capture messages
api.errorReporter?.captureMessage("User performed unusual action", "warning");
// Set user context
api.errorReporter?.setUser({ id: "123", email: "[email protected]" });
api.errorReporter?.clearUser();
// Tags and extras
api.errorReporter?.setTag("feature", "checkout");
api.errorReporter?.setExtra("cart", { items: 3, total: 99.99 });
// Breadcrumbs
api.errorReporter?.addBreadcrumb({
category: "navigation",
message: "User navigated to checkout",
level: "info",
});Built-in Features
Health Check Endpoint
Automatically registered at GET /health during initialize(). Returns status of all registered services — including custom ones added via registerService().
{
"status": "OK",
"timestamp": 1700000000000,
"uptime": 3600.123,
"memory": { "rss": 52428800, "heapTotal": 20971520, "heapUsed": 15728640 },
"services": {
"database": "healthy",
"cache": "healthy",
"queue-manager": "healthy",
"socket": "healthy",
"s3": "healthy"
}
}If any service is unhealthy, the overall status changes to "DEGRADED" but the endpoint still responds with 200 — allowing load balancers to see granular service status.
Request ID Tracking
Every request receives an x-request-id header (from the incoming request or auto-generated UUID v4). A child logger with the request ID is created per request for log correlation.
Global Error Handler
Automatically registered during initialize():
- 4xx errors are logged as
warn, 5xx aserror - 500 errors are reported to Sentry with full request context
- In production, 500 error messages are replaced with "Internal Server Error"
- Error responses optionally include
code,timestamp,stack, and SentryeventId
Graceful Shutdown
Listens for SIGTERM and SIGINT. Only manages its own signal handlers — consumer-registered handlers are never removed.
Shutdown order (LIFO — last registered disconnects first):
- Stop accepting new requests (Fastify close)
- Disconnect services in reverse registration order (socket, queue, cache, error reporter, database)
Database disconnects last, ensuring other services can still use it during their teardown. If shutdown exceeds 30 seconds, the process is forcefully terminated.
Config Validation
Performed at construction time (before any services are created):
- Database URL required when database config object is provided
- Queue configs array required when queues are configured
- Sentry DSN required when error reporter config object is provided
- Cache URL required when cache config object is provided
- Key delimiter required when key prefix is set on cache
- Rate limit requires numeric
defaultLimitandwindowMs - Redis-backed rate limiting requires cache to be configured
Validation is skipped when passing a pre-built instance instead of a config object.
Architecture
src/
index.ts Api facade + exports
constants.ts All constant objects (no hardcoded strings)
registry.ts ServiceRegistry — lifecycle management
shutdown.ts ShutdownManager — graceful shutdown
types/
index.ts Public interfaces
service.ts ServiceAdapter + HealthResult
plugins/
error-handler.ts Fastify plugin — error formatting + Sentry
health.ts Fastify plugin — GET /health via registry
request-context.ts Fastify plugin — request ID + child logger
request-logging.ts Fastify plugin — request/response logging
implementations/
pinoLogger.ts Logger (PinoLogger)
prismaDatabase.ts Database (PrismaDatabase + ServiceAdapter)
redisCache.ts Cache (RedisCache + ServiceAdapter)
rabbitMQQueue.ts Queue (RabbitMQQueue + QueueManager + ServiceAdapter)
socketIOSocket.ts Socket (SocketIOWrapper + ServiceAdapter)
sentryErrorReporter.ts ErrorReporter (SentryErrorReporter + ServiceAdapter)Exported Classes & Types
// Main class
export { Api, ApiConfig } from "zeratus-api";
// Implementation classes (can be used standalone)
export {
PinoLogger,
PrismaDatabase,
RedisCache,
RabbitMQQueue,
QueueManager,
SocketIOWrapper,
SentryErrorReporter,
} from "zeratus-api";
// v2: Service lifecycle
export {
ServiceRegistry,
ShutdownManager,
HealthStatus,
} from "zeratus-api";
export type {
ServiceAdapter,
HealthResult,
HealthStatusType,
RabbitMQConfig,
} from "zeratus-api";
// Constants (all available for consumer use)
export {
ServiceName,
HttpMethod,
HttpHeader,
ErrorCode,
StorageType,
RoutePath,
// ... and more
} from "zeratus-api";Development Scripts
| Script | Command | Description |
|---|---|---|
| Build | npm run build | Compile TypeScript to dist/ |
| Dev | npm run dev | Run with tsx (hot reload) |
| Lint | npm run lint | Run ESLint on source files |
| Lint Fix | npm run lint:fix | Auto-fix ESLint issues |
| Clean | npm run clean | Remove dist/ directory |
| Unused Deps | npm run check-unused-deps | Analyze codebase for unused dependencies |
Environment Variables
# Database
DATABASE_URL=postgresql://user:pass@localhost:5432/db
# Cache
REDIS_URL=redis://localhost:6379
# Queue
RABBITMQ_URL=amqp://localhost:5672
# Error Reporting
SENTRY_DSN=https://your-sentry-dsn
# Application
NODE_ENV=production
PORT=3000
CORS_ORIGIN=https://yourdomain.comDocker Example
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]Contributing
This library is maintained by Zeratus. For internal development:
- Clone the repository
- Install dependencies:
npm install - Build the project:
npm run build - Lint:
npm run lint - Check for unused deps:
npm run check-unused-deps
License
Private - Zeratus Internal Use
About Zeratus
Zeratus is a technology company focused on building scalable, maintainable software solutions. This API boilerplate represents our best practices and patterns for Node.js API development.
Built with care by the Zeratus Team
