Skip to content

Commit 369c701

Browse files
committed
feat: new hook rateLimit
1 parent b626363 commit 369c701

File tree

9 files changed

+339
-3
lines changed

9 files changed

+339
-3
lines changed

.claude/settings.json

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,18 @@
77
"Bash(npx vitepress *)",
88
"Bash(npx prettier *)",
99
"Bash(pnpm test)",
10-
"Bash(npx eslint *)"
10+
"Bash(npx eslint *)",
11+
"WebFetch(domain:github.com)",
12+
"WebSearch",
13+
"WebFetch(domain:daddywarbucks.github.io)",
14+
"WebFetch(domain:raw.githubusercontent.com)",
15+
"Bash(curl -sS https://raw.githubusercontent.com/DaddyWarbucks/feathers-fletching/master/src/hooks/rateLimit.ts)",
16+
"Bash(curl -sS https://raw.githubusercontent.com/DaddyWarbucks/feathers-fletching/master/tests/hooks/rateLimit.test.ts)",
17+
"Bash(pnpm run test:unit)",
18+
"Bash(pnpm run test:unit *)",
19+
"Bash(pnpm run build)",
20+
"Bash(gh release list *)",
21+
"Bash(gh release view *)"
1122
]
1223
}
1324
}

package.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,16 @@
122122
"tsdown": "^0.20.3",
123123
"typescript": "^5.9.3",
124124
"vitepress": "^2.0.0-alpha.16",
125+
"rate-limiter-flexible": "^10.0.1",
125126
"vitest": "^4.0.18"
126127
},
127128
"peerDependencies": {
128-
"@feathersjs/feathers": "^5.0.0"
129+
"@feathersjs/feathers": "^5.0.0",
130+
"rate-limiter-flexible": ">=10.0.0"
131+
},
132+
"peerDependenciesMeta": {
133+
"rate-limiter-flexible": {
134+
"optional": true
135+
}
129136
}
130137
}

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/hooks/disallow/disallow.hook.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export const disallow = <H extends HookContext = HookContext>(
3535

3636
if (isProvider(...(transportsArr as TransportName[]))(context)) {
3737
throw new MethodNotAllowed(
38-
`Provider '${context.params.provider}' can not call '${context.method}'. (disallow)`,
38+
`Provider '${context.params.provider}' can not call '${context.method}' on '${context.path}'. (disallow)`,
3939
)
4040
}
4141

src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export * from './on-delete/on-delete.hook.js'
1212
export * from './params-for-server/params-for-server.hook.js'
1313
export * from './params-from-client/params-from-client.hook.js'
1414
export * from './prevent-changes/prevent-changes.hook.js'
15+
export * from './rate-limit/rate-limit.hook.js'
1516
export * from './set-data/set-data.hook.js'
1617
export * from './set-field/set-field.hook.js'
1718
export * from './set-result/set-result.hook.js'
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
---
2+
title: rateLimit
3+
category: hooks
4+
hook:
5+
type: ["before", "around"]
6+
method: ["find", "get", "create", "update", "patch", "remove"]
7+
multi: true
8+
---
9+
10+
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.
11+
12+
Any rate limiter backend supported by `rate-limiter-flexible` can be used (Memory, Redis, Mongo, Postgres, etc.).
13+
14+
## Options
15+
16+
| Option | Type | Description |
17+
| --- | --- | --- |
18+
| `key` | `(context) => string` | Generate the rate-limiting key. Defaults to `context.path`. |
19+
| `points` | `(context) => number` | Number of points to consume per request. Defaults to `1`. |
20+
21+
The `RateLimiterRes` is stored on `context.params.rateLimit` on both success and failure, so downstream hooks or services can inspect `remainingPoints`, `consumedPoints`, `msBeforeNext`, etc.
22+
23+
## Examples
24+
25+
### Basic Usage
26+
27+
```ts
28+
import { rateLimit } from 'feathers-utils/hooks'
29+
import { RateLimiterMemory } from 'rate-limiter-flexible'
30+
31+
const rateLimiter = new RateLimiterMemory({
32+
points: 10, // 10 requests
33+
duration: 1, // per 1 second
34+
})
35+
36+
app.service('users').hooks({
37+
before: {
38+
find: [rateLimit(rateLimiter)],
39+
},
40+
})
41+
```
42+
43+
### Rate Limit per User
44+
45+
Use the `key` option to rate limit per authenticated user instead of per service path:
46+
47+
```ts
48+
const rateLimiter = new RateLimiterMemory({ points: 100, duration: 60 })
49+
50+
app.service('messages').hooks({
51+
before: {
52+
create: [
53+
rateLimit(rateLimiter, {
54+
key: (context) => `${context.path}:${context.params.user?.id}`,
55+
}),
56+
],
57+
},
58+
})
59+
```
60+
61+
### Custom Points per Request
62+
63+
Use the `points` option to consume more points for expensive operations:
64+
65+
```ts
66+
app.service('reports').hooks({
67+
before: {
68+
find: [
69+
rateLimit(rateLimiter, {
70+
points: (context) => context.params.query?.$limit > 100 ? 5 : 1,
71+
}),
72+
],
73+
},
74+
})
75+
```
76+
77+
### Redis Backend
78+
79+
```ts
80+
import { RateLimiterRedis } from 'rate-limiter-flexible'
81+
import Redis from 'ioredis'
82+
83+
const redisClient = new Redis()
84+
85+
const rateLimiter = new RateLimiterRedis({
86+
storeClient: redisClient,
87+
points: 100,
88+
duration: 60,
89+
keyPrefix: 'rl',
90+
})
91+
92+
app.service('users').hooks({
93+
before: {
94+
find: [rateLimit(rateLimiter)],
95+
},
96+
})
97+
```
98+
99+
### Bypass with iff
100+
101+
Use [`iff`](/hooks/iff.html) to skip rate limiting for internal (server-side) calls:
102+
103+
```ts
104+
import { rateLimit, iff } from 'feathers-utils/hooks'
105+
import { isProvider } from 'feathers-utils/predicates'
106+
107+
app.service('users').hooks({
108+
before: {
109+
find: [
110+
iff(isProvider('rest', 'socketio', 'external'), rateLimit(rateLimiter)),
111+
],
112+
},
113+
})
114+
```
115+
116+
### Bypass with skippable
117+
118+
Use [`skippable`](/hooks/skippable.html) to allow specific callers to opt out of rate limiting:
119+
120+
```ts
121+
import { rateLimit, skippable } from 'feathers-utils/hooks'
122+
123+
app.service('users').hooks({
124+
before: {
125+
find: [skippable(rateLimit(rateLimiter))],
126+
},
127+
})
128+
129+
// Skip rate limiting for this call
130+
app.service('users').find({ skipHooks: ['rateLimit'] })
131+
```
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { describe, it, expect, vi } from 'vitest'
2+
import { RateLimiterMemory } from 'rate-limiter-flexible'
3+
import { rateLimit } from './rate-limit.hook.js'
4+
5+
describe('hook - rateLimit', () => {
6+
it('passes through when under limit and sets context.params.rateLimit', async () => {
7+
const context: any = {
8+
type: 'before',
9+
method: 'find',
10+
path: 'users',
11+
params: {},
12+
}
13+
const rateLimiter = new RateLimiterMemory({ points: 5, duration: 1 })
14+
15+
await rateLimit(rateLimiter)(context)
16+
17+
expect(context.params.rateLimit).toBeDefined()
18+
expect(context.params.rateLimit.remainingPoints).toBe(4)
19+
expect(context.params.rateLimit.consumedPoints).toBe(1)
20+
})
21+
22+
it('throws TooManyRequests when limit is exceeded', async () => {
23+
const context: any = {
24+
type: 'before',
25+
method: 'find',
26+
path: 'users',
27+
params: {},
28+
}
29+
const rateLimiter = new RateLimiterMemory({ points: 1, duration: 1 })
30+
31+
await rateLimit(rateLimiter)(context)
32+
33+
await expect(rateLimit(rateLimiter)(context)).rejects.toThrow(
34+
'Too many requests',
35+
)
36+
})
37+
38+
it('sets context.params.rateLimit even on rejection', async () => {
39+
const context: any = {
40+
type: 'before',
41+
method: 'find',
42+
path: 'users',
43+
params: {},
44+
}
45+
const rateLimiter = new RateLimiterMemory({ points: 1, duration: 1 })
46+
47+
await rateLimit(rateLimiter)(context)
48+
49+
try {
50+
await rateLimit(rateLimiter)(context)
51+
} catch {
52+
// expected
53+
}
54+
55+
expect(context.params.rateLimit).toBeDefined()
56+
})
57+
58+
it('uses custom key', async () => {
59+
const context: any = {
60+
type: 'before',
61+
method: 'find',
62+
path: 'users',
63+
params: {},
64+
}
65+
const rateLimiter = new RateLimiterMemory({ points: 1, duration: 1 })
66+
67+
// With random keys, each request gets its own bucket
68+
const key = () => Math.random().toString()
69+
70+
await rateLimit(rateLimiter, { key })(context)
71+
await expect(
72+
rateLimit(rateLimiter, { key })(context),
73+
).resolves.not.toThrow()
74+
})
75+
76+
it('uses custom points', async () => {
77+
const context: any = {
78+
type: 'before',
79+
method: 'find',
80+
path: 'users',
81+
params: {},
82+
}
83+
const rateLimiter = new RateLimiterMemory({ points: 1, duration: 1 })
84+
85+
// Consuming 2 points against a 1-point limit should fail immediately
86+
const points = () => 2
87+
88+
await expect(rateLimit(rateLimiter, { points })(context)).rejects.toThrow(
89+
'Too many requests',
90+
)
91+
})
92+
93+
it('throws when used in an after hook', async () => {
94+
const context: any = {
95+
type: 'after',
96+
method: 'find',
97+
path: 'users',
98+
params: {},
99+
}
100+
const rateLimiter = new RateLimiterMemory({ points: 5, duration: 1 })
101+
102+
await expect(rateLimit(rateLimiter)(context)).rejects.toThrow()
103+
})
104+
105+
it('calls next() for around hooks', async () => {
106+
const context: any = {
107+
type: 'around',
108+
method: 'find',
109+
path: 'users',
110+
params: {},
111+
}
112+
const rateLimiter = new RateLimiterMemory({ points: 5, duration: 1 })
113+
const next = vi.fn()
114+
115+
await rateLimit(rateLimiter)(context, next)
116+
117+
expect(next).toHaveBeenCalledOnce()
118+
})
119+
})
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { TooManyRequests } from '@feathersjs/errors'
2+
import type { HookContext, NextFunction } from '@feathersjs/feathers'
3+
import type { RateLimiterAbstract, RateLimiterRes } from 'rate-limiter-flexible'
4+
import { checkContext } from '../../utils/index.js'
5+
import type { Promisable } from '../../internal.utils.js'
6+
7+
export type RateLimitOptions<H extends HookContext = HookContext> = {
8+
/** Generate the rate-limiting key. Defaults to `context.path`. */
9+
key?: (context: H) => Promisable<string>
10+
/** Number of points to consume per request. Defaults to `1`. */
11+
points?: (context: H) => Promisable<number>
12+
}
13+
14+
/**
15+
* Rate limits service method calls using `rate-limiter-flexible`.
16+
* You provide a pre-configured `RateLimiterAbstract` instance
17+
* (Memory, Redis, Mongo, etc.) and the hook consumes points per request.
18+
*
19+
* @example
20+
* ```ts
21+
* import { rateLimit } from 'feathers-utils/hooks'
22+
* import { RateLimiterMemory } from 'rate-limiter-flexible'
23+
*
24+
* const rateLimiter = new RateLimiterMemory({ points: 10, duration: 1 })
25+
*
26+
* app.service('users').hooks({
27+
* before: { find: [rateLimit(rateLimiter)] }
28+
* })
29+
* ```
30+
*
31+
* @see https://utils.feathersjs.com/hooks/rate-limit.html
32+
*/
33+
export const rateLimit = <H extends HookContext = HookContext>(
34+
rateLimiter: RateLimiterAbstract,
35+
options?: RateLimitOptions<H>,
36+
) => {
37+
const key = options?.key ?? ((context: HookContext) => context.path)
38+
const points = options?.points ?? (() => 1)
39+
40+
return async (context: H, next?: NextFunction) => {
41+
checkContext(context, { type: ['before', 'around'], label: 'rateLimit' })
42+
43+
const resolvedKey = await key(context)
44+
const resolvedPoints = await points(context)
45+
46+
try {
47+
const res = await rateLimiter.consume(resolvedKey, resolvedPoints)
48+
context.params.rateLimit = res
49+
} catch (res) {
50+
context.params.rateLimit = res as RateLimiterRes
51+
throw new TooManyRequests('Too many requests', {
52+
rateLimitRes: res as RateLimiterRes,
53+
})
54+
}
55+
56+
if (next) return await next()
57+
}
58+
}

0 commit comments

Comments
 (0)