index.cjs 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. 'use strict';
  2. Object.defineProperty(exports, '__esModule', { value: true });
  3. const scripts = {
  4. increment: `
  5. local totalHits = redis.call("INCR", KEYS[1])
  6. local timeToExpire = redis.call("PTTL", KEYS[1])
  7. if timeToExpire <= 0 or ARGV[1] == "1"
  8. then
  9. redis.call("PEXPIRE", KEYS[1], tonumber(ARGV[2]))
  10. timeToExpire = tonumber(ARGV[2])
  11. end
  12. return { totalHits, timeToExpire }
  13. `.replaceAll(/^\s+/gm, "").trim(),
  14. get: `
  15. local totalHits = redis.call("GET", KEYS[1])
  16. local timeToExpire = redis.call("PTTL", KEYS[1])
  17. return { totalHits, timeToExpire }
  18. `.replaceAll(/^\s+/gm, "").trim()
  19. };
  20. const toInt = (input) => {
  21. if (typeof input === "number")
  22. return input;
  23. return Number.parseInt((input ?? "").toString(), 10);
  24. };
  25. const parseScriptResponse = (results) => {
  26. if (!Array.isArray(results))
  27. throw new TypeError("Expected result to be array of values");
  28. if (results.length !== 2)
  29. throw new Error(`Expected 2 replies, got ${results.length}`);
  30. const totalHits = results[0] === false ? 0 : toInt(results[0]);
  31. const timeToExpire = toInt(results[1]);
  32. const resetTime = new Date(Date.now() + timeToExpire);
  33. return { totalHits, resetTime };
  34. };
  35. class RedisStore {
  36. /**
  37. * The function used to send raw commands to Redis.
  38. */
  39. sendCommand;
  40. /**
  41. * The text to prepend to the key in Redis.
  42. */
  43. prefix;
  44. /**
  45. * Whether to reset the expiry for a particular key whenever its hit count
  46. * changes.
  47. */
  48. resetExpiryOnChange;
  49. /**
  50. * Stores the loaded SHA1s of the LUA scripts used for executing the increment
  51. * and get key operations.
  52. */
  53. incrementScriptSha;
  54. getScriptSha;
  55. /**
  56. * The number of milliseconds to remember that user's requests.
  57. */
  58. windowMs;
  59. /**
  60. * @constructor for `RedisStore`.
  61. *
  62. * @param options {Options} - The configuration options for the store.
  63. */
  64. constructor(options) {
  65. this.sendCommand = options.sendCommand;
  66. this.prefix = options.prefix ?? "rl:";
  67. this.resetExpiryOnChange = options.resetExpiryOnChange ?? false;
  68. this.incrementScriptSha = this.loadIncrementScript();
  69. this.getScriptSha = this.loadGetScript();
  70. }
  71. /**
  72. * Loads the script used to increment a client's hit count.
  73. */
  74. async loadIncrementScript() {
  75. const result = await this.sendCommand("SCRIPT", "LOAD", scripts.increment);
  76. if (typeof result !== "string") {
  77. throw new TypeError("unexpected reply from redis client");
  78. }
  79. return result;
  80. }
  81. /**
  82. * Loads the script used to fetch a client's hit count and expiry time.
  83. */
  84. async loadGetScript() {
  85. const result = await this.sendCommand("SCRIPT", "LOAD", scripts.get);
  86. if (typeof result !== "string") {
  87. throw new TypeError("unexpected reply from redis client");
  88. }
  89. return result;
  90. }
  91. /**
  92. * Runs the increment command, and retries it if the script is not loaded.
  93. */
  94. async retryableIncrement(key) {
  95. const evalCommand = async () => this.sendCommand(
  96. "EVALSHA",
  97. await this.incrementScriptSha,
  98. "1",
  99. this.prefixKey(key),
  100. this.resetExpiryOnChange ? "1" : "0",
  101. this.windowMs.toString()
  102. );
  103. try {
  104. const result = await evalCommand();
  105. return result;
  106. } catch {
  107. this.incrementScriptSha = this.loadIncrementScript();
  108. return evalCommand();
  109. }
  110. }
  111. /**
  112. * Method to prefix the keys with the given text.
  113. *
  114. * @param key {string} - The key.
  115. *
  116. * @returns {string} - The text + the key.
  117. */
  118. prefixKey(key) {
  119. return `${this.prefix}${key}`;
  120. }
  121. /**
  122. * Method that actually initializes the store.
  123. *
  124. * @param options {RateLimitConfiguration} - The options used to setup the middleware.
  125. */
  126. init(options) {
  127. this.windowMs = options.windowMs;
  128. }
  129. /**
  130. * Method to fetch a client's hit count and reset time.
  131. *
  132. * @param key {string} - The identifier for a client.
  133. *
  134. * @returns {ClientRateLimitInfo | undefined} - The number of hits and reset time for that client.
  135. */
  136. async get(key) {
  137. const results = await this.sendCommand(
  138. "EVALSHA",
  139. await this.getScriptSha,
  140. "1",
  141. this.prefixKey(key)
  142. );
  143. return parseScriptResponse(results);
  144. }
  145. /**
  146. * Method to increment a client's hit counter.
  147. *
  148. * @param key {string} - The identifier for a client
  149. *
  150. * @returns {IncrementResponse} - The number of hits and reset time for that client
  151. */
  152. async increment(key) {
  153. const results = await this.retryableIncrement(key);
  154. return parseScriptResponse(results);
  155. }
  156. /**
  157. * Method to decrement a client's hit counter.
  158. *
  159. * @param key {string} - The identifier for a client
  160. */
  161. async decrement(key) {
  162. await this.sendCommand("DECR", this.prefixKey(key));
  163. }
  164. /**
  165. * Method to reset a client's hit counter.
  166. *
  167. * @param key {string} - The identifier for a client
  168. */
  169. async resetKey(key) {
  170. await this.sendCommand("DEL", this.prefixKey(key));
  171. }
  172. }
  173. exports.RedisStore = RedisStore;
  174. exports["default"] = RedisStore;