Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 40 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,14 +140,53 @@ 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)` |
| [`redis-fast-driver`](https://github.com/h0x91b/redis-fast-driver) | `async (...args: string[]) => client.rawCallAsync(args)` |
| [`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> | 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<RedisReply>,
}),
})
app.use(limiter)
```

#### `prefix`

The text to prepend to the key in Redict/Redis.
Expand Down
102 changes: 74 additions & 28 deletions source/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand All @@ -97,8 +119,12 @@ export class RedisStore implements Store {
/**
* Loads the script used to increment a client's hit count.
*/
async loadIncrementScript(): Promise<string> {
const result = await this.sendCommand('SCRIPT', 'LOAD', scripts.increment)
async loadIncrementScript(key?: string): Promise<string> {
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')
Expand All @@ -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<string> {
const result = await this.sendCommand('SCRIPT', 'LOAD', scripts.get)
async loadGetScript(key?: string): Promise<string> {
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')
Expand All @@ -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<RedisReply> {
async retryableIncrement(_key: string): Promise<RedisReply> {
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()
}
}
Expand Down Expand Up @@ -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<ClientRateLimitInfo | undefined> {
const results = await this.sendCommand(
'EVALSHA',
await this.getScriptSha,
'1',
this.prefixKey(key),
)
async get(_key: string): Promise<ClientRateLimitInfo | undefined> {
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)
}
Expand All @@ -199,17 +243,19 @@ export class RedisStore implements Store {
*
* @param key {string} - The identifier for a client
*/
async decrement(key: string): Promise<void> {
await this.sendCommand('DECR', this.prefixKey(key))
async decrement(_key: string): Promise<void> {
const key = this.prefixKey(_key)
await this.sendCommand({ key, isReadOnly: false, command: ['DECR', key] })
}

/**
* Method to reset a client's hit counter.
*
* @param key {string} - The identifier for a client
*/
async resetKey(key: string): Promise<void> {
await this.sendCommand('DEL', this.prefixKey(key))
async resetKey(_key: string): Promise<void> {
const key = this.prefixKey(_key)
await this.sendCommand({ key, isReadOnly: false, command: ['DEL', key] })
}
}

Expand Down
37 changes: 31 additions & 6 deletions source/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,20 @@ export type RedisReply = Data | Data[]
*/
export type SendCommandFn = (...args: string[]) => Promise<RedisReply>

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<RedisReply>

type CommonOptions = {
/**
* The text to prepend to the key in Redis.
*/
Expand All @@ -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