UNPKG

ts-rate-limiter

Version:

High-performance, flexible rate limiting for TypeScript and Bun

290 lines (243 loc) 8.35 kB
export declare class RateLimiter { private windowMs: number private maxRequests: number private storage: StorageProvider private keyGenerator: (request: Request) => string | Promise<string> private skipFailedRequests: boolean private algorithm: RateLimitAlgorithm private standardHeaders: boolean private legacyHeaders: boolean private skipFn?: (request: Request) => boolean | Promise<boolean> private handler?: (request: Request, result: RateLimitResult) => Response | Promise<Response> private draftMode: boolean private tokenBucketOptions?: TokenBucketOptions private tokenBuckets: Map<string, { tokens: number, lastRefill: number }> constructor(options: RateLimiterOptions) { this.windowMs = options.windowMs || config.windowMs || 60 * 1000 this.maxRequests = options.maxRequests || config.maxRequests || 100 if (options.maxRequests === 0) { this.maxRequests = 0 } if (options.storage) { this.storage = options.storage } else if (config.storage === 'redis' && config.redis) { throw new Error('Redis client must be provided explicitly when using Redis storage') } else { this.storage = new MemoryStorage(config.memoryStorage) } this.skipFailedRequests = options.skipFailedRequests ?? false this.keyGenerator = options.keyGenerator || this.defaultKeyGenerator this.algorithm = options.algorithm || config.algorithm || 'fixed-window' this.standardHeaders = options.standardHeaders ?? config.standardHeaders ?? true this.legacyHeaders = options.legacyHeaders ?? config.legacyHeaders ?? true this.skipFn = options.skip this.handler = options.handler this.draftMode = options.draftMode ?? config.draftMode ?? false this.tokenBuckets = new Map() if (this.algorithm === 'token-bucket') { this.tokenBucketOptions = { capacity: this.maxRequests, refillRate: this.maxRequests / (this.windowMs / 1000) / 1000, } } if (config.verbose) { const storageType = options.storage ? (options.storage instanceof MemoryStorage ? 'memory (custom)' : 'redis (custom)') : config.storage console.warn(`[ts-rate-limiter] Initialized with: - Algorithm: ${this.algorithm} - Window: ${this.windowMs}ms - Max Requests: ${this.maxRequests} - Storage: ${storageType} - Draft Mode: ${this.draftMode ? 'enabled' : 'disabled'}`) } } private defaultKeyGenerator(request: Request): string { const forwarded = request.headers.get('x-forwarded-for') if (forwarded) { return forwarded.split(',')[0].trim() } const clientIP = request.headers.get('x-client-ip') if (clientIP) { return clientIP } const realIP = request.headers.get('x-real-ip') if (realIP) { return realIP } const socket = (request as any).socket return socket?.remoteAddress || '127.0.0.1' } async check(request: Request): Promise<RateLimitResult> { try { if (this.skipFn && await this.skipFn(request)) { return this.createAllowedResult() } if (this.maxRequests === 0) { return { allowed: false, current: 1, limit: 0, remaining: this.windowMs, resetTime: Date.now() + this.windowMs, } } const key = await this.keyGenerator(request) if (this.algorithm === 'sliding-window' && this.storage.getSlidingWindowCount) { return this.checkSlidingWindow(key) } else if (this.algorithm === 'token-bucket') { return this.checkTokenBucket(key) } else { return this.checkFixedWindow(key) } } catch (error) { if (this.skipFailedRequests) { return this.createAllowedResult() } throw error } } private async checkFixedWindow(key: string): Promise<RateLimitResult> { const { count, resetTime } = await this.storage.increment(key, this.windowMs) const remaining = Math.max(0, resetTime - Date.now()) const allowed = this.draftMode ? true : count <= this.maxRequests return { allowed, current: count, limit: this.maxRequests, remaining, resetTime, } } private async checkSlidingWindow(key: string): Promise<RateLimitResult> { if (!this.storage.getSlidingWindowCount) { return this.checkFixedWindow(key) } await this.storage.increment(key, this.windowMs) const count = await this.storage.getSlidingWindowCount(key, this.windowMs) const now = Date.now() const resetTime = now + this.windowMs const allowed = this.draftMode ? true : count <= this.maxRequests return { allowed, current: count, limit: this.maxRequests, remaining: this.windowMs, resetTime, } } private async checkTokenBucket(key: string): Promise<RateLimitResult> { if (!this.tokenBucketOptions) { return this.checkFixedWindow(key) } const now = Date.now() let bucket = this.tokenBuckets.get(key) if (!bucket) { bucket = { tokens: this.tokenBucketOptions.capacity, lastRefill: now, } this.tokenBuckets.set(key, bucket) } const timeElapsed = now - bucket.lastRefill const tokensToAdd = timeElapsed * this.tokenBucketOptions.refillRate if (tokensToAdd > 0) { bucket.tokens = Math.min( bucket.tokens + tokensToAdd, this.tokenBucketOptions.capacity, ) bucket.lastRefill = now } const allowed = this.draftMode ? true : bucket.tokens >= 1 if (allowed && !this.draftMode) { bucket.tokens -= 1 } const msUntilNextToken = bucket.tokens >= this.tokenBucketOptions.capacity ? this.windowMs : Math.ceil(1 / this.tokenBucketOptions.refillRate) return { allowed, current: this.tokenBucketOptions.capacity - Math.floor(bucket.tokens), limit: this.tokenBucketOptions.capacity, remaining: msUntilNextToken, resetTime: now + msUntilNextToken, } } private createAllowedResult(): RateLimitResult { return { allowed: true, current: 0, limit: this.maxRequests, remaining: this.windowMs, resetTime: Date.now() + this.windowMs, } } async consume(key: string): Promise<RateLimitResult> { if (this.algorithm === 'sliding-window' && this.storage.getSlidingWindowCount) { return this.checkSlidingWindow(key) } else if (this.algorithm === 'token-bucket') { return this.checkTokenBucket(key) } else { return this.checkFixedWindow(key) } } async reset(key: string): Promise<void> { await this.storage.reset(key) if (this.algorithm === 'token-bucket') { this.tokenBuckets.delete(key) } } async resetAll(): Promise<void> { if (this.algorithm === 'token-bucket') { this.tokenBuckets.clear() } if (this.storage.cleanExpired) { this.storage.cleanExpired() } } private getHeaders(result: RateLimitResult): Record<string, string> { const headers: Record<string, string> = {} if (this.standardHeaders) { headers['RateLimit-Limit'] = this.maxRequests.toString() headers['RateLimit-Remaining'] = Math.max(0, this.maxRequests - result.current).toString() headers['RateLimit-Reset'] = Math.ceil(result.resetTime / 1000).toString() } if (this.legacyHeaders) { headers['X-RateLimit-Limit'] = this.maxRequests.toString() headers['X-RateLimit-Remaining'] = Math.max(0, this.maxRequests - result.current).toString() headers['X-RateLimit-Reset'] = Math.ceil(result.resetTime / 1000).toString() if (!result.allowed) { headers['Retry-After'] = Math.ceil(result.remaining / 1000).toString() } } return headers } middleware(): (req: Request) => Promise<Response | null> { return async (req: Request) => { const result = await this.check(req) if (!result.allowed) { if (this.handler) { return this.handler(req, result) } return new Response('Rate limit exceeded', { status: 429, headers: this.getHeaders(result), }) } return null } } dispose(): void { if (this.storage.dispose) { this.storage.dispose() } this.tokenBuckets.clear() } }