UNPKG

oss-ratelimit

Version:

Flexible rate limiting library with Redis for TypeScript applications

1 lines 66.8 kB
{"version":3,"sources":["../src/index.ts","../src/client-registry.ts","../src/initialize-limiter.ts"],"sourcesContent":["import { EventEmitter } from 'events';\nimport { RedisClientType, createClient } from 'redis';\nimport { initRateLimit } from './client-registry';\nimport { initializeLimiters } from './initialize-limiter';\n\n/**\n * @package open-ratelimit\n * A production-ready, open-source rate limiter with multiple algorithms\n * Inspired by Upstash/ratelimit but with enhanced capabilities\n */\n\n// ----------------------\n// Error Types\n// ----------------------\n\n/**\n * Base error class for rate limiting operations\n */\nexport class RatelimitError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'RatelimitError';\n Error.captureStackTrace(this, this.constructor);\n }\n}\n\n/**\n * Error thrown when Redis connection fails\n */\nexport class RedisConnectionError extends RatelimitError {\n constructor(message: string) {\n super(`Redis connection error: ${message}`);\n this.name = 'RedisConnectionError';\n }\n}\n\n/**\n * Error thrown when rate limit is exceeded\n */\nexport class RateLimitExceededError extends RatelimitError {\n public readonly retryAfter: number;\n public readonly identifier: string;\n\n constructor(identifier: string, retryAfter: number) {\n super(`Rate limit exceeded for \"${identifier}\". Retry after ${retryAfter} seconds.`);\n this.name = 'RateLimitExceededError';\n this.retryAfter = retryAfter;\n this.identifier = identifier;\n }\n}\n\n// ----------------------\n// Time Utilities\n// ----------------------\n\n/**\n * Time window definition using type literals\n */\nexport type TimeWindow =\n | `${number} ms`\n | `${number} s`\n | `${number} m`\n | `${number} h`\n | `${number} d`;\n\n/**\n * Time units in milliseconds\n */\nconst TIME_UNITS = {\n ms: 1,\n s: 1000,\n m: 60 * 1000,\n h: 60 * 60 * 1000,\n d: 24 * 60 * 60 * 1000,\n};\n\n/**\n * Parse time window string to milliseconds\n */\nexport const parseTimeWindow = (window: TimeWindow): number => {\n try {\n const [valueStr, unit] = window.trim().split(/\\s+/);\n const value = parseInt(valueStr, 10);\n\n if (Number.isNaN(value) || value <= 0) {\n throw new RatelimitError(`Invalid time value: ${valueStr}`);\n }\n\n const unitMultiplier = TIME_UNITS[unit as keyof typeof TIME_UNITS];\n if (!unitMultiplier) {\n throw new RatelimitError(`Invalid time unit: ${unit}. Must be one of: ms, s, m, h, d`);\n }\n\n return value * unitMultiplier;\n } catch (error) {\n if (error instanceof RatelimitError) throw error;\n throw new RatelimitError(`Failed to parse time window: ${window}`);\n }\n};\n\n// ----------------------\n// Limiter Algorithm Types\n// ----------------------\n\n/**\n * Base interface for all limiter options\n */\ninterface BaseLimiterOptions {\n limit: number;\n type: string;\n}\n\n/**\n * Fixed window algorithm options\n */\nexport interface FixedWindowOptions extends BaseLimiterOptions {\n type: 'fixedWindow';\n windowMs: number;\n}\n\n/**\n * Sliding window algorithm options\n */\nexport interface SlidingWindowOptions extends BaseLimiterOptions {\n type: 'slidingWindow';\n windowMs: number;\n}\n\n/**\n * Token bucket algorithm options\n */\nexport interface TokenBucketOptions extends BaseLimiterOptions {\n type: 'tokenBucket';\n refillRate: number;\n interval: number;\n}\n\n/**\n * Union type for all limiter algorithms\n */\nexport type LimiterType = FixedWindowOptions | SlidingWindowOptions | TokenBucketOptions;\n\n// ----------------------\n// Limiter Factory Functions\n// ----------------------\n\n/**\n * Create a fixed window limiter configuration\n */\nexport const fixedWindow = (limit: number, window: TimeWindow): FixedWindowOptions => {\n return {\n type: 'fixedWindow',\n limit,\n windowMs: parseTimeWindow(window),\n };\n};\n\n/**\n * Create a sliding window limiter configuration\n */\nexport const slidingWindow = (limit: number, window: TimeWindow): SlidingWindowOptions => {\n return {\n type: 'slidingWindow',\n limit,\n windowMs: parseTimeWindow(window),\n };\n};\n\n/**\n * Create a token bucket limiter configuration\n */\nexport const tokenBucket = (\n refillRate: number,\n interval: TimeWindow,\n limit: number\n): TokenBucketOptions => {\n return {\n type: 'tokenBucket',\n refillRate,\n interval: parseTimeWindow(interval),\n limit,\n };\n};\n\n// ----------------------\n// Ephemeral Cache\n// ----------------------\n\n/**\n * In-memory cache for fallback during Redis outages\n */\nclass EphemeralCache {\n private cache: Map<string, { count: number; expires: number }>;\n private ttl: number;\n private cleanupInterval: NodeJS.Timeout;\n\n constructor(ttlMs = 60000) {\n this.cache = new Map();\n this.ttl = ttlMs;\n\n // Clean expired items periodically\n this.cleanupInterval = setInterval(() => this.cleanup(), Math.min(ttlMs / 2, 30000));\n }\n\n /**\n * Get current count for a key\n */\n get(key: string): number {\n const now = Date.now();\n const item = this.cache.get(key);\n\n if (!item || item.expires < now) return 0;\n return item.count;\n }\n\n /**\n * Set count for a key\n */\n set(key: string, count: number, windowMs: number): void {\n this.cache.set(key, {\n count,\n expires: Date.now() + Math.min(windowMs, this.ttl),\n });\n }\n\n /**\n * Increment counter for a key\n */\n increment(key: string, windowMs: number): number {\n const now = Date.now();\n const item = this.cache.get(key) || { count: 0, expires: now + windowMs };\n\n if (item.expires < now) {\n item.count = 1;\n item.expires = now + windowMs;\n } else {\n item.count++;\n }\n\n this.cache.set(key, item);\n return item.count;\n }\n\n /**\n * Remove expired entries\n */\n cleanup(): void {\n const now = Date.now();\n for (const [key, item] of this.cache.entries()) {\n if (item.expires < now) {\n this.cache.delete(key);\n }\n }\n }\n\n /**\n * Destroy the cache and clear timer\n */\n destroy(): void {\n clearInterval(this.cleanupInterval);\n this.cache.clear();\n }\n}\n\n// ----------------------\n// Redis Client Management\n// ----------------------\n\n/**\n * Redis connection options\n */\nexport interface RedisOptions {\n url?: string;\n host?: string;\n port?: number;\n username?: string;\n password?: string;\n database?: number;\n tls?: boolean;\n connectTimeout?: number;\n reconnectStrategy?: number | false | ((retries: number, cause: Error) => number | false | Error);\n}\n\n/**\n * Default Redis configuration\n */\nconst DEFAULT_REDIS_OPTIONS: RedisOptions = {\n url: process.env.REDIS_URL || 'redis://localhost:6379',\n connectTimeout: 5000,\n reconnectStrategy: (retries: number) => Math.min(retries * 50, 3000),\n};\n\n/**\n * Singleton Redis client\n */\nlet redisClient: RedisClientType | undefined;\n\n/**\n * Get or create a Redis client instance\n */\nexport const getRedisClient = async (options: RedisOptions = {}): Promise<RedisClientType> => {\n if (redisClient?.isOpen) {\n return redisClient;\n }\n\n const mergedOptions = { ...DEFAULT_REDIS_OPTIONS, ...options };\n\n try {\n redisClient = createClient({\n url: mergedOptions.url,\n socket: {\n host: mergedOptions.host,\n port: mergedOptions.port,\n tls: mergedOptions.tls,\n connectTimeout: mergedOptions.connectTimeout,\n reconnectStrategy: mergedOptions.reconnectStrategy,\n },\n username: mergedOptions.username,\n password: mergedOptions.password,\n database: mergedOptions.database,\n }) as RedisClientType;\n\n // Set up error handling\n redisClient.on('error', (err) => {\n console.error('[open-ratelimit] Redis client error:', err);\n });\n\n await redisClient.connect();\n return redisClient;\n } catch (error) {\n throw new RedisConnectionError(error instanceof Error ? error.message : String(error));\n }\n};\n\n/**\n * Close Redis client if open\n */\nexport const closeRedisClient = async (): Promise<void> => {\n if (redisClient?.isOpen) {\n await redisClient.quit();\n redisClient = undefined;\n }\n};\n\n// ----------------------\n// Rate Limit Response\n// ----------------------\n\n/**\n * Rate limit check response\n */\nexport interface RatelimitResponse {\n /** Whether the request is allowed */\n success: boolean;\n /** Maximum number of requests allowed */\n limit: number;\n /** Number of requests remaining in the current window */\n remaining: number;\n /** Timestamp (ms) when the limit resets */\n reset: number;\n /** Seconds until retry is possible (only when success=false) */\n retryAfter?: number;\n /** Current pending requests count (only with analytics=true) */\n pending?: number;\n /** Requests per second (only with analytics=true) */\n throughput?: number;\n}\n\n// ----------------------\n// Ratelimiter Configuration\n// ----------------------\n\n/**\n * Rate limiter configuration options\n */\nexport interface RatelimitConfig {\n /** Redis client instance or connection options */\n redis: RedisClientType | RedisOptions;\n /** Rate limiting algorithm configuration */\n limiter: LimiterType;\n /** Key prefix for Redis */\n prefix?: string;\n /** Whether to collect analytics */\n analytics?: boolean;\n /** Redis operation timeout in ms */\n timeout?: number;\n /** Whether to use in-memory cache as fallback */\n ephemeralCache?: boolean;\n /** TTL for ephemeral cache entries in ms */\n ephemeralCacheTTL?: number;\n /** Whether to fail open (allow requests) when Redis is unavailable */\n failOpen?: boolean;\n /** Whether to disable console logs */\n silent?: boolean;\n}\n\n/**\n * Default rate limiter configuration\n */\nconst DEFAULT_CONFIG: Partial<RatelimitConfig> = {\n prefix: 'open-ratelimit',\n analytics: false,\n timeout: 1000,\n ephemeralCache: true,\n ephemeralCacheTTL: 60000,\n failOpen: false,\n silent: false,\n};\n\n// ----------------------\n// Main Ratelimiter Class\n// ----------------------\n\n/**\n * Main rate limiter implementation\n */\nexport class Ratelimit extends EventEmitter {\n private redis: RedisClientType | Promise<RedisClientType>;\n private limiter: LimiterType;\n private prefix: string;\n private analytics: boolean;\n private timeout: number;\n private ephemeralCache?: EphemeralCache;\n private failOpen: boolean;\n private silent: boolean;\n private scripts: Map<string, string> = new Map();\n\n /**\n * Create a new rate limiter instance\n */\n constructor(config: RatelimitConfig) {\n super();\n\n const finalConfig = { ...DEFAULT_CONFIG, ...config };\n\n // Handle Redis client or options\n if ('isOpen' in config.redis) {\n this.redis = config.redis as RedisClientType;\n } else {\n this.redis = getRedisClient(config.redis as RedisOptions);\n }\n\n this.limiter = finalConfig.limiter;\n this.prefix = finalConfig.prefix as string;\n this.analytics = !!finalConfig.analytics;\n this.timeout = finalConfig.timeout as number;\n this.failOpen = !!finalConfig.failOpen;\n this.silent = !!finalConfig.silent;\n\n // Create ephemeral cache if requested\n if (finalConfig.ephemeralCache) {\n this.ephemeralCache = new EphemeralCache(finalConfig.ephemeralCacheTTL);\n }\n\n // Initialize Lua scripts\n this.initScripts();\n }\n\n /**\n * Initialize Lua scripts for each algorithm\n */\n private initScripts(): void {\n // Sliding Window script\n this.scripts.set(\n 'slidingWindow',\n `\n local key = KEYS[1]\n local analyticsKey = KEYS[2]\n local now = tonumber(ARGV[1])\n local windowMs = tonumber(ARGV[2])\n local maxRequests = tonumber(ARGV[3])\n local doAnalytics = tonumber(ARGV[4])\n \n local windowStart = now - windowMs\n \n -- Remove counts older than the current window\n redis.call('ZREMRANGEBYSCORE', key, 0, windowStart)\n \n -- Get current count\n local count = redis.call('ZCARD', key)\n local success = count < maxRequests\n \n -- Add current timestamp if successful\n if success then\n redis.call('ZADD', key, now, now .. ':' .. math.random())\n count = count + 1\n end\n \n -- Set expiration to keep memory usage bounded\n redis.call('PEXPIRE', key, windowMs * 2)\n \n -- Analytics if requested\n if doAnalytics == 1 then\n redis.call('ZADD', analyticsKey, now, now)\n redis.call('PEXPIRE', analyticsKey, windowMs * 2)\n end\n \n -- Calculate when the oldest request expires\n local oldestTimestamp = 0\n if count >= maxRequests then\n local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')\n if #oldest >= 2 then\n oldestTimestamp = tonumber(oldest[2])\n end\n end\n \n -- Calculate pending and throughput if analytics enabled\n local pending = 0\n local throughput = 0\n if doAnalytics == 1 then\n pending = count\n -- Calculate requests in the last second\n local secondAgo = now - 1000\n throughput = redis.call('ZCOUNT', analyticsKey, secondAgo, '+inf')\n end\n \n -- Return results\n return {\n success and 1 or 0,\n maxRequests,\n math.max(0, maxRequests - count),\n now + windowMs,\n oldestTimestamp > 0 and math.ceil((oldestTimestamp + windowMs - now) / 1000) or 0,\n pending,\n throughput\n }\n `\n );\n\n // Fixed Window script\n this.scripts.set(\n 'fixedWindow',\n `\n local key = KEYS[1]\n local analyticsKey = KEYS[2]\n local limit = tonumber(ARGV[1])\n local windowMs = tonumber(ARGV[2])\n local doAnalytics = tonumber(ARGV[3])\n local now = tonumber(ARGV[4])\n \n -- Increment counter for this window\n local count = redis.call('INCR', key)\n \n -- Set expiration if this is first request in window\n if count == 1 then\n redis.call('PEXPIRE', key, windowMs)\n end\n \n local success = count <= limit\n \n -- Analytics if requested\n if doAnalytics == 1 then\n redis.call('ZADD', analyticsKey, now, now)\n redis.call('PEXPIRE', analyticsKey, windowMs)\n end\n \n -- Calculate remaining time in window\n local ttl = redis.call('PTTL', key)\n if ttl < 0 then ttl = windowMs end\n \n -- Calculate throughput if analytics enabled\n local throughput = 0\n if doAnalytics == 1 then\n -- Calculate requests in the last second\n local secondAgo = now - 1000\n throughput = redis.call('ZCOUNT', analyticsKey, secondAgo, '+inf')\n end\n \n return {\n success and 1 or 0,\n limit,\n math.max(0, limit - count),\n now + ttl,\n success and 0 or math.ceil(ttl / 1000),\n count,\n throughput\n }\n `\n );\n\n // Token Bucket script\n this.scripts.set(\n 'tokenBucket',\n `\n local key = KEYS[1]\n local analyticsKey = KEYS[2]\n local now = tonumber(ARGV[1])\n local refillRate = tonumber(ARGV[2])\n local refillInterval = tonumber(ARGV[3])\n local bucketCapacity = tonumber(ARGV[4])\n local doAnalytics = tonumber(ARGV[5])\n \n -- Get current bucket state\n local bucketInfo = redis.call('HMGET', key, 'tokens', 'lastRefill')\n local tokens = tonumber(bucketInfo[1]) or bucketCapacity\n local lastRefill = tonumber(bucketInfo[2]) or 0\n \n -- Calculate token refill\n local elapsedTime = now - lastRefill\n local tokensToAdd = math.floor(elapsedTime * (refillRate / refillInterval))\n \n if tokensToAdd > 0 then\n -- Add tokens based on elapsed time\n tokens = math.min(bucketCapacity, tokens + tokensToAdd)\n lastRefill = now\n end\n \n -- Try to consume a token\n local success = tokens >= 1\n if success then\n tokens = tokens - 1\n end\n \n -- Save updated bucket state\n redis.call('HMSET', key, 'tokens', tokens, 'lastRefill', lastRefill)\n redis.call('PEXPIRE', key, refillInterval * 2)\n \n -- Analytics if requested\n if doAnalytics == 1 then\n redis.call('ZADD', analyticsKey, now, now)\n redis.call('PEXPIRE', analyticsKey, refillInterval)\n end\n \n -- Calculate time until next token refill\n local timeToNextToken = success and 0 or math.ceil((1 - tokens) * (refillInterval / refillRate))\n \n -- Calculate throughput if analytics enabled\n local throughput = 0\n if doAnalytics == 1 then\n -- Calculate requests in the last second\n local secondAgo = now - 1000\n throughput = redis.call('ZCOUNT', analyticsKey, secondAgo, '+inf')\n end\n \n return {\n success and 1 or 0,\n bucketCapacity,\n tokens,\n now + (refillInterval / refillRate),\n timeToNextToken,\n bucketCapacity - tokens,\n throughput\n }\n `\n );\n }\n\n /**\n * Get the Redis client with timeout protection\n */\n private async getRedis(): Promise<RedisClientType> {\n try {\n const timeoutPromise = new Promise<never>((_, reject) => {\n setTimeout(\n () => reject(new RedisConnectionError(`Connection timed out after ${this.timeout}ms`)),\n this.timeout\n );\n });\n\n const redis = await Promise.race([this.redis, timeoutPromise]);\n\n // Check if Redis is connected\n if (!redis.isOpen) {\n await redis.connect();\n }\n\n // Verify connection with ping\n await redis.ping();\n\n return redis;\n } catch (error) {\n this.emit(\n 'error',\n new RedisConnectionError(error instanceof Error ? error.message : String(error))\n );\n\n if (!this.failOpen) {\n throw new RedisConnectionError(error instanceof Error ? error.message : String(error));\n }\n\n // If fail-open is enabled, we need to return something that won't break\n // downstream code, but this will never actually be used\n return Promise.resolve(this.redis) as Promise<RedisClientType>;\n }\n }\n\n /**\n * Apply rate limit for an identifier\n */\n async limit(identifier: string): Promise<RatelimitResponse> {\n const now = Date.now();\n const key = `${this.prefix}:${identifier}`;\n\n try {\n // Try Redis first\n return await this.applyLimit(key, now);\n } catch (error) {\n this.emit('error', error);\n\n // Handle Redis failure\n if (this.ephemeralCache && this.limiter.type === 'slidingWindow') {\n if (!this.silent) {\n console.warn(\n `[open-ratelimit] Redis error, using ephemeral cache: ${\n error instanceof Error ? error.message : String(error)\n }`\n );\n }\n\n // Fall back to ephemeral cache\n return this.applyEphemeralLimit(key, identifier, now);\n }\n\n // If Redis fails and no ephemeral cache or incompatible limiter type\n if (!this.failOpen) {\n throw error;\n }\n\n // If fail-open is enabled, allow the request\n this.emit('failOpen', { identifier, error });\n if (!this.silent) {\n console.warn(\n `[open-ratelimit] Redis error, failing open for ${identifier}: ${\n error instanceof Error ? error.message : String(error)\n }`\n );\n }\n\n // Get limit based on limiter type\n const limit = 'limit' in this.limiter ? this.limiter.limit : 10;\n return {\n success: true, // Fail open\n limit,\n remaining: limit - 1,\n reset: now + 60000, // Arbitrary 1-minute reset\n };\n }\n }\n\n /**\n * Apply rate limit using appropriate algorithm\n */\n private async applyLimit(key: string, now: number): Promise<RatelimitResponse> {\n const redis = await this.getRedis();\n\n switch (this.limiter.type) {\n case 'slidingWindow':\n return this.applySlidingWindowLimit(redis, key, now);\n case 'fixedWindow':\n return this.applyFixedWindowLimit(redis, key, now);\n case 'tokenBucket':\n return this.applyTokenBucketLimit(redis, key, now);\n default:\n throw new RatelimitError(\n `Unknown limiter type: ${\n // biome-ignore lint/suspicious/noExplicitAny: <explanation>\n (this.limiter as any).type\n }`\n );\n }\n }\n\n /**\n * Apply sliding window rate limit\n */\n private async applySlidingWindowLimit(\n redis: RedisClientType,\n key: string,\n now: number\n ): Promise<RatelimitResponse> {\n try {\n const limiter = this.limiter as SlidingWindowOptions;\n const analyticsKey = `${key}:analytics`;\n\n const result = await redis.eval(this.scripts.get('slidingWindow') as string, {\n keys: [key, analyticsKey],\n arguments: [\n now.toString(),\n limiter.windowMs.toString(),\n limiter.limit.toString(),\n this.analytics ? '1' : '0',\n ],\n });\n\n if (!Array.isArray(result)) {\n throw new RatelimitError('Invalid response from Redis');\n }\n\n const response: RatelimitResponse = {\n success: Boolean(result[0]),\n limit: Number(result[1]),\n remaining: Number(result[2]),\n reset: Number(result[3]),\n };\n\n // Add conditional properties\n const retryAfter = Number(result[4]);\n if (retryAfter > 0) response.retryAfter = retryAfter;\n\n if (this.analytics) {\n response.pending = Number(result[5]);\n response.throughput = Number(result[6]);\n }\n\n // Store in ephemeral cache if available\n if (this.ephemeralCache) {\n this.ephemeralCache.set(key, limiter.limit - response.remaining, limiter.windowMs);\n }\n\n // Emit events\n this.emit(response.success ? 'allowed' : 'limited', {\n identifier: key.substring(this.prefix.length + 1),\n remaining: response.remaining,\n limit: response.limit,\n });\n\n return response;\n } catch (error) {\n throw new RatelimitError(\n `Sliding window limit error: ${error instanceof Error ? error.message : String(error)}`\n );\n }\n }\n\n /**\n * Apply fixed window rate limit\n */\n private async applyFixedWindowLimit(\n redis: RedisClientType,\n key: string,\n now: number\n ): Promise<RatelimitResponse> {\n try {\n const limiter = this.limiter as FixedWindowOptions;\n\n // Create window key with fixed time boundary\n const windowKey = `${key}:${Math.floor(now / limiter.windowMs)}`;\n const analyticsKey = `${key}:analytics`;\n\n const result = await redis.eval(this.scripts.get('fixedWindow') as string, {\n keys: [windowKey, analyticsKey],\n arguments: [\n limiter.limit.toString(),\n limiter.windowMs.toString(),\n this.analytics ? '1' : '0',\n now.toString(),\n ],\n });\n\n if (!Array.isArray(result)) {\n throw new RatelimitError('Invalid response from Redis');\n }\n\n const response: RatelimitResponse = {\n success: Boolean(result[0]),\n limit: Number(result[1]),\n remaining: Number(result[2]),\n reset: Number(result[3]),\n };\n\n const retryAfter = Number(result[4]);\n if (retryAfter > 0) response.retryAfter = retryAfter;\n\n if (this.analytics) {\n response.pending = Number(result[5]);\n response.throughput = Number(result[6]);\n }\n\n // Emit events\n this.emit(response.success ? 'allowed' : 'limited', {\n identifier: key.substring(this.prefix.length + 1),\n remaining: response.remaining,\n limit: response.limit,\n });\n\n return response;\n } catch (error) {\n throw new RatelimitError(\n `Fixed window limit error: ${error instanceof Error ? error.message : String(error)}`\n );\n }\n }\n\n /**\n * Apply token bucket rate limit\n */\n private async applyTokenBucketLimit(\n redis: RedisClientType,\n key: string,\n now: number\n ): Promise<RatelimitResponse> {\n try {\n const limiter = this.limiter as TokenBucketOptions;\n const analyticsKey = `${key}:analytics`;\n\n const result = await redis.eval(this.scripts.get('tokenBucket') as string, {\n keys: [key, analyticsKey],\n arguments: [\n now.toString(),\n limiter.refillRate.toString(),\n limiter.interval.toString(),\n limiter.limit.toString(),\n this.analytics ? '1' : '0',\n ],\n });\n\n if (!Array.isArray(result)) {\n throw new RatelimitError('Invalid response from Redis');\n }\n\n const response: RatelimitResponse = {\n success: Boolean(result[0]),\n limit: Number(result[1]),\n remaining: Number(result[2]),\n reset: Number(result[3]),\n };\n\n const retryAfter = Number(result[4]);\n if (retryAfter > 0) response.retryAfter = retryAfter;\n\n if (this.analytics) {\n response.pending = Number(result[5]);\n response.throughput = Number(result[6]);\n }\n\n // Emit events\n this.emit(response.success ? 'allowed' : 'limited', {\n identifier: key.substring(this.prefix.length + 1),\n remaining: response.remaining,\n limit: response.limit,\n });\n\n return response;\n } catch (error) {\n throw new RatelimitError(\n `Token bucket limit error: ${error instanceof Error ? error.message : String(error)}`\n );\n }\n }\n\n /**\n * Apply rate limit using ephemeral cache (for fallback)\n */\n private applyEphemeralLimit(key: string, identifier: string, now: number): RatelimitResponse {\n if (!this.ephemeralCache) {\n throw new RatelimitError('Ephemeral cache not available');\n }\n\n if (this.limiter.type !== 'slidingWindow') {\n throw new RatelimitError(\n `Ephemeral cache only supports sliding window, got: ${this.limiter.type}`\n );\n }\n\n const limiter = this.limiter as SlidingWindowOptions;\n const count = this.ephemeralCache.increment(key, limiter.windowMs);\n const success = count <= limiter.limit;\n\n const response: RatelimitResponse = {\n success,\n limit: limiter.limit,\n remaining: Math.max(0, limiter.limit - count),\n reset: now + limiter.windowMs,\n };\n\n if (!success) {\n response.retryAfter = Math.ceil(limiter.windowMs / 1000);\n }\n\n // Emit events\n this.emit(success ? 'allowed' : 'limited', {\n identifier,\n remaining: response.remaining,\n limit: response.limit,\n fromCache: true,\n });\n\n return response;\n }\n\n /**\n * Block until rate limit allows or max wait time is reached\n */\n async block(\n identifier: string,\n options?: {\n maxWaitMs?: number;\n maxAttempts?: number;\n retryDelayMs?: number;\n }\n ): Promise<RatelimitResponse> {\n const { maxWaitMs = 5000, maxAttempts = 50, retryDelayMs = 100 } = options || {};\n\n const startTime = Date.now();\n let attempts = 0;\n\n while (attempts < maxAttempts) {\n attempts++;\n\n const response = await this.limit(identifier);\n if (response.success) {\n return response;\n }\n\n const currentTime = Date.now();\n if (currentTime - startTime >= maxWaitMs) {\n throw new RateLimitExceededError(identifier, response.retryAfter || 1);\n }\n\n // Dynamic backoff based on retry-after, but within bounds\n const waitTime = Math.max(\n 50,\n Math.min(1000, response.retryAfter ? (response.retryAfter * 1000) / 4 : retryDelayMs)\n );\n\n // Emit waiting event\n this.emit('waiting', {\n identifier,\n attempt: attempts,\n waitTime,\n elapsed: currentTime - startTime,\n });\n\n // Wait before retry\n await new Promise((resolve) => setTimeout(resolve, waitTime));\n }\n\n // This is a safeguard in case we reach max attempts\n throw new RateLimitExceededError(identifier, 1);\n }\n\n /**\n * Reset rate limit for an identifier\n */\n async reset(identifier: string): Promise<boolean> {\n try {\n const redis = await this.getRedis();\n const key = `${this.prefix}:${identifier}`;\n\n // Clear all keys related to this identifier\n const keys = [key, `${key}:analytics`];\n\n // For fixed window, we need to find all window keys\n if (this.limiter.type === 'fixedWindow') {\n const pattern = `${key}:*`;\n const scanResult = await redis.scan(0, { MATCH: pattern, COUNT: 100 });\n\n if (scanResult.keys.length > 0) {\n keys.push(...scanResult.keys);\n }\n }\n\n // Delete all keys\n if (keys.length > 0) {\n await redis.del(keys);\n }\n\n // Also clear ephemeral cache if available\n if (this.ephemeralCache) {\n this.ephemeralCache.set(key, 0, 0);\n }\n\n this.emit('reset', { identifier });\n return true;\n } catch (error) {\n this.emit(\n 'error',\n new RatelimitError(\n `Failed to reset rate limit: ${error instanceof Error ? error.message : String(error)}`\n )\n );\n\n if (!this.failOpen) {\n throw new RatelimitError(\n `Failed to reset rate limit: ${error instanceof Error ? error.message : String(error)}`\n );\n }\n\n return false;\n }\n }\n\n /**\n * Get current rate limit statistics\n */\n async getStats(identifier: string): Promise<{\n used: number;\n remaining: number;\n limit: number;\n reset: number;\n }> {\n const response = await this.limit(identifier);\n\n // Return limit info without consuming a token\n return {\n used: response.limit - response.remaining,\n remaining: response.remaining,\n limit: response.limit,\n reset: response.reset,\n };\n }\n\n /**\n * Check if rate limit is exceeded without consuming a token\n */\n async check(identifier: string): Promise<boolean> {\n const stats = await this.getStats(identifier);\n return stats.remaining > 0;\n }\n\n /**\n * Clean up resources\n */\n async close(): Promise<void> {\n if (this.ephemeralCache) {\n this.ephemeralCache.destroy();\n }\n\n // Don't close Redis if it was passed in\n if (typeof this.redis === 'object' && 'quit' in this.redis) {\n // We don't want to close a client that might be shared\n // Only close if we created it\n }\n\n this.removeAllListeners();\n }\n}\n\n// ----------------------\n// Factory Functions\n// ----------------------\n\n// --- Potentially Deprecated Factory Function ---\n/**\n * Creates a single rate limiter instance with its own Redis client connection.\n * Does not use the shared client management features of the registry.\n * Consider using `createSingletonRateLimiter` with appropriate Redis options instead\n * if client reuse is desired.\n *\n * @deprecated Consider using createSingletonRateLimiter with the registry for better client management.\n */\nexport const createRateLimiter = async (\n config: Omit<RatelimitConfig, 'redis'> & {\n redis?: RedisOptions;\n }\n): Promise<Ratelimit> => {\n const redisClient = await getRedisClient(config.redis || {});\n\n return new Ratelimit({\n redis: redisClient,\n ...config,\n });\n};\n\nexport { initRateLimit } from './client-registry'; // Export the builder init function\nexport type { RateLimitBuilder } from './client-registry'; // Export the builder type interface\n// Export everything\n\nexport {\n InitLimitersOptions,\n RegisterConfigParam,\n initializeLimiters,\n createLimiterAccessor,\n getInitializedLimiter,\n} from './initialize-limiter';\nexport default {\n Ratelimit,\n createRateLimiter,\n fixedWindow,\n slidingWindow,\n tokenBucket,\n parseTimeWindow,\n getRedisClient,\n closeRedisClient,\n RatelimitError,\n RedisConnectionError,\n RateLimitExceededError,\n};\n","import { EventEmitter } from 'events';\nimport { RedisClientType } from 'redis';\nimport {\n Ratelimit,\n RatelimitConfig,\n RedisOptions,\n getRedisClient, // Factory function from the library\n slidingWindow, // Default limiter example\n} from './index'; // Adjust import path if core types are elsewhere\n\n/**\n * INTERNAL class managing rate limiter instances and their Redis clients.\n */\n// Note: Still considering not exporting this if only init is the API\nexport class _InternalRateLimiterRegistry<TAllNames extends string> {\n // Made generic\n // Use the generic type for keys where appropriate\n private limiters: Partial<Record<TAllNames, Ratelimit | Promise<Ratelimit>>> = {};\n private redisClients: Record<string, Promise<RedisClientType>> = {};\n private registryEmitter = new EventEmitter();\n private defaultRedisOpts: RedisOptions;\n\n constructor(defaultRedisOptions: RedisOptions = {}) {\n this.defaultRedisOpts = defaultRedisOptions;\n }\n\n /**\n * Generates a unique key for a Redis configuration to enable client reuse.\n * Made public for use by the builder wrapper.\n */\n public getRedisClientKey(config?: {\n // Changed to public\n redis?: RedisOptions;\n envRedisKey?: string;\n }): string {\n const specificOptions = config?.redis || {};\n const specificEnvKey = config?.envRedisKey;\n const effectiveOptions = { ...this.defaultRedisOpts, ...specificOptions };\n const effectiveEnvKey = specificEnvKey;\n\n const envUrl = effectiveEnvKey ? process.env[effectiveEnvKey] : undefined;\n if (envUrl?.trim()) return `env:${envUrl.trim()}`;\n if (effectiveOptions.url?.trim()) return `opts_url:${effectiveOptions.url.trim()}`;\n try {\n const keyOptions = {\n host: effectiveOptions.host,\n port: effectiveOptions.port,\n database: effectiveOptions.database,\n username: effectiveOptions.username,\n };\n return `opts_obj:${JSON.stringify(keyOptions)}`;\n } catch (e) {\n return `opts_fallback:${Date.now()}_${Math.random()}`;\n }\n }\n\n // --- getManagedRedisClient remains private ---\n private getManagedRedisClient(config?: {\n redis?: RedisOptions;\n envRedisKey?: string;\n }): Promise<RedisClientType> {\n // Pass effective options when calling getRedisClientKey internally\n const effectiveOptions = { ...this.defaultRedisOpts, ...(config?.redis || {}) };\n const clientKey = this.getRedisClientKey({\n redis: effectiveOptions,\n envRedisKey: config?.envRedisKey,\n });\n\n if (!this.redisClients[clientKey]) {\n console.log(\n `[_InternalRateLimiterRegistry] Creating new Redis client promise for key: ${clientKey}`\n );\n let finalFactoryOptions: RedisOptions = {};\n const envUrl = config?.envRedisKey ? process.env[config.envRedisKey] : undefined;\n if (envUrl?.trim()) {\n finalFactoryOptions = { url: envUrl.trim() };\n } else {\n // Use the potentially merged options\n finalFactoryOptions = effectiveOptions;\n }\n this.redisClients[clientKey] = getRedisClient(finalFactoryOptions)\n .then((client) => {\n /* ...log, emit */ return client;\n })\n .catch((err) => {\n /* ...log, delete, emit */ throw err;\n });\n }\n return this.redisClients[clientKey];\n }\n\n // Use the generic TAllNames for the name parameter type\n public register(\n // Removed <T extends string> here\n name: TAllNames, // Use the instance-level generic type\n config?: Omit<RatelimitConfig, 'redis'> & {\n redis?: RedisOptions;\n envRedisKey?: string;\n }\n ): Promise<Ratelimit> {\n if (typeof name !== 'string' || !name.trim()) {\n // Keep runtime check\n return Promise.reject(\n new Error('[_InternalRateLimiterRegistry] Registration name must be a non-empty string.')\n );\n }\n // No need for trimmedName if using TAllNames directly as key type\n if (this.limiters[name]) {\n return Promise.resolve(this.limiters[name] as Ratelimit | Promise<Ratelimit>); // Type assertion needed\n }\n console.log(`[_InternalRateLimiterRegistry] Initializing limiter: \"${String(name)}\"`); // Coerce to string for logging\n\n const limiterOrDefault = config?.limiter || slidingWindow(10, '10 s');\n const effectiveRegisterConfig = { limiter: limiterOrDefault, ...config };\n\n const initializationPromise = (async (): Promise<Ratelimit> => {\n try {\n const redisClient = await this.getManagedRedisClient({\n redis: effectiveRegisterConfig.redis,\n envRedisKey: effectiveRegisterConfig.envRedisKey,\n });\n const finalRatelimitConfig: RatelimitConfig = {\n redis: redisClient,\n limiter: effectiveRegisterConfig.limiter,\n prefix: effectiveRegisterConfig.prefix,\n analytics: effectiveRegisterConfig.analytics,\n timeout: effectiveRegisterConfig.timeout,\n ephemeralCache: effectiveRegisterConfig.ephemeralCache,\n ephemeralCacheTTL: effectiveRegisterConfig.ephemeralCacheTTL,\n failOpen: effectiveRegisterConfig.failOpen,\n silent: effectiveRegisterConfig.silent,\n };\n const instance = new Ratelimit(finalRatelimitConfig);\n const clientKey = this.getRedisClientKey(effectiveRegisterConfig); // Use public method\n console.log(\n `[_InternalRateLimiterRegistry] Registered limiter \"${String(\n name\n )}\" using Redis client key: ${clientKey}`\n );\n this.registryEmitter.emit('limiterRegister', { name: name, clientKey });\n this.limiters[name] = instance;\n return instance;\n } catch (error) {\n console.error(\n `[_InternalRateLimiterRegistry] Failed to initialize limiter \"${String(name)}\":`,\n error\n );\n delete this.limiters[name];\n this.registryEmitter.emit('limiterError', { name: name, error });\n throw error;\n }\n })();\n this.limiters[name] = initializationPromise;\n return initializationPromise;\n }\n\n // Use the generic TAllNames for the name parameter type\n public get(name: TAllNames): Ratelimit {\n // Removed <T extends string>\n const instance = this.limiters[name];\n if (!instance) {\n throw new Error(`[_InternalRateLimiterRegistry] Limiter \"${String(name)}\" not found.`);\n }\n if (instance instanceof Promise) {\n throw new Error(\n `[_InternalRateLimiterRegistry] Limiter \"${String(name)}\" still initializing.`\n );\n }\n return instance as Ratelimit; // Type assertion\n }\n\n // isInitialized still takes string as it might be called with arbitrary values\n public isInitialized(name: string): boolean {\n const instance = this.limiters[name as TAllNames]; // Cast needed for lookup\n return !!instance && !(instance instanceof Promise);\n }\n\n public async close(): Promise<void> {\n // ... (close logic remains the same, using client.quit()) ...\n const clientKeys = Object.keys(this.redisClients);\n console.log(\n `[_InternalRateLimiterRegistry] Closing ${clientKeys.length} managed Redis client(s)...`\n );\n const quitPromises: Promise<void>[] = [];\n for (const key of clientKeys) {\n const clientPromise = this.redisClients[key];\n const quitPromise = clientPromise\n .then((client) =>\n client && typeof client.quit === 'function'\n ? client.quit().then(() => {\n // not returning client\n })\n : Promise.resolve()\n )\n .catch((err) => {\n console.error(`Error quitting client ${key}:`, err);\n return Promise.resolve();\n });\n quitPromises.push(quitPromise);\n }\n await Promise.allSettled(quitPromises);\n this.redisClients = {};\n this.limiters = {};\n this.registryEmitter.emit('close');\n console.log('[_InternalRateLimiterRegistry] Registry cleared.');\n }\n\n public getClientPromiseForKey(key: string): Promise<RedisClientType> | undefined {\n return this.redisClients[key];\n }\n\n // Event listeners remain the same\n // biome-ignore lint/suspicious/noExplicitAny: <explanation>\n public on(eventName: string, listener: (...args: any[]) => void): this {\n this.registryEmitter.on(eventName, listener);\n return this;\n }\n // biome-ignore lint/suspicious/noExplicitAny: <explanation>\n public off(eventName: string, listener: (...args: any[]) => void): this {\n this.registryEmitter.off(eventName, listener);\n return this;\n }\n}\n\n// --- Builder Function ---\n\ntype RegisterConfigParam = Omit<RatelimitConfig, 'redis'> & {\n redis?: RedisOptions;\n envRedisKey?: string;\n};\n\n// Define the interface for the returned builder object, now generic over names\nexport interface RateLimitBuilder<TNames extends string> {\n // Made generic\n /** Registers or retrieves a rate limiter instance with the given name and config */\n register: (name: TNames, config?: RegisterConfigParam) => Promise<Ratelimit>; // Uses TNames\n /** Synchronously gets an initialized rate limiter instance by name */\n get: (name: TNames) => Ratelimit; // Uses TNames\n /** Checks if a limiter instance is registered and initialized */\n isInitialized: (name: TNames | string) => boolean; // Can check specific or any string\n /** Closes all Redis clients managed by this registry instance */\n close: () => Promise<void>;\n /** Allows listening to registry events */\n on: (\n eventName: 'redisConnect' | 'redisError' | 'limiterRegister' | 'limiterError' | 'close',\n // biome-ignore lint/suspicious/noExplicitAny: <explanation>\n listener: (...args: any[]) => void\n ) => RateLimitBuilder<TNames>; // Returns typed builder\n /** Allows removing registry event listeners */\n off: (\n eventName: 'redisConnect' | 'redisError' | 'limiterRegister' | 'limiterError' | 'close',\n // biome-ignore lint/suspicious/noExplicitAny: <explanation>\n listener: (...args: any[]) => void\n ) => RateLimitBuilder<TNames>; // Returns typed builder\n /** Attempts to get the managed Redis client promise based on configuration key */\n getClient: (config: { redis?: RedisOptions; envRedisKey?: string }) =>\n | Promise<RedisClientType>\n | undefined;\n}\n\n/**\n * Initializes a rate limiter registry instance and returns a typed builder API.\n * @param options Configuration options for the registry instance.\n * @param options.defaultRedisOptions Default Redis connection options.\n * @typeparam TNames A string literal type union representing the valid names for limiters in this registry instance (e.g., 'api' | 'webhooks'). Defaults to `string`.\n */\n// Make initRateLimit generic, defaulting TNames to string if not provided\nexport function initRateLimit<TNames extends string = string>(options?: {\n defaultRedisOptions?: RedisOptions;\n}): RateLimitBuilder<TNames> {\n // Pass TNames generic type to the internal registry\n const registry = new _InternalRateLimiterRegistry<TNames>(options?.defaultRedisOptions);\n\n // Explicitly define the builder object to be returned *before* defining methods\n // that reference it (like on/off needing to return `builder`).\n const builder: RateLimitBuilder<TNames> = {\n // Bind methods to the specific registry instance created\n register: registry.register.bind(registry),\n get: registry.get.bind(registry),\n isInitialized: registry.isInitialized.bind(registry),\n close: registry.close.bind(registry),\n on: (eventName, listener) => {\n registry.on(eventName, listener);\n // Return the captured builder object, not 'this'\n return builder;\n },\n off: (eventName, listener) => {\n registry.off(eventName, listener);\n // Return the captured builder object, not 'this'\n return builder;\n },\n getClient: (config) => {\n // Access the now public method on the registry instance\n const key = registry.getRedisClientKey(config);\n return registry.getClientPromiseForKey(key);\n },\n };\n\n return builder; // Return the fully constructed builder object\n}\n","import { RateLimitBuilder, Ratelimit, RatelimitConfig, RedisOptions } from '../src/index';\n\n// 2. Define the configuration type expected by the registry's register method\nexport type RegisterConfigParam = Omit<RatelimitConfig, 'redis'> & {\n redis?: RedisOptions;\n envRedisKey?: string;\n};\n\n/**\n * Options for the limiter initialization utility\n */\nexport interface InitLimitersOptions<TNames extends string> {\n /** Registry instance to use for limiter registration */\n registry: RateLimitBuilder<TNames>;\n\n /** Configuration for each limiter */\n configs: Record<TNames, RegisterConfigParam>;\n\n /** Callback fired when each limiter is registered */\n onRegister?: (name: TNames) => void;\n\n /** Callback fired when all limiters are initialized */\n onComplete?: () => void;\n\n /** Whether to throw on initialization errors (default: true) */\n throwOnError?: boolean;\n\n /** Whether to log progress (default: true) */\n verbose?: boolean;\n}\n\n/**\n * Registers and initializes multiple rate limiters from a configuration object\n *\n * @param options Configuration options for initialization\n * @returns Promise that resolves to a record of initialized limiters\n * @throws If any limiter fails to initialize and throwOnError is true\n *\n * @example\n * ```typescript\n * // Initialize all limiters at once\n * const limiters = await initializeLimiters({\n * registry: rl,\n * configs: limiterConfigs\n * });\n *\n * // Use specific limiters\n * const apiLimiter = limiters.apiPublic;\n * ```\n */\nexport async function initializeLimiters<TNames extends string>(\n options: InitLimitersOptions<TNames>\n): Promise<Record<TNames, Ratelimit>> {\n const {\n registry,\n configs,\n onRegister,\n onComplete,\n throwOnError = true,\n verbose = true,\n } = options;\n\n if (verbose) console.log('Initializing rate limiters...');\n\n // biome-ignore lint/suspicious/noExplicitAny: <explanation>\n const registrationPromises: Array<Promise<{ name: TNames; limiter?: Ratelimit; error?: any }>> =\n [];\n const results: Partial<Record<TNames, Ratelimit>> = {};\n\n // Iterate through the config and register each limiter\n for (const name in configs) {\n if (Object.prototype.hasOwnProperty.call(configs, name)) {\n const limiterName = name as TNames;\n if (verbose) console.log(`- Registering: ${String(limiterName)}`);\n\n const promise = registry\n .register(limiterName, configs[limiterName])\n .then((limiter) => {\n if (onRegister) onRegister(limiterName);\n return { name: limiterName, limiter };\n })\n .catch((error) => {\n console.error(`Failed to initialize limiter \"${String(limiterName)}\":`, error);\n if (throwOnError) throw error;\n return { name: limiterName, error };\n });\n\n registrationPromises.push(promise);\n }\n }\n\n // Wait for all limiters to initialize\n const settled = await Promise.allSettled(registrationPromises);\n\n // Process results\n for (const result of settled) {\n if (result.status === 'fulfilled' && !('error' in result.value)) {\n results[result.value.name] = result.value.limiter;\n }\n }\n\n if (verbose) console.log('✅ All limiters initialized.');\n if (onComplete) onComplete();\n\n // Cast to non-partial record since we'll either have all entries or have thrown\n return results as Record<TNames, Ratelimit>;\n}\n\n/**\n * Type-safe accessor for limiters that ensures they are initialized\n *\n * @param name Name of the limiter to retrieve\n * @param registry Registry instance containing the limiters\n * @returns The initialized limiter\n * @throws If the limiter is not found or not initialized\n *\n * @example\n * ```typescript\n * // Get a specific limiter\n * const apiLimiter = getInitializedLimiter('apiPublic', rl);\n * ```\n */\nexport function getInitializedLimiter<TNames extends string>(\n name: TNames,\n registry: RateLimitBuilder<TNames>\n): Ratelimit {\n try {\n return registry.get(name);\n } catch (error) {\n throw new Error(`Limiter \"${String(name)}\" not initialized. Did you call initializeLimiters?`);\n }\n}\n\n/**\n * Create a type-safe accessor function for a specific registry\n *\n * @param registry Registry instance containing limiters\n * @returns A function that retrieves initialized limiters\n *\n * @example\n * ```typescript\n * // Create a bound getter for your registry\n * const getLimiter = createLimiterAccessor(rl);\n *\n * // Use it to get specific limiters\n * const apiLimiter = getLimiter('apiPublic');\n * ```\n */\nexport function createLimiterAccessor<TNames extends string>(\n registry: RateLimitBuilder<TNames>\n): (name: TNames) => Ratelimit {\n return (name: TNames) => getInitializedLimiter(name, registry);\n}\n"],"mappings":"AAAA,OAAS,gBAAAA,MAAoB,SAC7B,OAA0B,gBAAAC,MAAoB,QCD9C,OAAS,gBAAAC,MAAoB,SActB,IAAMC,EAAN,KAA6D,CAQlE,YAAYC,EAAoC,CAAC,EAAG,CALpD,KAAQ,SAAuE,CAAC,EAChF,KAAQ,aAAyD,CAAC,EAClE,KAAQ,gBAAkB,IAAIC,EAI5B,KAAK,iBAAmBD,CAC1B,CAMO,kBAAkBE,EAId,CACT,IAAMC,EAAkBD,GAAQ,OAAS,CAAC,EACpCE,EAAiBF,GAAQ,YACzBG,EAAmB,CAAE,GAAG,KAAK,iBAAkB,GAAGF,CAAgB,