ts-rate-limiter
Version:
High-performance, flexible rate limiting for TypeScript and Bun
183 lines (142 loc) • 5.39 kB
TypeScript
import type { RedisStorageOptions, StorageProvider } from '../types';
export declare class RedisStorage implements StorageProvider {
private client: any
private keyPrefix: string
private slidingWindowEnabled: boolean
private luaScript: string
constructor(options: RedisStorageOptions) {
this.client = options.client
const redisConfig = config.redis || {}
this.keyPrefix = options.keyPrefix ?? config.redisKeyPrefix ?? 'ratelimit:'
this.slidingWindowEnabled = options.enableSlidingWindow ?? redisConfig.enableSlidingWindow ?? false
this.luaScript = `
local key = KEYS[1]
local window = tonumber(ARGV[1])
local now = tonumber(ARGV[2])
-- Increment counter
local count = redis.call('INCR', key)
-- Get TTL
local ttl = redis.call('TTL', key)
-- Set expiration if this is a new key or TTL is negative
if count == 1 or ttl < 0 then
redis.call('PEXPIRE', key, window)
ttl = window / 1000
end
return {count, ttl}
`
if (config.verbose) {
console.warn(`[ts-rate-limiter] Redis storage initialized with:
- Key Prefix: ${this.keyPrefix}
- Sliding Window: ${this.slidingWindowEnabled ? 'enabled' : 'disabled'}`)
}
}
async increment(key: string, windowMs: number): Promise<{ count: number, resetTime: number }> {
const fullKey = this.keyPrefix + key
const now = Date.now()
if (this.slidingWindowEnabled) {
return this.incrementSlidingWindow(fullKey, windowMs, now)
}
try {
const result = await this.client.eval(
this.luaScript,
{
keys: [fullKey],
arguments: [windowMs.toString(), now.toString()],
},
)
const count = Array.isArray(result) ? Number(result[0]) : Number(result)
const ttl = Array.isArray(result) ? Number(result[1]) : windowMs / 1000
return {
count,
resetTime: now + (ttl * 1000),
}
}
catch (error) {
return this.incrementStandard(fullKey, windowMs, now)
}
}
private async incrementStandard(fullKey: string, windowMs: number, now: number): Promise<{ count: number, resetTime: number }> {
const count = await this.client.incr(fullKey)
let ttl = await this.client.ttl(fullKey)
if (count === 1 || ttl < 0) {
await this.client.expire(fullKey, Math.ceil(windowMs / 1000))
ttl = windowMs / 1000
}
return {
count: Number(count),
resetTime: now + (ttl > 0 ? ttl * 1000 : windowMs),
}
}
private async incrementSlidingWindow(fullKey: string, windowMs: number, now: number): Promise<{ count: number, resetTime: number }> {
const windowKey = `${fullKey}:window`
const windowScore = now.toString()
const windowExpiry = (now - windowMs).toString()
await this.client.zAdd(windowKey, { score: windowScore, value: windowScore })
await this.client.zRemRangeByScore(windowKey, '0', windowExpiry)
const count = await this.client.zCard(windowKey)
await this.client.pExpire(windowKey, windowMs)
return {
count: Number(count),
resetTime: now + windowMs,
}
}
async reset(key: string): Promise<void> {
const fullKey = this.keyPrefix + key
if (this.slidingWindowEnabled) {
await this.client.del([fullKey, `${fullKey}:window`])
}
else {
await this.client.del(fullKey)
}
}
async getCount(key: string): Promise<number> {
const fullKey = this.keyPrefix + key
const count = await this.client.get(fullKey)
return count ? Number(count) : 0
}
async getSlidingWindowCount(key: string, windowMs: number): Promise<number> {
if (!this.slidingWindowEnabled) {
throw new Error('Sliding window not enabled for this Redis storage instance')
}
const windowKey = `${this.keyPrefix}${key}:window`
const now = Date.now()
const windowExpiry = (now - windowMs).toString()
await this.client.zRemRangeByScore(windowKey, '0', windowExpiry)
const count = await this.client.zCard(windowKey)
return Number(count)
}
async batchIncrement(keys: string[], windowMs: number): Promise<Map<string, { count: number, resetTime: number }>> {
const results = new Map<string, { count: number, resetTime: number }>()
const now = Date.now()
for (const key of keys) {
const fullKey = this.keyPrefix + key
if (this.slidingWindowEnabled) {
const windowKey = `${fullKey}:window`
const windowScore = now.toString()
const windowExpiry = (now - windowMs).toString()
await this.client.zAdd(windowKey, { score: windowScore, value: windowScore })
await this.client.zRemRangeByScore(windowKey, '0', windowExpiry)
const count = await this.client.zCard(windowKey)
await this.client.pExpire(windowKey, windowMs)
results.set(key, {
count: Number(count),
resetTime: now + windowMs,
})
}
else {
const count = await this.client.incr(fullKey)
const ttl = await this.client.ttl(fullKey)
if (count === 1 || ttl < 0) {
await this.client.expire(fullKey, Math.ceil(windowMs / 1000))
}
results.set(key, {
count: Number(count),
resetTime: now + (ttl > 0 ? ttl * 1000 : windowMs),
})
}
}
return results
}
async dispose(): Promise<void> {
}
}