UNPKG

@devhuset-oss/ratelimit

Version:

A flexible rate limiting library with support for fixed and sliding windows using Valkey

192 lines (176 loc) 5.94 kB
'use strict'; var Redis = require('iovalkey'); function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; } var Redis__default = /*#__PURE__*/_interopDefault(Redis); // src/client.ts var Valkey = class extends Redis__default.default { constructor(arg1, arg2, arg3) { if (typeof arg1 === "number" && typeof arg2 === "string" && arg3) { super(arg1, arg2, arg3); } else if (typeof arg1 === "string" && typeof arg2 === "object") { super(arg1, arg2); } else if (typeof arg1 === "number" && typeof arg2 === "object") { super(arg1, arg2); } else if (typeof arg1 === "number" && typeof arg2 === "string") { super(arg1, arg2); } else if (typeof arg1 === "object") { super(arg1); } else if (typeof arg1 === "number") { super(arg1); } else if (typeof arg1 === "string") { super(arg1); } else { super(); } this.defineCommand("slidingWindowRateLimit", { numberOfKeys: 2, lua: SLIDING_WINDOW_SCRIPT }); } }; var SLIDING_WINDOW_SCRIPT = ` local current_key = KEYS[1] local previous_key = KEYS[2] local tokens = tonumber(ARGV[1]) local now = tonumber(ARGV[2]) local window = tonumber(ARGV[3]) local increment_by = tonumber(ARGV[4]) local current_count = tonumber(redis.call("GET", current_key) or "0") local previous_count = tonumber(redis.call("GET", previous_key) or "0") local time_in_current = now % window local time_remaining_previous = window - time_in_current local weighted_previous = (previous_count * time_remaining_previous) / window local cumulative_count = math.floor(weighted_previous) + current_count + increment_by if cumulative_count > tokens then local needed = cumulative_count - tokens + increment_by local retry_after = window - time_in_current if previous_count > 0 then local time_needed = (needed * window) / previous_count retry_after = math.ceil(time_needed) if retry_after > time_remaining_previous then retry_after = time_remaining_previous end end return { -1, retry_after } end current_count = current_count + increment_by redis.call("SET", current_key, current_count) redis.call("PEXPIRE", current_key, window * 2 + 1000) return { tokens - (math.floor(weighted_previous) + current_count), 0 } `; // src/errors.ts var ConfigurationError = class extends Error { constructor(message) { super(message); this.name = "ConfigurationError"; } }; var ValkeyError = class extends Error { constructor(message, originalError) { super(message); this.originalError = originalError; this.name = "ValkeyError"; this.stack = new Error().stack; this.message = message; this.originalError = originalError; } }; // src/ratelimit.ts var Ratelimit = class { constructor(valkey, options, time_provider = Date.now) { this.options = options; this.time_provider = time_provider; this.valkey = valkey; this.validateOptions(options); } static fixedWindow(params) { return { type: "fixed", ...params }; } static slidingWindow(params) { return { type: "sliding", ...params }; } async limit(identifier) { try { return this.options.type === "fixed" ? await this.fixedWindowLimit(identifier) : await this.slidingWindowLimit(identifier); } catch (error) { throw new ValkeyError( "Failed to check rate limit", error instanceof Error ? error : new Error(String(error)) ); } } validateOptions(options) { if (options.limit <= 0) { throw new ConfigurationError("Limit must be greater than 0"); } if (options.window <= 0) { throw new ConfigurationError("Time window must be greater than 0"); } if (options.type !== "fixed" && options.type !== "sliding") { throw new ConfigurationError( 'Type must be either "fixed" or "sliding"' ); } } getKey(identifier, suffix) { const prefix = this.options.prefix || "ratelimit"; return `${prefix}:${identifier}:${suffix}`; } async fixedWindowLimit(identifier) { const now = this.time_provider(); const window_size = this.options.window; const current_window = Math.floor(now / (window_size * 1e3)); const window_key = this.getKey(identifier, current_window.toString()); const window_end = (current_window + 1) * (window_size * 1e3); const count = await this.valkey.incr(window_key); if (count === 1) { await this.valkey.expire(window_key, window_size); } if (count > this.options.limit) { const ttl = await this.valkey.ttl(window_key); return { success: false, limit: this.options.limit, remaining: 0, retry_after: Math.max(ttl * 1e3, 0), reset: window_end }; } return { success: true, limit: this.options.limit, remaining: this.options.limit - count, retry_after: 0, reset: window_end }; } async slidingWindowLimit(identifier) { const now = this.time_provider(); const window = this.options.window * 1e3; const current_window = Math.floor(now / window); const previous_window = current_window - 1; const current_key = this.getKey(identifier, current_window.toString()); const previous_key = this.getKey( identifier, previous_window.toString() ); const [remaining, retry_after] = await this.valkey.slidingWindowRateLimit( [current_key, previous_key], this.options.limit.toString(), now.toString(), window.toString(), "1" ); return { success: remaining >= 0, limit: this.options.limit, remaining: Math.max(0, remaining), retry_after, reset: this.time_provider() + this.options.window * 2e3 }; } }; exports.ConfigurationError = ConfigurationError; exports.Ratelimit = Ratelimit; exports.Valkey = Valkey; exports.ValkeyError = ValkeyError;