| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -6,7 +6,7 @@ hook: | |||
| 6 | 6 | method: ['find', 'get', 'create', 'update', 'patch', 'remove'] | |
| 7 | 7 | multi: true | |
| 8 | 8 | see: | |
| 9 | - - utils/passParams | ||
| 9 | + - utils/gateParams | ||
| 10 | 10 | --- | |
| 11 | 11 | ||
| 12 | 12 | The `cache` hook caches `get` and `find` results based on `params`. On mutating methods (`create`, `update`, `patch`, `remove`), affected cache entries are automatically invalidated. | |
@@ -21,16 +21,16 @@ The `cache` hook caches `get` and `find` results based on `params`. On mutating | |||
| 21 | 21 | | ----------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | |
| 22 | 22 | | `map` | `Cache` | The cache implementation. Must implement `get`, `set`, `delete`, `clear`, and `keys`. | | |
| 23 | 23 | | `id` | `string` | The id field to use. Defaults to `service.options.id`, then `'id'`. | | |
| 24 | - | `transformParams` | `(params) => params` | Transform params before they are used as cache key. Compose it with [`passParams`](/utils/pass-params) to declaratively pick/drop keys and avoid false hits — see [Choosing Cache-Relevant Params](#choosing-cache-relevant-params-with-passparams). | | ||
| 24 | + | `transformParams` | `(params) => params` | Transform params before they are used as cache key. Compose it with [`gateParams`](/utils/gate-params) to declaratively pick/drop keys and avoid false hits — see [Choosing Cache-Relevant Params](#choosing-cache-relevant-params-with-gateparams). | | ||
| 25 | 25 | ||
| 26 | - ## Choosing Cache-Relevant Params (with `passParams`) | ||
| 26 | + ## Choosing Cache-Relevant Params (with `gateParams`) | ||
| 27 | 27 | ||
| 28 | 28 | Deciding which `params` keys form the cache key is the trickiest part of caching, and the two failure modes are asymmetric: | |
| 29 | 29 | ||
| 30 | 30 | - **False hits (dangerous):** if a key that affects the result is left out (e.g. `user`/tenant, `provider`), two semantically different requests collapse to the same key — one user can be served another user's cached data. | |
| 31 | 31 | - **False misses (wasteful):** if a per-request/metrics key is included (e.g. `rateLimit`), every request produces a unique key and the cache never hits. A function-valued key (e.g. `stashed` from `stashable`) would even make serialization throw. | |
| 32 | 32 | ||
| 33 | - The [`passParams`](/utils/pass-params) utility makes this explicit and safe. It takes a declarative path schema (`true` include, `false` drop, or a predicate/projection function). `query` is always included by default, and keys you never classified are **kept by default** — the safe direction, since a forgotten key causes at worst a harmless cache miss, never a false hit. | ||
| 33 | + The [`gateParams`](/utils/gate-params) utility makes this explicit and safe. It takes a declarative path schema (`true` include, `false` drop, or a predicate/projection function). `query` is always included by default, and keys you never classified are **kept by default** — the safe direction, since a forgotten key causes at worst a harmless cache miss, never a false hit. | ||
| 34 | 34 | ||
| 35 | 35 | > Transient keys that feathers-utils' own hooks attach to `params` — `rateLimit` (`rateLimit`), `skipHooks` (`skippable`/`addSkip`), the `stashed` function and `_stashable` flag (`stashable`) — are never cache-relevant. Drop them with `false`, or keep only what you list via `dropUnknownParams: true`. | |
| 36 | 36 | ||
@@ -39,12 +39,12 @@ The [`passParams`](/utils/pass-params) utility makes this explicit and safe. It | |||
| 39 | 39 | Cache on everything except the keys you explicitly drop with `false`. This is the default direction — safe against false hits: | |
| 40 | 40 | ||
| 41 | 41 | ```ts | |
| 42 | - import { passParams } from 'feathers-utils/utils' | ||
| 42 | + import { gateParams } from 'feathers-utils/utils' | ||
| 43 | 43 | ||
| 44 | 44 | cache({ | |
| 45 | 45 | map: new Map(), | |
| 46 | 46 | transformParams: (params) => | |
| 47 | - passParams(params, { rateLimit: false, skipHooks: false }), | ||
| 47 | + gateParams(params, { rateLimit: false, skipHooks: false }), | ||
| 48 | 48 | }) | |
| 49 | 49 | ``` | |
| 50 | 50 | ||
@@ -53,12 +53,12 @@ cache({ | |||
| 53 | 53 | Set `dropUnknownParams: true` so only `query` (always) and the listed paths form the cache key. `user.id` is picked via dot-notation so different tenants never collide and per-request `user` fields don't bloat the key. Use `onUnknownParams` to log anything that was dropped: | |
| 54 | 54 | ||
| 55 | 55 | ```ts | |
| 56 | - import { passParams } from 'feathers-utils/utils' | ||
| 56 | + import { gateParams } from 'feathers-utils/utils' | ||
| 57 | 57 | ||
| 58 | 58 | cache({ | |
| 59 | 59 | map: new Map(), | |
| 60 | 60 | transformParams: (params) => | |
| 61 | - passParams( | ||
| 61 | + gateParams( | ||
| 62 | 62 | params, | |
| 63 | 63 | { 'user.id': true }, // `query` is included automatically | |
| 64 | 64 | { | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -7,7 +7,7 @@ import { TTLCache } from '@isaacs/ttlcache' | |||
| 7 | 7 | import { MemoryService } from '@feathersjs/memory' | |
| 8 | 8 | import { expect, expectTypeOf } from 'vitest' | |
| 9 | 9 | import { copy } from 'fast-copy' | |
| 10 | - import { passParams } from '../../utils/pass-params/pass-params.util.js' | ||
| 10 | + import { gateParams } from '../../utils/gate-params/gate-params.util.js' | ||
| 11 | 11 | ||
| 12 | 12 | const setup = (options: CacheOptions, serviceOptions?: { id?: string }) => { | |
| 13 | 13 | const app = feathers<{ | |
@@ -1149,12 +1149,12 @@ describe('cache hook as an around hook', () => { | |||
| 1149 | 1149 | }) | |
| 1150 | 1150 | }) | |
| 1151 | 1151 | ||
| 1152 | - describe('cache hook with passParams', () => { | ||
| 1152 | + describe('cache hook with gateParams', () => { | ||
| 1153 | 1153 | it('prevents false hits across users and collapses non-id user fields (whitelist)', async () => { | |
| 1154 | 1154 | const { usersService, before } = setup({ | |
| 1155 | 1155 | map: new Map(), | |
| 1156 | 1156 | // `query` is included by default; only `user.id` is added explicitly. | |
| 1157 | - transformParams: (params) => passParams(params, { 'user.id': true }), | ||
| 1157 | + transformParams: (params) => gateParams(params, { 'user.id': true }), | ||
| 1158 | 1158 | }) | |
| 1159 | 1159 | ||
| 1160 | 1160 | await usersService.create({ id: 1, name: 'John' }) | |
@@ -1185,7 +1185,7 @@ describe('cache hook with passParams', () => { | |||
| 1185 | 1185 | const { usersService, before } = setup({ | |
| 1186 | 1186 | map: new Map(), | |
| 1187 | 1187 | // keep everything except the transient `rateLimit` metric. | |
| 1188 | - transformParams: (params) => passParams(params, { rateLimit: false }), | ||
| 1188 | + transformParams: (params) => gateParams(params, { rateLimit: false }), | ||
| 1189 | 1189 | }) | |
| 1190 | 1190 | ||
| 1191 | 1191 | await usersService.create({ id: 1, name: 'John' }) | |
@@ -1209,7 +1209,7 @@ describe('cache hook with passParams', () => { | |||
| 1209 | 1209 | map: new Map(), | |
| 1210 | 1210 | // keep only `query` (default); `stashed` (a function) is dropped. | |
| 1211 | 1211 | transformParams: (params) => | |
| 1212 | - passParams(params, {}, { dropUnknownParams: true }), | ||
| 1212 | + gateParams(params, {}, { dropUnknownParams: true }), | ||
| 1213 | 1213 | }) | |
| 1214 | 1214 | ||
| 1215 | 1215 | await usersService.create({ id: 1, name: 'John' }) | |
@@ -1235,7 +1235,7 @@ describe('cache hook with passParams', () => { | |||
| 1235 | 1235 | const { usersService } = setup({ | |
| 1236 | 1236 | map: new Map(), | |
| 1237 | 1237 | transformParams: (params) => | |
| 1238 | - passParams(params, { query: true }, { onUnknownParams }), | ||
| 1238 | + gateParams(params, { query: true }, { onUnknownParams }), | ||
| 1239 | 1239 | }) | |
| 1240 | 1240 | ||
| 1241 | 1241 | await usersService.create({ id: 1, name: 'John' }) | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -35,17 +35,17 @@ export type CacheOptions = { | |||
| 35 | 35 | * There are params properties you don't want to include in the cache key. | |
| 36 | 36 | * You can use this function to transform the params before they are stringified. | |
| 37 | 37 | * | |
| 38 | - * The {@link passParams} util is built for exactly this: it declaratively | ||
| 38 | + * The {@link gateParams} util is built for exactly this: it declaratively | ||
| 39 | 39 | * selects/projects `params` keys (keeping `query` by default) so noise like | |
| 40 | 40 | * `rateLimit` never ends up in the cache key. | |
| 41 | 41 | * | |
| 42 | 42 | * @example | |
| 43 | 43 | * ```ts | |
| 44 | - * import { passParams } from 'feathers-utils/utils' | ||
| 44 | + * import { gateParams } from 'feathers-utils/utils' | ||
| 45 | 45 | * | |
| 46 | 46 | * cache({ | |
| 47 | 47 | * map: new Map(), | |
| 48 | - * transformParams: (params) => passParams(params, { rateLimit: false }), | ||
| 48 | + * transformParams: (params) => gateParams(params, { rateLimit: false }), | ||
| 49 | 49 | * }) | |
| 50 | 50 | * ``` | |
| 51 | 51 | */ | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -2,8 +2,8 @@ | |||
| 2 | 2 | title: rateLimit | |
| 3 | 3 | category: hooks | |
| 4 | 4 | hook: | |
| 5 | - type: ["before", "around"] | ||
| 6 | - method: ["find", "get", "create", "update", "patch", "remove"] | ||
| 5 | + type: ['before', 'around'] | ||
| 6 | + method: ['find', 'get', 'create', 'update', 'patch', 'remove'] | ||
| 7 | 7 | multi: true | |
| 8 | 8 | --- | |
| 9 | 9 | ||
@@ -13,10 +13,10 @@ Any rate limiter backend supported by `rate-limiter-flexible` can be used (Memor | |||
| 13 | 13 | ||
| 14 | 14 | ## Options | |
| 15 | 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`. | | ||
| 16 | + | Option | Type | Description | | ||
| 17 | + | -------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ||
| 18 | + | `key` | `string \| ((context) => string)` | The rate-limiting key, or a function to derive it from the context. Defaults to `context.path`. Pass a static string for a single shared bucket (a global rate limit). | | ||
| 19 | + | `points` | `number \| ((context) => number)` | Number of points to consume per request, or a function to compute it from the context. Defaults to `1`. | | ||
| 20 | 20 | ||
| 21 | 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 | 22 | ||
@@ -58,16 +58,40 @@ app.service('messages').hooks({ | |||
| 58 | 58 | }) | |
| 59 | 59 | ``` | |
| 60 | 60 | ||
| 61 | + ### Global Rate Limit | ||
| 62 | + | ||
| 63 | + Pass a static string as the `key` to share a single bucket across all requests — a global cap on an endpoint instead of one bucket per `context.path`: | ||
| 64 | + | ||
| 65 | + ```ts | ||
| 66 | + const rateLimiter = new RateLimiterMemory({ points: 1000, duration: 60 }) | ||
| 67 | + | ||
| 68 | + app.service('search').hooks({ | ||
| 69 | + before: { | ||
| 70 | + find: [rateLimit(rateLimiter, { key: 'search' })], | ||
| 71 | + }, | ||
| 72 | + }) | ||
| 73 | + ``` | ||
| 74 | + | ||
| 61 | 75 | ### Custom Points per Request | |
| 62 | 76 | ||
| 63 | - Use the `points` option to consume more points for expensive operations: | ||
| 77 | + Pass a static number to consume a fixed cost per request: | ||
| 78 | + | ||
| 79 | + ```ts | ||
| 80 | + app.service('reports').hooks({ | ||
| 81 | + before: { | ||
| 82 | + find: [rateLimit(rateLimiter, { points: 5 })], | ||
| 83 | + }, | ||
| 84 | + }) | ||
| 85 | + ``` | ||
| 86 | + | ||
| 87 | + Or pass a function to compute the cost from the context — e.g. to charge more for expensive queries: | ||
| 64 | 88 | ||
| 65 | 89 | ```ts | |
| 66 | 90 | app.service('reports').hooks({ | |
| 67 | 91 | before: { | |
| 68 | 92 | find: [ | |
| 69 | 93 | rateLimit(rateLimiter, { | |
| 70 | - points: (context) => context.params.query?.$limit > 100 ? 5 : 1, | ||
| 94 | + points: (context) => (context.params.query?.$limit > 100 ? 5 : 1), | ||
| 71 | 95 | }), | |
| 72 | 96 | ], | |
| 73 | 97 | }, | |
@@ -128,4 +152,4 @@ app.service('users').hooks({ | |||
| 128 | 152 | ||
| 129 | 153 | // Skip rate limiting for this call | |
| 130 | 154 | app.service('users').find({ skipHooks: ['rateLimit'] }) | |
| 131 | - ``` | ||
| 155 | + ``` | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -93,6 +93,37 @@ describe('hook - rateLimit', () => { | |||
| 93 | 93 | ) | |
| 94 | 94 | }) | |
| 95 | 95 | ||
| 96 | + it('uses a static string key as a shared bucket', async () => { | ||
| 97 | + const context: any = { | ||
| 98 | + type: 'before', | ||
| 99 | + method: 'find', | ||
| 100 | + path: 'users', | ||
| 101 | + params: {}, | ||
| 102 | + } | ||
| 103 | + const rateLimiter = new RateLimiterMemory({ points: 1, duration: 1 }) | ||
| 104 | + | ||
| 105 | + // Both requests share the same static bucket, so the second is rejected | ||
| 106 | + await rateLimit(rateLimiter, { key: 'global' })(context) | ||
| 107 | + await expect( | ||
| 108 | + rateLimit(rateLimiter, { key: 'global' })(context), | ||
| 109 | + ).rejects.toThrow('Too many requests') | ||
| 110 | + }) | ||
| 111 | + | ||
| 112 | + it('uses static number points', async () => { | ||
| 113 | + const context: any = { | ||
| 114 | + type: 'before', | ||
| 115 | + method: 'find', | ||
| 116 | + path: 'users', | ||
| 117 | + params: {}, | ||
| 118 | + } | ||
| 119 | + const rateLimiter = new RateLimiterMemory({ points: 1, duration: 1 }) | ||
| 120 | + | ||
| 121 | + // Consuming 2 points against a 1-point limit should fail immediately | ||
| 122 | + await expect( | ||
| 123 | + rateLimit(rateLimiter, { points: 2 })(context), | ||
| 124 | + ).rejects.toThrow('Too many requests') | ||
| 125 | + }) | ||
| 126 | + | ||
| 96 | 127 | it('throws when used in an after hook', async () => { | |
| 97 | 128 | const context: any = { | |
| 98 | 129 | type: 'after', | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -5,10 +5,20 @@ import { checkContext } from '../../utils/index.js' | |||
| 5 | 5 | import type { Promisable } from '../../internal.utils.js' | |
| 6 | 6 | ||
| 7 | 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> | ||
| 8 | + /** | ||
| 9 | + * The rate-limiting key, or a function to derive it from the context. | ||
| 10 | + * Defaults to `context.path`. | ||
| 11 | + * | ||
| 12 | + * Pass a static string to use a single shared bucket (a global rate limit | ||
| 13 | + * across all requests), or a function to compute the key per request | ||
| 14 | + * (e.g. per user or per IP). | ||
| 15 | + */ | ||
| 16 | + key?: string | ((context: H) => Promisable<string>) | ||
| 17 | + /** | ||
| 18 | + * Number of points to consume per request, or a function to compute it from | ||
| 19 | + * the context. Defaults to `1`. | ||
| 20 | + */ | ||
| 21 | + points?: number | ((context: H) => Promisable<number>) | ||
| 12 | 22 | } | |
| 13 | 23 | ||
| 14 | 24 | /** | |
@@ -35,13 +45,14 @@ export const rateLimit = <H extends HookContext = HookContext>( | |||
| 35 | 45 | options?: RateLimitOptions<H>, | |
| 36 | 46 | ) => { | |
| 37 | 47 | const key = options?.key ?? ((context: HookContext) => context.path) | |
| 38 | - const points = options?.points ?? (() => 1) | ||
| 48 | + const points = options?.points ?? 1 | ||
| 39 | 49 | ||
| 40 | 50 | return async (context: H, next?: NextFunction): Promise<void> => { | |
| 41 | 51 | checkContext(context, { type: ['before', 'around'], label: 'rateLimit' }) | |
| 42 | 52 | ||
| 43 | - const resolvedKey = await key(context) | ||
| 44 | - const resolvedPoints = await points(context) | ||
| 53 | + const resolvedKey = typeof key === 'function' ? await key(context) : key | ||
| 54 | + const resolvedPoints = | ||
| 55 | + typeof points === 'function' ? await points(context) : points | ||
| 45 | 56 | ||
| 46 | 57 | try { | |
| 47 | 58 | const res = await rateLimiter.consume(resolvedKey, resolvedPoints) | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -1,5 +1,5 @@ | |||
| 1 | 1 | --- | |
| 2 | - title: passParams | ||
| 2 | + title: gateParams | ||
| 3 | 3 | category: utils | |
| 4 | 4 | see: | |
| 5 | 5 | - hooks/cache | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -1,14 +1,14 @@ | |||
| 1 | 1 | import type { Params } from '@feathersjs/feathers' | |
| 2 | 2 | import { expectTypeOf } from 'vitest' | |
| 3 | - import { passParams } from './pass-params.util.js' | ||
| 3 | + import { gateParams } from './gate-params.util.js' | ||
| 4 | 4 | ||
| 5 | 5 | it('returns a Params object', () => { | |
| 6 | - const out = passParams({ query: {} }, { query: true }) | ||
| 6 | + const out = gateParams({ query: {} }, { query: true }) | ||
| 7 | 7 | expectTypeOf(out).toEqualTypeOf<Params>() | |
| 8 | 8 | }) | |
| 9 | 9 | ||
| 10 | 10 | it('accepts boolean and function rules, including nested paths and custom keys', () => { | |
| 11 | - passParams({ query: {}, user: { id: 1 }, custom: 1 } as Params, { | ||
| 11 | + gateParams({ query: {}, user: { id: 1 }, custom: 1 } as Params, { | ||
| 12 | 12 | query: true, | |
| 13 | 13 | paginate: false, | |
| 14 | 14 | 'user.id': true, | |
@@ -22,7 +22,7 @@ it('accepts boolean and function rules, including nested paths and custom keys', | |||
| 22 | 22 | }) | |
| 23 | 23 | ||
| 24 | 24 | it('types onUnknownParams and dropUnknownParams', () => { | |
| 25 | - passParams( | ||
| 25 | + gateParams( | ||
| 26 | 26 | { query: {} } as Params, | |
| 27 | 27 | { query: true }, | |
| 28 | 28 | { | |
| Back | FazBrowse Home | New Git URL |
0 commit comments