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
13 changes: 12 additions & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,18 @@
"Bash(npx vitepress *)",
"Bash(npx prettier *)",
"Bash(pnpm test)",
"Bash(npx eslint *)"
"Bash(npx eslint *)",
"WebFetch(domain:github.com)",
"WebSearch",
"WebFetch(domain:daddywarbucks.github.io)",
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(curl -sS https://raw.githubusercontent.com/DaddyWarbucks/feathers-fletching/master/src/hooks/rateLimit.ts)",
"Bash(curl -sS https://raw.githubusercontent.com/DaddyWarbucks/feathers-fletching/master/tests/hooks/rateLimit.test.ts)",
"Bash(pnpm run test:unit)",
"Bash(pnpm run test:unit *)",
"Bash(pnpm run build)",
"Bash(gh release list *)",
"Bash(gh release view *)"
]
}
}
9 changes: 8 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,16 @@
"tsdown": "^0.20.3",
"typescript": "^5.9.3",
"vitepress": "^2.0.0-alpha.16",
"rate-limiter-flexible": "^10.0.1",
"vitest": "^4.0.18"
},
"peerDependencies": {
"@feathersjs/feathers": "^5.0.0"
"@feathersjs/feathers": "^5.0.0",
"rate-limiter-flexible": ">=10.0.0"
},
"peerDependenciesMeta": {
"rate-limiter-flexible": {
"optional": true
}
}
}
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

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

