oss-ratelimit
Version:
Flexible rate limiting library with Redis for TypeScript applications
168 lines (143 loc) • 19.1 kB
JavaScript
"use strict";var C=Object.defineProperty;var W=Object.getOwnPropertyDescriptor;var B=Object.getOwnPropertyNames;var D=Object.prototype.hasOwnProperty;var q=(o,e)=>{for(var t in e)C(o,t,{get:e[t],enumerable:!0})},G=(o,e,t,i)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of B(e))!D.call(o,s)&&s!==t&&C(o,s,{get:()=>e[s],enumerable:!(i=W(e,s))||i.enumerable});return o};var _=o=>G(C({},"__esModule",{value:!0}),o);var Z={};q(Z,{RateLimitExceededError:()=>y,Ratelimit:()=>g,RatelimitError:()=>c,RedisConnectionError:()=>h,closeRedisClient:()=>K,createLimiterAccessor:()=>N,createRateLimiter:()=>M,default:()=>U,fixedWindow:()=>I,getInitializedLimiter:()=>x,getRedisClient:()=>f,initRateLimit:()=>P,initializeLimiters:()=>E,parseTimeWindow:()=>w,slidingWindow:()=>b,tokenBucket:()=>$});module.exports=_(Z);var S=require("events"),L=require("redis");var A=require("events");var v=class{constructor(e={}){this.limiters={};this.redisClients={};this.registryEmitter=new A.EventEmitter;this.defaultRedisOpts=e}getRedisClientKey(e){let t=e?.redis||{},i=e?.envRedisKey,s={...this.defaultRedisOpts,...t},r=i,a=r?process.env[r]:void 0;if(a?.trim())return`env:${a.trim()}`;if(s.url?.trim())return`opts_url:${s.url.trim()}`;try{let l={host:s.host,port:s.port,database:s.database,username:s.username};return`opts_obj:${JSON.stringify(l)}`}catch{return`opts_fallback:${Date.now()}_${Math.random()}`}}getManagedRedisClient(e){let t={...this.defaultRedisOpts,...e?.redis||{}},i=this.getRedisClientKey({redis:t,envRedisKey:e?.envRedisKey});if(!this.redisClients[i]){console.log(`[_InternalRateLimiterRegistry] Creating new Redis client promise for key: ${i}`);let s={},r=e?.envRedisKey?process.env[e.envRedisKey]:void 0;r?.trim()?s={url:r.trim()}:s=t,this.redisClients[i]=f(s).then(a=>a).catch(a=>{throw a})}return this.redisClients[i]}register(e,t){if(typeof e!="string"||!e.trim())return Promise.reject(new Error("[_InternalRateLimiterRegistry] Registration name must be a non-empty string."));if(this.limiters[e])return Promise.resolve(this.limiters[e]);console.log(`[_InternalRateLimiterRegistry] Initializing limiter: "${String(e)}"`);let s={limiter:t?.limiter||b(10,"10 s"),...t},r=(async()=>{try{let l={redis:await this.getManagedRedisClient({redis:s.redis,envRedisKey:s.envRedisKey}),limiter:s.limiter,prefix:s.prefix,analytics:s.analytics,timeout:s.timeout,ephemeralCache:s.ephemeralCache,ephemeralCacheTTL:s.ephemeralCacheTTL,failOpen:s.failOpen,silent:s.silent},n=new g(l),m=this.getRedisClientKey(s);return console.log(`[_InternalRateLimiterRegistry] Registered limiter "${String(e)}" using Redis client key: ${m}`),this.registryEmitter.emit("limiterRegister",{name:e,clientKey:m}),this.limiters[e]=n,n}catch(a){throw console.error(`[_InternalRateLimiterRegistry] Failed to initialize limiter "${String(e)}":`,a),delete this.limiters[e],this.registryEmitter.emit("limiterError",{name:e,error:a}),a}})();return this.limiters[e]=r,r}get(e){let t=this.limiters[e];if(!t)throw new Error(`[_InternalRateLimiterRegistry] Limiter "${String(e)}" not found.`);if(t instanceof Promise)throw new Error(`[_InternalRateLimiterRegistry] Limiter "${String(e)}" still initializing.`);return t}isInitialized(e){let t=this.limiters[e];return!!t&&!(t instanceof Promise)}async close(){let e=Object.keys(this.redisClients);console.log(`[_InternalRateLimiterRegistry] Closing ${e.length} managed Redis client(s)...`);let t=[];for(let i of e){let r=this.redisClients[i].then(a=>a&&typeof a.quit=="function"?a.quit().then(()=>{}):Promise.resolve()).catch(a=>(console.error(`Error quitting client ${i}:`,a),Promise.resolve()));t.push(r)}await Promise.allSettled(t),this.redisClients={},this.limiters={},this.registryEmitter.emit("close"),console.log("[_InternalRateLimiterRegistry] Registry cleared.")}getClientPromiseForKey(e){return this.redisClients[e]}on(e,t){return this.registryEmitter.on(e,t),this}off(e,t){return this.registryEmitter.off(e,t),this}};function P(o){let e=new v(o?.defaultRedisOptions),t={register:e.register.bind(e),get:e.get.bind(e),isInitialized:e.isInitialized.bind(e),close:e.close.bind(e),on:(i,s)=>(e.on(i,s),t),off:(i,s)=>(e.off(i,s),t),getClient:i=>{let s=e.getRedisClientKey(i);return e.getClientPromiseForKey(s)}};return t}async function E(o){let{registry:e,configs:t,onRegister:i,onComplete:s,throwOnError:r=!0,verbose:a=!0}=o;a&&console.log("Initializing rate limiters...");let l=[],n={};for(let d in t)if(Object.prototype.hasOwnProperty.call(t,d)){let u=d;a&&console.log(`- Registering: ${String(u)}`);let T=e.register(u,t[u]).then(R=>(i&&i(u),{name:u,limiter:R})).catch(R=>{if(console.error(`Failed to initialize limiter "${String(u)}":`,R),r)throw R;return{name:u,error:R}});l.push(T)}let m=await Promise.allSettled(l);for(let d of m)d.status==="fulfilled"&&!("error"in d.value)&&(n[d.value.name]=d.value.limiter);return a&&console.log("\u2705 All limiters initialized."),s&&s(),n}function x(o,e){try{return e.get(o)}catch{throw new Error(`Limiter "${String(o)}" not initialized. Did you call initializeLimiters?`)}}function N(o){return e=>x(e,o)}var c=class extends Error{constructor(e){super(e),this.name="RatelimitError",Error.captureStackTrace(this,this.constructor)}},h=class extends c{constructor(e){super(`Redis connection error: ${e}`),this.name="RedisConnectionError"}},y=class extends c{constructor(e,t){super(`Rate limit exceeded for "${e}". Retry after ${t} seconds.`),this.name="RateLimitExceededError",this.retryAfter=t,this.identifier=e}},z={ms:1,s:1e3,m:60*1e3,h:60*60*1e3,d:24*60*60*1e3},w=o=>{try{let[e,t]=o.trim().split(/\s+/),i=parseInt(e,10);if(Number.isNaN(i)||i<=0)throw new c(`Invalid time value: ${e}`);let s=z[t];if(!s)throw new c(`Invalid time unit: ${t}. Must be one of: ms, s, m, h, d`);return i*s}catch(e){throw e instanceof c?e:new c(`Failed to parse time window: ${o}`)}},I=(o,e)=>({type:"fixedWindow",limit:o,windowMs:w(e)}),b=(o,e)=>({type:"slidingWindow",limit:o,windowMs:w(e)}),$=(o,e,t)=>({type:"tokenBucket",refillRate:o,interval:w(e),limit:t}),O=class{constructor(e=6e4){this.cache=new Map,this.ttl=e,this.cleanupInterval=setInterval(()=>this.cleanup(),Math.min(e/2,3e4))}get(e){let t=Date.now(),i=this.cache.get(e);return!i||i.expires<t?0:i.count}set(e,t,i){this.cache.set(e,{count:t,expires:Date.now()+Math.min(i,this.ttl)})}increment(e,t){let i=Date.now(),s=this.cache.get(e)||{count:0,expires:i+t};return s.expires<i?(s.count=1,s.expires=i+t):s.count++,this.cache.set(e,s),s.count}cleanup(){let e=Date.now();for(let[t,i]of this.cache.entries())i.expires<e&&this.cache.delete(t)}destroy(){clearInterval(this.cleanupInterval),this.cache.clear()}},F={url:process.env.REDIS_URL||"redis://localhost:6379",connectTimeout:5e3,reconnectStrategy:o=>Math.min(o*50,3e3)},p,f=async(o={})=>{if(p?.isOpen)return p;let e={...F,...o};try{return p=(0,L.createClient)({url:e.url,socket:{host:e.host,port:e.port,tls:e.tls,connectTimeout:e.connectTimeout,reconnectStrategy:e.reconnectStrategy},username:e.username,password:e.password,database:e.database}),p.on("error",t=>{console.error("[open-ratelimit] Redis client error:",t)}),await p.connect(),p}catch(t){throw new h(t instanceof Error?t.message:String(t))}},K=async()=>{p?.isOpen&&(await p.quit(),p=void 0)},V={prefix:"open-ratelimit",analytics:!1,timeout:1e3,ephemeralCache:!0,ephemeralCacheTTL:6e4,failOpen:!1,silent:!1},g=class extends S.EventEmitter{constructor(t){super();this.scripts=new Map;let i={...V,...t};"isOpen"in t.redis?this.redis=t.redis:this.redis=f(t.redis),this.limiter=i.limiter,this.prefix=i.prefix,this.analytics=!!i.analytics,this.timeout=i.timeout,this.failOpen=!!i.failOpen,this.silent=!!i.silent,i.ephemeralCache&&(this.ephemeralCache=new O(i.ephemeralCacheTTL)),this.initScripts()}initScripts(){this.scripts.set("slidingWindow",`
local key = KEYS[1]
local analyticsKey = KEYS[2]
local now = tonumber(ARGV[1])
local windowMs = tonumber(ARGV[2])
local maxRequests = tonumber(ARGV[3])
local doAnalytics = tonumber(ARGV[4])
local windowStart = now - windowMs
-- Remove counts older than the current window
redis.call('ZREMRANGEBYSCORE', key, 0, windowStart)
-- Get current count
local count = redis.call('ZCARD', key)
local success = count < maxRequests
-- Add current timestamp if successful
if success then
redis.call('ZADD', key, now, now .. ':' .. math.random())
count = count + 1
end
-- Set expiration to keep memory usage bounded
redis.call('PEXPIRE', key, windowMs * 2)
-- Analytics if requested
if doAnalytics == 1 then
redis.call('ZADD', analyticsKey, now, now)
redis.call('PEXPIRE', analyticsKey, windowMs * 2)
end
-- Calculate when the oldest request expires
local oldestTimestamp = 0
if count >= maxRequests then
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
if #oldest >= 2 then
oldestTimestamp = tonumber(oldest[2])
end
end
-- Calculate pending and throughput if analytics enabled
local pending = 0
local throughput = 0
if doAnalytics == 1 then
pending = count
-- Calculate requests in the last second
local secondAgo = now - 1000
throughput = redis.call('ZCOUNT', analyticsKey, secondAgo, '+inf')
end
-- Return results
return {
success and 1 or 0,
maxRequests,
math.max(0, maxRequests - count),
now + windowMs,
oldestTimestamp > 0 and math.ceil((oldestTimestamp + windowMs - now) / 1000) or 0,
pending,
throughput
}
`),this.scripts.set("fixedWindow",`
local key = KEYS[1]
local analyticsKey = KEYS[2]
local limit = tonumber(ARGV[1])
local windowMs = tonumber(ARGV[2])
local doAnalytics = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
-- Increment counter for this window
local count = redis.call('INCR', key)
-- Set expiration if this is first request in window
if count == 1 then
redis.call('PEXPIRE', key, windowMs)
end
local success = count <= limit
-- Analytics if requested
if doAnalytics == 1 then
redis.call('ZADD', analyticsKey, now, now)
redis.call('PEXPIRE', analyticsKey, windowMs)
end
-- Calculate remaining time in window
local ttl = redis.call('PTTL', key)
if ttl < 0 then ttl = windowMs end
-- Calculate throughput if analytics enabled
local throughput = 0
if doAnalytics == 1 then
-- Calculate requests in the last second
local secondAgo = now - 1000
throughput = redis.call('ZCOUNT', analyticsKey, secondAgo, '+inf')
end
return {
success and 1 or 0,
limit,
math.max(0, limit - count),
now + ttl,
success and 0 or math.ceil(ttl / 1000),
count,
throughput
}
`),this.scripts.set("tokenBucket",`
local key = KEYS[1]
local analyticsKey = KEYS[2]
local now = tonumber(ARGV[1])
local refillRate = tonumber(ARGV[2])
local refillInterval = tonumber(ARGV[3])
local bucketCapacity = tonumber(ARGV[4])
local doAnalytics = tonumber(ARGV[5])
-- Get current bucket state
local bucketInfo = redis.call('HMGET', key, 'tokens', 'lastRefill')
local tokens = tonumber(bucketInfo[1]) or bucketCapacity
local lastRefill = tonumber(bucketInfo[2]) or 0
-- Calculate token refill
local elapsedTime = now - lastRefill
local tokensToAdd = math.floor(elapsedTime * (refillRate / refillInterval))
if tokensToAdd > 0 then
-- Add tokens based on elapsed time
tokens = math.min(bucketCapacity, tokens + tokensToAdd)
lastRefill = now
end
-- Try to consume a token
local success = tokens >= 1
if success then
tokens = tokens - 1
end
-- Save updated bucket state
redis.call('HMSET', key, 'tokens', tokens, 'lastRefill', lastRefill)
redis.call('PEXPIRE', key, refillInterval * 2)
-- Analytics if requested
if doAnalytics == 1 then
redis.call('ZADD', analyticsKey, now, now)
redis.call('PEXPIRE', analyticsKey, refillInterval)
end
-- Calculate time until next token refill
local timeToNextToken = success and 0 or math.ceil((1 - tokens) * (refillInterval / refillRate))
-- Calculate throughput if analytics enabled
local throughput = 0
if doAnalytics == 1 then
-- Calculate requests in the last second
local secondAgo = now - 1000
throughput = redis.call('ZCOUNT', analyticsKey, secondAgo, '+inf')
end
return {
success and 1 or 0,
bucketCapacity,
tokens,
now + (refillInterval / refillRate),
timeToNextToken,
bucketCapacity - tokens,
throughput
}
`)}async getRedis(){try{let t=new Promise((s,r)=>{setTimeout(()=>r(new h(`Connection timed out after ${this.timeout}ms`)),this.timeout)}),i=await Promise.race([this.redis,t]);return i.isOpen||await i.connect(),await i.ping(),i}catch(t){if(this.emit("error",new h(t instanceof Error?t.message:String(t))),!this.failOpen)throw new h(t instanceof Error?t.message:String(t));return Promise.resolve(this.redis)}}async limit(t){let i=Date.now(),s=`${this.prefix}:${t}`;try{return await this.applyLimit(s,i)}catch(r){if(this.emit("error",r),this.ephemeralCache&&this.limiter.type==="slidingWindow")return this.silent||console.warn(`[open-ratelimit] Redis error, using ephemeral cache: ${r instanceof Error?r.message:String(r)}`),this.applyEphemeralLimit(s,t,i);if(!this.failOpen)throw r;this.emit("failOpen",{identifier:t,error:r}),this.silent||console.warn(`[open-ratelimit] Redis error, failing open for ${t}: ${r instanceof Error?r.message:String(r)}`);let a="limit"in this.limiter?this.limiter.limit:10;return{success:!0,limit:a,remaining:a-1,reset:i+6e4}}}async applyLimit(t,i){let s=await this.getRedis();switch(this.limiter.type){case"slidingWindow":return this.applySlidingWindowLimit(s,t,i);case"fixedWindow":return this.applyFixedWindowLimit(s,t,i);case"tokenBucket":return this.applyTokenBucketLimit(s,t,i);default:throw new c(`Unknown limiter type: ${this.limiter.type}`)}}async applySlidingWindowLimit(t,i,s){try{let r=this.limiter,a=`${i}:analytics`,l=await t.eval(this.scripts.get("slidingWindow"),{keys:[i,a],arguments:[s.toString(),r.windowMs.toString(),r.limit.toString(),this.analytics?"1":"0"]});if(!Array.isArray(l))throw new c("Invalid response from Redis");let n={success:!!l[0],limit:Number(l[1]),remaining:Number(l[2]),reset:Number(l[3])},m=Number(l[4]);return m>0&&(n.retryAfter=m),this.analytics&&(n.pending=Number(l[5]),n.throughput=Number(l[6])),this.ephemeralCache&&this.ephemeralCache.set(i,r.limit-n.remaining,r.windowMs),this.emit(n.success?"allowed":"limited",{identifier:i.substring(this.prefix.length+1),remaining:n.remaining,limit:n.limit}),n}catch(r){throw new c(`Sliding window limit error: ${r instanceof Error?r.message:String(r)}`)}}async applyFixedWindowLimit(t,i,s){try{let r=this.limiter,a=`${i}:${Math.floor(s/r.windowMs)}`,l=`${i}:analytics`,n=await t.eval(this.scripts.get("fixedWindow"),{keys:[a,l],arguments:[r.limit.toString(),r.windowMs.toString(),this.analytics?"1":"0",s.toString()]});if(!Array.isArray(n))throw new c("Invalid response from Redis");let m={success:!!n[0],limit:Number(n[1]),remaining:Number(n[2]),reset:Number(n[3])},d=Number(n[4]);return d>0&&(m.retryAfter=d),this.analytics&&(m.pending=Number(n[5]),m.throughput=Number(n[6])),this.emit(m.success?"allowed":"limited",{identifier:i.substring(this.prefix.length+1),remaining:m.remaining,limit:m.limit}),m}catch(r){throw new c(`Fixed window limit error: ${r instanceof Error?r.message:String(r)}`)}}async applyTokenBucketLimit(t,i,s){try{let r=this.limiter,a=`${i}:analytics`,l=await t.eval(this.scripts.get("tokenBucket"),{keys:[i,a],arguments:[s.toString(),r.refillRate.toString(),r.interval.toString(),r.limit.toString(),this.analytics?"1":"0"]});if(!Array.isArray(l))throw new c("Invalid response from Redis");let n={success:!!l[0],limit:Number(l[1]),remaining:Number(l[2]),reset:Number(l[3])},m=Number(l[4]);return m>0&&(n.retryAfter=m),this.analytics&&(n.pending=Number(l[5]),n.throughput=Number(l[6])),this.emit(n.success?"allowed":"limited",{identifier:i.substring(this.prefix.length+1),remaining:n.remaining,limit:n.limit}),n}catch(r){throw new c(`Token bucket limit error: ${r instanceof Error?r.message:String(r)}`)}}applyEphemeralLimit(t,i,s){if(!this.ephemeralCache)throw new c("Ephemeral cache not available");if(this.limiter.type!=="slidingWindow")throw new c(`Ephemeral cache only supports sliding window, got: ${this.limiter.type}`);let r=this.limiter,a=this.ephemeralCache.increment(t,r.windowMs),l=a<=r.limit,n={success:l,limit:r.limit,remaining:Math.max(0,r.limit-a),reset:s+r.windowMs};return l||(n.retryAfter=Math.ceil(r.windowMs/1e3)),this.emit(l?"allowed":"limited",{identifier:i,remaining:n.remaining,limit:n.limit,fromCache:!0}),n}async block(t,i){let{maxWaitMs:s=5e3,maxAttempts:r=50,retryDelayMs:a=100}=i||{},l=Date.now(),n=0;for(;n<r;){n++;let m=await this.limit(t);if(m.success)return m;let d=Date.now();if(d-l>=s)throw new y(t,m.retryAfter||1);let u=Math.max(50,Math.min(1e3,m.retryAfter?m.retryAfter*1e3/4:a));this.emit("waiting",{identifier:t,attempt:n,waitTime:u,elapsed:d-l}),await new Promise(T=>setTimeout(T,u))}throw new y(t,1)}async reset(t){try{let i=await this.getRedis(),s=`${this.prefix}:${t}`,r=[s,`${s}:analytics`];if(this.limiter.type==="fixedWindow"){let a=`${s}:*`,l=await i.scan(0,{MATCH:a,COUNT:100});l.keys.length>0&&r.push(...l.keys)}return r.length>0&&await i.del(r),this.ephemeralCache&&this.ephemeralCache.set(s,0,0),this.emit("reset",{identifier:t}),!0}catch(i){if(this.emit("error",new c(`Failed to reset rate limit: ${i instanceof Error?i.message:String(i)}`)),!this.failOpen)throw new c(`Failed to reset rate limit: ${i instanceof Error?i.message:String(i)}`);return!1}}async getStats(t){let i=await this.limit(t);return{used:i.limit-i.remaining,remaining:i.remaining,limit:i.limit,reset:i.reset}}async check(t){return(await this.getStats(t)).remaining>0}async close(){this.ephemeralCache&&this.ephemeralCache.destroy(),typeof this.redis=="object"&&"quit"in this.redis,this.removeAllListeners()}},M=async o=>{let e=await f(o.redis||{});return new g({redis:e,...o})},U={Ratelimit:g,createRateLimiter:M,fixedWindow:I,slidingWindow:b,tokenBucket:$,parseTimeWindow:w,getRedisClient:f,closeRedisClient:K,RatelimitError:c,RedisConnectionError:h,RateLimitExceededError:y};0&&(module.exports={RateLimitExceededError,Ratelimit,RatelimitError,RedisConnectionError,closeRedisClient,createLimiterAccessor,createRateLimiter,fixedWindow,getInitializedLimiter,getRedisClient,initRateLimit,initializeLimiters,parseTimeWindow,slidingWindow,tokenBucket});
//# sourceMappingURL=index.js.map