const scripts = { increment: ` local totalHits = redis.call("INCR", KEYS[1]) local timeToExpire = redis.call("PTTL", KEYS[1]) if timeToExpire <= 0 or ARGV[1] == "1" then redis.call("PEXPIRE", KEYS[1], tonumber(ARGV[2])) timeToExpire = tonumber(ARGV[2]) end return { totalHits, timeToExpire } `.replaceAll(/^\s+/gm, "").trim(), get: ` local totalHits = redis.call("GET", KEYS[1]) local timeToExpire = redis.call("PTTL", KEYS[1]) return { totalHits, timeToExpire } `.replaceAll(/^\s+/gm, "").trim() }; const toInt = (input) => { if (typeof input === "number") return input; return Number.parseInt((input ?? "").toString(), 10); }; const parseScriptResponse = (results) => { if (!Array.isArray(results)) throw new TypeError("Expected result to be array of values"); if (results.length !== 2) throw new Error(`Expected 2 replies, got ${results.length}`); const totalHits = results[0] === false ? 0 : toInt(results[0]); const timeToExpire = toInt(results[1]); const resetTime = new Date(Date.now() + timeToExpire); return { totalHits, resetTime }; }; class RedisStore { /** * The function used to send raw commands to Redis. */ sendCommand; /** * The text to prepend to the key in Redis. */ prefix; /** * Whether to reset the expiry for a particular key whenever its hit count * changes. */ resetExpiryOnChange; /** * Stores the loaded SHA1s of the LUA scripts used for executing the increment * and get key operations. */ incrementScriptSha; getScriptSha; /** * The number of milliseconds to remember that user's requests. */ windowMs; /** * @constructor for `RedisStore`. * * @param options {Options} - The configuration options for the store. */ constructor(options) { this.sendCommand = options.sendCommand; this.prefix = options.prefix ?? "rl:"; this.resetExpiryOnChange = options.resetExpiryOnChange ?? false; this.incrementScriptSha = this.loadIncrementScript(); this.getScriptSha = this.loadGetScript(); } /** * Loads the script used to increment a client's hit count. */ async loadIncrementScript() { const result = await this.sendCommand("SCRIPT", "LOAD", scripts.increment); if (typeof result !== "string") { throw new TypeError("unexpected reply from redis client"); } return result; } /** * Loads the script used to fetch a client's hit count and expiry time. */ async loadGetScript() { const result = await this.sendCommand("SCRIPT", "LOAD", scripts.get); if (typeof result !== "string") { throw new TypeError("unexpected reply from redis client"); } return result; } /** * Runs the increment command, and retries it if the script is not loaded. */ async retryableIncrement(key) { const evalCommand = async () => this.sendCommand( "EVALSHA", await this.incrementScriptSha, "1", this.prefixKey(key), this.resetExpiryOnChange ? "1" : "0", this.windowMs.toString() ); try { const result = await evalCommand(); return result; } catch { this.incrementScriptSha = this.loadIncrementScript(); return evalCommand(); } } /** * Method to prefix the keys with the given text. * * @param key {string} - The key. * * @returns {string} - The text + the key. */ prefixKey(key) { return `${this.prefix}${key}`; } /** * Method that actually initializes the store. * * @param options {RateLimitConfiguration} - The options used to setup the middleware. */ init(options) { this.windowMs = options.windowMs; } /** * Method to fetch a client's hit count and reset time. * * @param key {string} - The identifier for a client. * * @returns {ClientRateLimitInfo | undefined} - The number of hits and reset time for that client. */ async get(key) { const results = await this.sendCommand( "EVALSHA", await this.getScriptSha, "1", this.prefixKey(key) ); return parseScriptResponse(results); } /** * Method to increment a client's hit counter. * * @param key {string} - The identifier for a client * * @returns {IncrementResponse} - The number of hits and reset time for that client */ async increment(key) { const results = await this.retryableIncrement(key); return parseScriptResponse(results); } /** * Method to decrement a client's hit counter. * * @param key {string} - The identifier for a client */ async decrement(key) { await this.sendCommand("DECR", this.prefixKey(key)); } /** * Method to reset a client's hit counter. * * @param key {string} - The identifier for a client */ async resetKey(key) { await this.sendCommand("DEL", this.prefixKey(key)); } } export { RedisStore, RedisStore as default };