diff --git a/package-lock.json b/package-lock.json index 1f230ff..b7c7651 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3783,10 +3783,24 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001297", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001297.tgz", - "integrity": "sha512-6bbIbowYG8vFs/Lk4hU9jFt7NknGDleVAciK916tp6ft1j+D//ZwwL6LbF1wXMQ32DMSjeuUV8suhh6dlmFjcA==", - "dev": true + "version": "1.0.30001755", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001755.tgz", + "integrity": "sha512-44V+Jm6ctPj7R52Na4TLi3Zri4dWUljJd+RDm+j8LtNCc/ihLCT+X1TzoOAkRETEWqjuLnh9581Tl80FvK7jVA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] }, "node_modules/chalk": { "version": "4.1.2", @@ -15232,9 +15246,9 @@ } }, "caniuse-lite": { - "version": "1.0.30001297", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001297.tgz", - "integrity": "sha512-6bbIbowYG8vFs/Lk4hU9jFt7NknGDleVAciK916tp6ft1j+D//ZwwL6LbF1wXMQ32DMSjeuUV8suhh6dlmFjcA==", + "version": "1.0.30001755", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001755.tgz", + "integrity": "sha512-44V+Jm6ctPj7R52Na4TLi3Zri4dWUljJd+RDm+j8LtNCc/ihLCT+X1TzoOAkRETEWqjuLnh9581Tl80FvK7jVA==", "dev": true }, "chalk": { diff --git a/readme.md b/readme.md index 2fb4d7a..d677840 100644 --- a/readme.md +++ b/readme.md @@ -140,7 +140,7 @@ below: | Library | Function | | ------------------------------------------------------------------ | ----------------------------------------------------------------------------- | | [`node-redis`](https://github.com/redis/node-redis) | `async (...args: string[]) => client.sendCommand(args)` | -| [`node-redis`](https://github.com/redis/node-redis) (cluster) | `async (...args: string[]) => cluster.sendCommand(args[1], false, args)` | +| [`node-redis`](https://github.com/redis/node-redis) (cluster) | See `sendCommandCluster` below | | [`ioredis`](https://github.com/luin/ioredis) | `async (command: string, ...args: string[]) => client.call(command, ...args)` | | [`handy-redis`](https://github.com/mmkal/handy-redis) | `async (...args: string[]) => client.nodeRedis.sendCommand(args)` | | [`tedis`](https://github.com/silkjs/tedis) | `async (...args: string[]) => client.command(...args)` | @@ -148,6 +148,45 @@ below: | [`yoredis`](https://github.com/djanowski/yoredis) | `async (...args: string[]) => (await client.callMany([args]))[0]` | | [`noderis`](https://github.com/wallneradam/noderis) | `async (...args: string[]) => client.callRedis(...args)` | +##### `sendCommandCluster` + +In cluster mode, node-redis requires a little extra information to help route +the command to to correct server. This is an alternative to `sendCommand` that +provides the necessary extra information. The signature is as follows: + +```ts +({key: string, isReadOnly: boolean, command: string[]}) => Promise | number +``` + +Example usage: + +```ts +import { rateLimit } from 'express-rate-limit' +import { RedisStore, type RedisReply, type } from 'rate-limit-redis' +import { createCluster } from 'redis' + +// Create a `node-redis` cluster client +const cluster = new createCluster({ + // see https://github.com/redis/node-redis/blob/master/docs/clustering.md +}) + +// Create and use the rate limiter +const limiter = rateLimit({ + // Rate limiter configuration here + + // Redis store configuration + store: new RedisStore({ + sendCommandCluster: ({ + key, + isReadOnly, + command, + }: SendCommandClusterDetails) => + cluster.sendCommand(key, isReadOnly, command) as Promise, + }), +}) +app.use(limiter) +``` + #### `prefix` The text to prepend to the key in Redict/Redis. diff --git a/source/lib.ts b/source/lib.ts index e7184c8..167ee01 100644 --- a/source/lib.ts +++ b/source/lib.ts @@ -8,7 +8,12 @@ import type { Options as RateLimitConfiguration, } from 'express-rate-limit' import scripts from './scripts.js' -import type { Options, SendCommandFn, RedisReply } from './types.js' +import type { + Options, + SendCommandClusterFn, + RedisReply, + SendCommandClusterDetails, +} from './types.js' /** * Converts a string/number to a number. @@ -49,8 +54,10 @@ const parseScriptResponse = (results: RedisReply): ClientRateLimitInfo => { export class RedisStore implements Store { /** * The function used to send raw commands to Redis. + * + * When a non-cluster SendCommandFn is provided, a wrapper function is used to convert between the two */ - sendCommand: SendCommandFn + sendCommand: SendCommandClusterFn /** * The text to prepend to the key in Redis. @@ -81,7 +88,22 @@ export class RedisStore implements Store { * @param options {Options} - The configuration options for the store. */ constructor(options: Options) { - this.sendCommand = options.sendCommand + if (typeof options !== 'object') { + throw new TypeError('rate-limit-redis: Error: options object is required') + } + + if ('sendCommand' in options && !('sendCommandCluster' in options)) { + // Normal case: wrap the sendCommand function to convert from cluster to regular + this.sendCommand = async ({ command }: SendCommandClusterDetails) => + options.sendCommand(...command) + } else if (!('sendCommand' in options) && 'sendCommandCluster' in options) { + this.sendCommand = options.sendCommandCluster + } else { + throw new Error( + 'rate-limit-redis: Error: options must include either sendCommand or sendCommandCluster (but not both)', + ) + } + this.prefix = options.prefix ?? 'rl:' this.resetExpiryOnChange = options.resetExpiryOnChange ?? false @@ -97,8 +119,12 @@ export class RedisStore implements Store { /** * Loads the script used to increment a client's hit count. */ - async loadIncrementScript(): Promise { - const result = await this.sendCommand('SCRIPT', 'LOAD', scripts.increment) + async loadIncrementScript(key?: string): Promise { + const result = await this.sendCommand({ + key, + isReadOnly: false, + command: ['SCRIPT', 'LOAD', scripts.increment], + }) if (typeof result !== 'string') { throw new TypeError('unexpected reply from redis client') @@ -110,8 +136,12 @@ export class RedisStore implements Store { /** * Loads the script used to fetch a client's hit count and expiry time. */ - async loadGetScript(): Promise { - const result = await this.sendCommand('SCRIPT', 'LOAD', scripts.get) + async loadGetScript(key?: string): Promise { + const result = await this.sendCommand({ + key, + isReadOnly: false, + command: ['SCRIPT', 'LOAD', scripts.get], + }) if (typeof result !== 'string') { throw new TypeError('unexpected reply from redis client') @@ -123,23 +153,28 @@ export class RedisStore implements Store { /** * Runs the increment command, and retries it if the script is not loaded. */ - async retryableIncrement(key: string): Promise { + async retryableIncrement(_key: string): Promise { + const key = this.prefixKey(_key) const evalCommand = async () => - this.sendCommand( - 'EVALSHA', - await this.incrementScriptSha, - '1', - this.prefixKey(key), - this.resetExpiryOnChange ? '1' : '0', - this.windowMs.toString(), - ) + this.sendCommand({ + key, + isReadOnly: false, + command: [ + 'EVALSHA', + await this.incrementScriptSha, + '1', + key, + this.resetExpiryOnChange ? '1' : '0', + this.windowMs.toString(), + ], + }) try { const result = await evalCommand() return result } catch { // TODO: distinguish different error types - this.incrementScriptSha = this.loadIncrementScript() + this.incrementScriptSha = this.loadIncrementScript(key) return evalCommand() } } @@ -171,13 +206,22 @@ export class RedisStore implements Store { * * @returns {ClientRateLimitInfo | undefined} - The number of hits and reset time for that client. */ - async get(key: string): Promise { - const results = await this.sendCommand( - 'EVALSHA', - await this.getScriptSha, - '1', - this.prefixKey(key), - ) + async get(_key: string): Promise { + const key = this.prefixKey(_key) + let results + const evalCommand = async () => + this.sendCommand({ + key, + isReadOnly: true, + command: ['EVALSHA', await this.getScriptSha, '1', key], + }) + try { + results = await evalCommand() + } catch { + // TODO: distinguish different error types + this.getScriptSha = this.loadGetScript(key) + results = await evalCommand() + } return parseScriptResponse(results) } @@ -199,8 +243,9 @@ export class RedisStore implements Store { * * @param key {string} - The identifier for a client */ - async decrement(key: string): Promise { - await this.sendCommand('DECR', this.prefixKey(key)) + async decrement(_key: string): Promise { + const key = this.prefixKey(_key) + await this.sendCommand({ key, isReadOnly: false, command: ['DECR', key] }) } /** @@ -208,8 +253,9 @@ export class RedisStore implements Store { * * @param key {string} - The identifier for a client */ - async resetKey(key: string): Promise { - await this.sendCommand('DEL', this.prefixKey(key)) + async resetKey(_key: string): Promise { + const key = this.prefixKey(_key) + await this.sendCommand({ key, isReadOnly: false, command: ['DEL', key] }) } } diff --git a/source/types.ts b/source/types.ts index 84926ab..0c0f144 100644 --- a/source/types.ts +++ b/source/types.ts @@ -13,15 +13,20 @@ export type RedisReply = Data | Data[] */ export type SendCommandFn = (...args: string[]) => Promise +export type SendCommandClusterDetails = { + key?: string + isReadOnly: boolean + command: string[] +} + /** - * The configuration options for the store. + * This alternative to SendCommandFn includes a little bit of extra data that node-redis requires, to help route the command to the correct server. */ -export type Options = { - /** - * The function used to send commands to Redis. - */ - readonly sendCommand: SendCommandFn +export type SendCommandClusterFn = ( + commandDetails: SendCommandClusterDetails, +) => Promise +type CommonOptions = { /** * The text to prepend to the key in Redis. */ @@ -33,3 +38,23 @@ export type Options = { */ readonly resetExpiryOnChange?: boolean } + +type SingleOptions = CommonOptions & { + /** + * The function used to send commands to Redis. + */ + readonly sendCommand: SendCommandFn +} + +type ClusterOptions = CommonOptions & { + /** + * The alternative function used to send commands to Redis when in cluster mode. + * (It provides extra parameters to help route the command to the correct redis node.) + */ + readonly sendCommandCluster: SendCommandClusterFn +} + +/** + * The configuration options for the store. + */ +export type Options = SingleOptions | ClusterOptions