UNPKG

oss-ratelimit

Version:

Flexible rate limiting library with Redis for TypeScript applications

169 lines (144 loc) 12.3 kB
"use strict";var y=Object.defineProperty;var A=Object.getOwnPropertyDescriptor;var x=Object.getOwnPropertyNames;var C=Object.prototype.hasOwnProperty;var S=(r,e)=>{for(var t in e)y(r,t,{get:e[t],enumerable:!0})},v=(r,e,t,i)=>{if(e&&typeof e=="object"||typeof e=="function")for(let n of x(e))!C.call(r,n)&&n!==t&&y(r,n,{get:()=>e[n],enumerable:!(i=A(e,n))||i.enumerable});return r};var E=r=>v(y({},"__esModule",{value:!0}),r);var $={};S($,{Ratelimit:()=>p,RatelimitError:()=>a,createRateLimiter:()=>T,createSingletonRateLimiter:()=>I,fixedWindow:()=>W,getRedisSingleClient:()=>k,slidingWindow:()=>b,tokenBucket:()=>N});module.exports=E($);var R=require("redis");var P=r=>"refillRate"in r?"tokenBucket":"interval"in r?"slidingWindow":"fixedWindow",f=class{constructor(e=6e4){this.cache=new Map,this.ttl=e,setInterval(()=>this.cleanup(),Math.min(e,6e4))}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(),n=this.cache.get(e)||{count:0,expires:i+t};return n.expires<i?(n.count=1,n.expires=i+t):n.count++,this.cache.set(e,n),n.count}cleanup(){let e=Date.now();for(let[t,i]of this.cache.entries())i.expires<e&&this.cache.delete(t)}},p=class{constructor(e){this.redisPromise=Promise.resolve(e.redis),this.limiter=e.limiter,this.limiterType=P(e.limiter),this.prefix=e.prefix||"ratelimit",this.analytics=e.analytics??!1,this.timeout=e.timeout??1e3,e.ephemeralCache&&(this.ephemeralCache=new f(e.ephemeralCacheTTL))}async getRedis(){try{let e=new Promise((i,n)=>{setTimeout(()=>n(new a(`Redis connection timed out after ${this.timeout}ms`)),this.timeout)}),t=await Promise.race([this.redisPromise,e]);return(!t.isOpen||!await t.ping())&&await t.connect(),t}catch(e){throw new a(`Failed to connect to Redis: ${e instanceof Error?e.message:String(e)}`)}}async limit(e){let t=Date.now(),i=`${this.prefix}:${e}`;if(this.ephemeralCache&&this.limiterType==="slidingWindow"){let n=this.limiter.interval,l=this.limiter.limit;try{return await this.applySlidingWindowLimit(i,t)}catch(m){console.warn(`Redis error, using ephemeral cache: ${m instanceof Error?m.message:String(m)}`);let u=this.ephemeralCache.increment(i,n),o=u<=l;return{success:o,limit:l,remaining:Math.max(0,l-u),reset:t+n,retryAfter:o?0:Math.ceil(n/1e3)}}}try{switch(this.limiterType){case"slidingWindow":return await this.applySlidingWindowLimit(i,t);case"fixedWindow":return await this.applyFixedWindowLimit(i,t);case"tokenBucket":return await this.applyTokenBucketLimit(i,t);default:throw new a(`Unknown limiter type: ${this.limiterType}`)}}catch(n){console.error(`Rate limiting error: ${n instanceof Error?n.message:String(n)}`);let l="limit"in this.limiter?this.limiter.limit:10;return{success:!0,limit:l,remaining:l-1,reset:t+6e4}}}async applySlidingWindowLimit(e,t){let i=await this.getRedis(),{limit:n,interval:l}=this.limiter,m=t-l,u=` 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 -- Record request timestamp for analytics 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 } `;try{let o=`${e}:analytics`,s=await i.eval(u,{keys:[e,o],arguments:[t.toString(),l.toString(),n.toString(),this.analytics?"1":"0"]});if(!Array.isArray(s))throw new a("Invalid response from Redis");let c={success:!!s[0],limit:Number(s[1]),remaining:Number(s[2]),reset:Number(s[3])},d=Number(s[4]);return d>0&&(c.retryAfter=d),this.analytics&&(c.pending=Number(s[5]),c.throughput=Number(s[6])),this.ephemeralCache&&this.ephemeralCache.set(e,n-c.remaining,l),c}catch(o){throw new a(`Sliding window limit error: ${o instanceof Error?o.message:String(o)}`)}}async applyFixedWindowLimit(e,t){let i=await this.getRedis(),{limit:n,interval:l}=this.limiter,m=`${e}:${Math.floor(t/l)}`,u=` 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 * 2) end local success = count <= limit -- Analytics 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 } `;try{let o=`${e}:analytics`,s=await i.eval(u,{keys:[m,o],arguments:[n.toString(),l.toString(),this.analytics?"1":"0",t.toString()]});if(!Array.isArray(s))throw new a("Invalid response from Redis");let c={success:!!s[0],limit:Number(s[1]),remaining:Number(s[2]),reset:Number(s[3])},d=Number(s[4]);return d>0&&(c.retryAfter=d),this.analytics&&(c.pending=Number(s[5]),c.throughput=Number(s[6])),c}catch(o){throw new a(`Fixed window limit error: ${o instanceof Error?o.message:String(o)}`)}}async applyTokenBucketLimit(e,t){let i=await this.getRedis(),{limit:n,refillRate:l,interval:m}=this.limiter,u=` 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 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 } `;try{let o=`${e}:analytics`,s=await i.eval(u,{keys:[e,o],arguments:[t.toString(),l.toString(),m.toString(),n.toString(),this.analytics?"1":"0"]});if(!Array.isArray(s))throw new a("Invalid response from Redis");let c={success:!!s[0],limit:Number(s[1]),remaining:Number(s[2]),reset:Number(s[3])},d=Number(s[4]);return d>0&&(c.retryAfter=d),this.analytics&&(c.pending=Number(s[5]),c.throughput=Number(s[6])),c}catch(o){throw new a(`Token bucket limit error: ${o instanceof Error?o.message:String(o)}`)}}async block(e,t=5e3){let i=Date.now(),n=0,l=100;for(;n<l;){n++;let m=await this.limit(e);if(m.success)return m;let u=Date.now();if(u-i>=t)throw new a(`Rate limit exceeded for ${e} after waiting ${u-i}ms`);let o=Math.max(50,Math.min(500,m.retryAfter?m.retryAfter*1e3/2:100));await new Promise(s=>setTimeout(s,o))}throw new a(`Rate limit exceeded for ${e} after ${l} attempts`)}async reset(e){try{let t=await this.getRedis(),i=`${this.prefix}:${e}`;await t.del(i),await t.del(`${i}:analytics`),this.ephemeralCache&&this.ephemeralCache.set(i,0,0)}catch(t){throw new a(`Failed to reset rate limit: ${t instanceof Error?t.message:String(t)}`)}}};var w=r=>{try{let[e,t]=r.split(" "),i=Number.parseInt(e||"s",10);if(Number.isNaN(i)||i<=0)throw new a(`Invalid time value: ${e}`);switch(t){case"ms":return i;case"s":return i*1e3;case"m":return i*60*1e3;case"h":return i*60*60*1e3;case"d":return i*24*60*60*1e3;default:throw new a(`Invalid time unit: ${t}`)}}catch(e){throw e instanceof a?e:new a(`Failed to parse time window: ${r}`)}};var a=class extends Error{constructor(e){super(e),this.name="RatelimitError"}},W=(r,e)=>({limit:r,interval:w(e)}),b=(r,e)=>({limit:r,interval:w(e)}),N=(r,e,t)=>({refillRate:r,interval:w(e),limit:t}),h,k=r=>h||(h=(0,R.createClient)({url:process.env[r]||"redis://localhost:6379",socket:{reconnectStrategy:e=>Math.min(e*50,1e3)}}),h.on("error",e=>console.error("Redis Client Error",e)),(async()=>{try{(!h.isOpen||!await h.ping())&&await h.connect()}catch(e){console.error("Failed to connect to Redis:",e)}})(),h),I=r=>{let e=k(r?.envRedisKey??"REDIS_URL");return T(e,r)},T=(r,e)=>new p({redis:r,limiter:e?.limiter??b(10,"10 s"),prefix:e?.prefix??"open-ratelimit",analytics:e?.analytics??!1,timeout:e?.timeout??1e3,ephemeralCache:e?.ephemeralCache??!0,ephemeralCacheTTL:e?.ephemeralCacheTTL??6e4});0&&(module.exports={Ratelimit,RatelimitError,createRateLimiter,createSingletonRateLimiter,fixedWindow,getRedisSingleClient,slidingWindow,tokenBucket}); //# sourceMappingURL=index.js.map