ts-rate-limiter
Version:
High-performance, flexible rate limiting for TypeScript and Bun
290 lines (243 loc) • 8.35 kB
TypeScript
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()
}
}