2 changes: 1 addition & 1 deletion src/hooks/disallow/disallow.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const disallow = <H extends HookContext = HookContext>(

if (isProvider(...(transportsArr as TransportName[]))(context)) {
throw new MethodNotAllowed(
`Provider '${context.params.provider}' can not call '${context.method}'. (disallow)`,
`Provider '${context.params.provider}' can not call '${context.method}' on '${context.path}'. (disallow)`,
)
}

Expand Down
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export * from './on-delete/on-delete.hook.js'
export * from './params-for-server/params-for-server.hook.js'
export * from './params-from-client/params-from-client.hook.js'
export * from './prevent-changes/prevent-changes.hook.js'
export * from './rate-limit/rate-limit.hook.js'
export * from './set-data/set-data.hook.js'
export * from './set-field/set-field.hook.js'
export * from './set-result/set-result.hook.js'
Expand Down
131 changes: 131 additions & 0 deletions src/hooks/rate-limit/rate-limit.hook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
---
title: rateLimit
category: hooks
hook:
type: ["before", "around"]
method: ["find", "get", "create", "update", "patch", "remove"]
multi: true
---

The `rateLimit` hook limits how many times a service method can be called within a time window using [rate-limiter-flexible](https://github.com/animir/node-rate-limiter-flexible). You provide a pre-configured rate limiter instance — the hook calls `consume()` on each request and throws a `TooManyRequests` error when the limit is exceeded.

Any rate limiter backend supported by `rate-limiter-flexible` can be used (Memory, Redis, Mongo, Postgres, etc.).

## Options

| Option | Type | Description |
| --- | --- | --- |
| `key` | `(context) => string` | Generate the rate-limiting key. Defaults to `context.path`. |
| `points` | `(context) => number` | Number of points to consume per request. Defaults to `1`. |

The `RateLimiterRes` is stored on `context.params.rateLimit` on both success and failure, so downstream hooks or services can inspect `remainingPoints`, `consumedPoints`, `msBeforeNext`, etc.

## Examples

### Basic Usage

```ts
import { rateLimit } from 'feathers-utils/hooks'
import { RateLimiterMemory } from 'rate-limiter-flexible'

const rateLimiter = new RateLimiterMemory({
points: 10, // 10 requests
duration: 1, // per 1 second
})

app.service('users').hooks({
before: {
find: [rateLimit(rateLimiter)],
},
})
```

### Rate Limit per User

Use the `key` option to rate limit per authenticated user instead of per service path:

```ts
const rateLimiter = new RateLimiterMemory({ points: 100, duration: 60 })

app.service('messages').hooks({
before: {
create: [
rateLimit(rateLimiter, {
key: (context) => `${context.path}:${context.params.user?.id}`,
}),
],
},
})
```

### Custom Points per Request

Use the `points` option to consume more points for expensive operations:

```ts
app.service('reports').hooks({
before: {
find: [
rateLimit(rateLimiter, {
points: (context) => context.params.query?.$limit > 100 ? 5 : 1,
}),
],
},
})
```

### Redis Backend

```ts
import { RateLimiterRedis } from 'rate-limiter-flexible'
import Redis from 'ioredis'

const redisClient = new Redis()

const rateLimiter = new RateLimiterRedis({
storeClient: redisClient,
points: 100,
duration: 60,
keyPrefix: 'rl',
})

app.service('users').hooks({
before: {
find: [rateLimit(rateLimiter)],
},
})
```

### Bypass with iff

Use [`iff`](/hooks/iff.html) to skip rate limiting for internal (server-side) calls:

```ts
import { rateLimit, iff } from 'feathers-utils/hooks'
import { isProvider } from 'feathers-utils/predicates'

app.service('users').hooks({
before: {
find: [
iff(isProvider('rest', 'socketio', 'external'), rateLimit(rateLimiter)),
],
},
})
```

### Bypass with skippable

Use [`skippable`](/hooks/skippable.html) to allow specific callers to opt out of rate limiting:

```ts
import { rateLimit, skippable } from 'feathers-utils/hooks'

app.service('users').hooks({
before: {
find: [skippable(rateLimit(rateLimiter))],
},
})

// Skip rate limiting for this call
app.service('users').find({ skipHooks: ['rateLimit'] })
```
119 changes: 119 additions & 0 deletions src/hooks/rate-limit/rate-limit.hook.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, it, expect, vi } from 'vitest'
import { RateLimiterMemory } from 'rate-limiter-flexible'
import { rateLimit } from './rate-limit.hook.js'

describe('hook - rateLimit', () => {
it('passes through when under limit and sets context.params.rateLimit', async () => {
const context: any = {
type: 'before',
method: 'find',
path: 'users',
params: {},
}
const rateLimiter = new RateLimiterMemory({ points: 5, duration: 1 })

await rateLimit(rateLimiter)(context)

expect(context.params.rateLimit).toBeDefined()
expect(context.params.rateLimit.remainingPoints).toBe(4)
expect(context.params.rateLimit.consumedPoints).toBe(1)
})

it('throws TooManyRequests when limit is exceeded', async () => {
const context: any = {
type: 'before',
method: 'find',
path: 'users',
params: {},
}
const rateLimiter = new RateLimiterMemory({ points: 1, duration: 1 })

await rateLimit(rateLimiter)(context)

await expect(rateLimit(rateLimiter)(context)).rejects.toThrow(
'Too many requests',
)
})

it('sets context.params.rateLimit even on rejection', async () => {
const context: any = {
type: 'before',
method: 'find',
path: 'users',
params: {},
}
const rateLimiter = new RateLimiterMemory({ points: 1, duration: 1 })

await rateLimit(rateLimiter)(context)

try {
await rateLimit(rateLimiter)(context)
} catch {
// expected
}

expect(context.params.rateLimit).toBeDefined()
})

it('uses custom key', async () => {
const context: any = {
type: 'before',
method: 'find',
path: 'users',
params: {},
}
const rateLimiter = new RateLimiterMemory({ points: 1, duration: 1 })

// With random keys, each request gets its own bucket
const key = () => Math.random().toString()

await rateLimit(rateLimiter, { key })(context)
await expect(
rateLimit(rateLimiter, { key })(context),
).resolves.not.toThrow()
})

it('uses custom points', async () => {
const context: any = {
type: 'before',
method: 'find',
path: 'users',
params: {},
}
const rateLimiter = new RateLimiterMemory({ points: 1, duration: 1 })

// Consuming 2 points against a 1-point limit should fail immediately
const points = () => 2

await expect(rateLimit(rateLimiter, { points })(context)).rejects.toThrow(
'Too many requests',
)
})

it('throws when used in an after hook', async () => {
const context: any = {
type: 'after',
method: 'find',
path: 'users',
params: {},
}
const rateLimiter = new RateLimiterMemory({ points: 5, duration: 1 })

await expect(rateLimit(rateLimiter)(context)).rejects.toThrow()
})

it('calls next() for around hooks', async () => {
const context: any = {
type: 'around',
method: 'find',
path: 'users',
params: {},
}
const rateLimiter = new RateLimiterMemory({ points: 5, duration: 1 })
const next = vi.fn()

await rateLimit(rateLimiter)(context, next)

expect(next).toHaveBeenCalledOnce()
})
})
58 changes: 58 additions & 0 deletions src/hooks/rate-limit/rate-limit.hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { TooManyRequests } from '@feathersjs/errors'
import type { HookContext, NextFunction } from '@feathersjs/feathers'
import type { RateLimiterAbstract, RateLimiterRes } from 'rate-limiter-flexible'
import { checkContext } from '../../utils/index.js'
import type { Promisable } from '../../internal.utils.js'

export type RateLimitOptions<H extends HookContext = HookContext> = {
/** Generate the rate-limiting key. Defaults to `context.path`. */
key?: (context: H) => Promisable<string>
/** Number of points to consume per request. Defaults to `1`. */
points?: (context: H) => Promisable<number>
}

/**
* Rate limits service method calls using `rate-limiter-flexible`.
* You provide a pre-configured `RateLimiterAbstract` instance
* (Memory, Redis, Mongo, etc.) and the hook consumes points per request.
*
* @example
* ```ts
* import { rateLimit } from 'feathers-utils/hooks'
* import { RateLimiterMemory } from 'rate-limiter-flexible'
*
* const rateLimiter = new RateLimiterMemory({ points: 10, duration: 1 })
*
* app.service('users').hooks({
* before: { find: [rateLimit(rateLimiter)] }
* })
* ```
*
* @see https://utils.feathersjs.com/hooks/rate-limit.html
*/
export const rateLimit = <H extends HookContext = HookContext>(
rateLimiter: RateLimiterAbstract,
options?: RateLimitOptions<H>,
) => {
const key = options?.key ?? ((context: HookContext) => context.path)
const points = options?.points ?? (() => 1)

return async (context: H, next?: NextFunction) => {
checkContext(context, { type: ['before', 'around'], label: 'rateLimit' })

const resolvedKey = await key(context)
const resolvedPoints = await points(context)

try {
const res = await rateLimiter.consume(resolvedKey, resolvedPoints)
context.params.rateLimit = res
} catch (res) {
context.params.rateLimit = res as RateLimiterRes
throw new TooManyRequests('Too many requests', {
rateLimitRes: res as RateLimiterRes,
})
}

if (next) return await next()
}
}
Loading
Loading