npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

zeratus-api

v1.1.5

Published

A powerful, production-ready Fastify boilerplate library built by Zeratus for rapid API development

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-api

zeratus-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 /health endpoint is automatically registered during initialize(). 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-id header on every response (auto-generated UUID or forwarded from incoming request)
  • GET /health endpoint 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.database is now available — a PrismaDatabase instance
  • initialize() calls database.connect() automatically
  • GET /health now 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 yoursinitialize() and shutdown() skip $connect()/$disconnect() since you manage the client
  • Logging is yours — zeratus-api won't register $on event handlers; configure logging on your own client
  • Health checks still workGET /health runs SELECT 1 through your client to verify the connection
  • All database operations work normallycreate, 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 stores myapp:user:123 in Redis. You never see the prefix — it's transparent.
  • Pattern TTL: Keys matching user:.* get 1800s TTL automatically. Keys matching session:.* get 900s. Everything else gets the defaultTTL (3600s).
  • Pub/Sub: The cache also supports subscribe(), publish(), and unsubscribe() 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. Use type: "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 eventId so 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 drain before 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/health with 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 as error
  • 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 Sentry eventId

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

  1. Stop accepting new requests (Fastify close)
  2. 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 defaultLimit and windowMs
  • 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.com

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

  1. Clone the repository
  2. Install dependencies: npm install
  3. Build the project: npm run build
  4. Lint: npm run lint
  5. 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