/** * Rate Limiter for Brainy API * * Provides rate limiting without external dependencies like Redis. * - Uses in-memory storage for single instances * - Can use S3/R2 for distributed rate limiting * * @module rateLimiter */ import { BaseStorageAdapter } from '../storage/adapters/baseStorageAdapter.js' export interface RateLimitConfig { /** * Maximum number of requests allowed */ maxRequests: number /** * Time window in milliseconds */ windowMs: number /** * Optional message to return when rate limit is exceeded */ message?: string /** * Whether to use distributed storage (S3/R2) for rate limiting */ distributed?: boolean /** * Storage adapter for distributed rate limiting */ storage?: BaseStorageAdapter /** * Key prefix for distributed storage */ keyPrefix?: string } export interface RateLimitResult { /** * Whether the request is allowed */ allowed: boolean /** * Number of requests remaining in the current window */ remaining: number /** * Time when the rate limit window resets (Unix timestamp) */ resetTime: number /** * Total limit for the window */ limit: number } /** * Simple in-memory rate limiter */ export class RateLimiter { private requests: Map = new Map() private cleanupInterval: NodeJS.Timeout | null = null constructor(private config: RateLimitConfig) { // Start cleanup interval to remove expired entries this.startCleanup() } /** * Check if a request is allowed and update the rate limit */ async checkLimit(identifier: string): Promise { const now = Date.now() if (this.config.distributed && this.config.storage) { return this.checkDistributedLimit(identifier, now) } return this.checkMemoryLimit(identifier, now) } /** * Check rate limit using in-memory storage */ private checkMemoryLimit(identifier: string, now: number): RateLimitResult { const entry = this.requests.get(identifier) const resetTime = now + this.config.windowMs if (!entry || entry.resetTime <= now) { // New window or expired window this.requests.set(identifier, { count: 1, resetTime }) return { allowed: true, remaining: this.config.maxRequests - 1, resetTime, limit: this.config.maxRequests } } // Existing window if (entry.count < this.config.maxRequests) { entry.count++ return { allowed: true, remaining: this.config.maxRequests - entry.count, resetTime: entry.resetTime, limit: this.config.maxRequests } } // Rate limit exceeded return { allowed: false, remaining: 0, resetTime: entry.resetTime, limit: this.config.maxRequests } } /** * Check rate limit using distributed storage (S3/R2) */ private async checkDistributedLimit( identifier: string, now: number ): Promise { const storage = this.config.storage! const key = `ratelimit_${identifier}` const resetTime = now + this.config.windowMs try { // Try to get existing rate limit data from metadata storage const existing = await storage.getMetadata(key) if (!existing || !existing.resetTime || Number(existing.resetTime) <= now) { // New window or expired window await storage.saveMetadata(key, { count: 1, resetTime: resetTime, identifier }) return { allowed: true, remaining: this.config.maxRequests - 1, resetTime, limit: this.config.maxRequests } } const count = Number(existing.count || 0) if (count < this.config.maxRequests) { // Update count await storage.saveMetadata(key, { count: count + 1, resetTime: existing.resetTime, identifier }) return { allowed: true, remaining: this.config.maxRequests - count - 1, resetTime: Number(existing.resetTime), limit: this.config.maxRequests } } // Rate limit exceeded return { allowed: false, remaining: 0, resetTime: Number(existing.resetTime), limit: this.config.maxRequests } } catch (error) { // On error, fail open (allow the request) console.warn('Rate limiter error, failing open:', error) return { allowed: true, remaining: this.config.maxRequests, resetTime, limit: this.config.maxRequests } } } /** * Reset rate limit for a specific identifier */ async reset(identifier: string): Promise { if (this.config.distributed && this.config.storage) { const key = `ratelimit_${identifier}` // Reset by setting count to 0 and expired time await this.config.storage.saveMetadata(key, { count: 0, resetTime: 0, identifier }) } else { this.requests.delete(identifier) } } /** * Start cleanup interval to remove expired entries */ private startCleanup(): void { // Run cleanup every minute this.cleanupInterval = setInterval(() => { const now = Date.now() const expired: string[] = [] for (const [key, entry] of this.requests) { if (entry.resetTime <= now) { expired.push(key) } } for (const key of expired) { this.requests.delete(key) } }, 60000) // 1 minute // Don't keep Node.js process alive just for cleanup if (this.cleanupInterval.unref) { this.cleanupInterval.unref() } } /** * Stop the rate limiter and cleanup */ destroy(): void { if (this.cleanupInterval) { clearInterval(this.cleanupInterval) this.cleanupInterval = null } this.requests.clear() } } /** * Express/Connect middleware for rate limiting */ export function rateLimitMiddleware(config: RateLimitConfig) { const limiter = new RateLimiter(config) return async (req: any, res: any, next: any) => { // Use IP address as identifier (can be customized) const identifier = req.ip || req.connection?.remoteAddress || 'unknown' const result = await limiter.checkLimit(identifier) // Set rate limit headers res.setHeader('X-RateLimit-Limit', result.limit) res.setHeader('X-RateLimit-Remaining', result.remaining) res.setHeader('X-RateLimit-Reset', result.resetTime) if (!result.allowed) { res.status(429).json({ error: config.message || 'Too many requests, please try again later.', retryAfter: Math.ceil((result.resetTime - Date.now()) / 1000) }) return } next() } } /** * Create a rate limiter for use with Brainy * * @example * ```typescript * // For single instance (in-memory) * const limiter = createRateLimiter({ * maxRequests: 100, * windowMs: 15 * 60 * 1000 // 15 minutes * }) * * // For distributed (using S3/R2) * const limiter = createRateLimiter({ * maxRequests: 100, * windowMs: 15 * 60 * 1000, * distributed: true, * storage: myS3Adapter * }) * * // Check rate limit * const result = await limiter.checkLimit('user-123') * if (!result.allowed) { * throw new Error('Rate limit exceeded') * } * ``` */ export function createRateLimiter(config: RateLimitConfig): RateLimiter { return new RateLimiter(config) } /** * Preset configurations for common use cases */ export const RateLimitPresets = { /** * Default API rate limit: 100 requests per 15 minutes */ default: { maxRequests: 100, windowMs: 15 * 60 * 1000 }, /** * Strict rate limit: 10 requests per minute */ strict: { maxRequests: 10, windowMs: 60 * 1000 }, /** * Lenient rate limit: 1000 requests per hour */ lenient: { maxRequests: 1000, windowMs: 60 * 60 * 1000 }, /** * Search endpoint: 30 requests per minute */ search: { maxRequests: 30, windowMs: 60 * 1000, message: 'Search rate limit exceeded. Please wait before searching again.' }, /** * Write operations: 20 requests per minute */ write: { maxRequests: 20, windowMs: 60 * 1000, message: 'Write rate limit exceeded. Please slow down your write operations.' } }