@cqrs-ddd/pipeline-rate-limit 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/COMMERCIAL_LICENSE.txt +34 -0
- package/LICENSE +661 -0
- package/README.md +36 -0
- package/dist/errors/missing-partition.error.d.ts +13 -0
- package/dist/errors/missing-partition.error.d.ts.map +1 -0
- package/dist/errors/missing-partition.error.js +14 -0
- package/dist/errors/missing-partition.error.js.map +1 -0
- package/dist/errors/rate-limit-exceeded.error.d.ts +35 -0
- package/dist/errors/rate-limit-exceeded.error.d.ts.map +1 -0
- package/dist/errors/rate-limit-exceeded.error.js +41 -0
- package/dist/errors/rate-limit-exceeded.error.js.map +1 -0
- package/dist/helpers/build-attributes.d.ts +47 -0
- package/dist/helpers/build-attributes.d.ts.map +1 -0
- package/dist/helpers/build-attributes.js +54 -0
- package/dist/helpers/build-attributes.js.map +1 -0
- package/dist/helpers/build-key.d.ts +23 -0
- package/dist/helpers/build-key.d.ts.map +1 -0
- package/dist/helpers/build-key.js +38 -0
- package/dist/helpers/build-key.js.map +1 -0
- package/dist/helpers/partitioned-key.d.ts +65 -0
- package/dist/helpers/partitioned-key.d.ts.map +1 -0
- package/dist/helpers/partitioned-key.js +61 -0
- package/dist/helpers/partitioned-key.js.map +1 -0
- package/dist/helpers/rate-limit.intent.d.ts +30 -0
- package/dist/helpers/rate-limit.intent.d.ts.map +1 -0
- package/dist/helpers/rate-limit.intent.js +25 -0
- package/dist/helpers/rate-limit.intent.js.map +1 -0
- package/dist/http.d.ts +22 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +27 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/interfaces/rate-limit-options.interface.d.ts +64 -0
- package/dist/interfaces/rate-limit-options.interface.d.ts.map +1 -0
- package/dist/interfaces/rate-limit-options.interface.js +3 -0
- package/dist/interfaces/rate-limit-options.interface.js.map +1 -0
- package/dist/interfaces/rate-limiter.interface.d.ts +35 -0
- package/dist/interfaces/rate-limiter.interface.d.ts.map +1 -0
- package/dist/interfaces/rate-limiter.interface.js +3 -0
- package/dist/interfaces/rate-limiter.interface.js.map +1 -0
- package/dist/rate-limit.behavior.d.ts +74 -0
- package/dist/rate-limit.behavior.d.ts.map +1 -0
- package/dist/rate-limit.behavior.js +188 -0
- package/dist/rate-limit.behavior.js.map +1 -0
- package/package.json +73 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,sEAAsE;AAEtE;;;;GAIG;AAEH,OAAO,EACL,8BAA8B,GAE/B,MAAM,qCAAqC,CAAC;AAC7C,OAAO,EAAE,sBAAsB,EAAE,MAAM,uCAAuC,CAAC;AAC/E,OAAO,EAAE,wBAAwB,EAAE,MAAM,+BAA+B,CAAC;AACzE,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,EACL,oCAAoC,GAGrC,MAAM,8BAA8B,CAAC;AACtC,OAAO,EAEL,SAAS,GACV,MAAM,gCAAgC,CAAC;AAUxC,OAAO,EACL,eAAe,EACf,qBAAqB,EACrB,mBAAmB,EACnB,yBAAyB,EACzB,iBAAiB,GAClB,MAAM,0BAA0B,CAAC"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { IPipelineContext } from '@cqrs-ddd/pipeline';
|
|
2
|
+
import type { RateLimiterLike } from './rate-limiter.interface.js';
|
|
3
|
+
/**
|
|
4
|
+
* Derives the rate-limit bucket key from the pipeline context — e.g. combine
|
|
5
|
+
* `context.requestName` with a tenant or user id to limit per caller.
|
|
6
|
+
*/
|
|
7
|
+
export type RateLimitKeyFactory = (context: IPipelineContext) => string;
|
|
8
|
+
/**
|
|
9
|
+
* Computes the points a request costs from the pipeline context — e.g. one
|
|
10
|
+
* point per item of a bulk command. Must return a non-negative safe integer.
|
|
11
|
+
*/
|
|
12
|
+
export type RateLimitCostFactory = (context: IPipelineContext) => number;
|
|
13
|
+
/**
|
|
14
|
+
* Per-handler (and constructor-default) options for {@link RateLimitBehavior}.
|
|
15
|
+
*
|
|
16
|
+
* @example Tenant + caller scoped limit
|
|
17
|
+
* ```ts
|
|
18
|
+
* const perUser = createPartitionedRateLimitKeyFactory(
|
|
19
|
+
* (ctx) => ctx.items.get('userId') as string | undefined,
|
|
20
|
+
* );
|
|
21
|
+
*
|
|
22
|
+
* class CreateOrderHandler {
|
|
23
|
+
* @pipeline.wrap({ kind: 'command' }, [RateLimitBehavior, {
|
|
24
|
+
* keyFactory: perUser,
|
|
25
|
+
* points: 1,
|
|
26
|
+
* }])
|
|
27
|
+
* async handle(command: CreateOrderCommand) {}
|
|
28
|
+
* }
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export interface RateLimitBehaviorOptions {
|
|
32
|
+
/**
|
|
33
|
+
* Points this request costs: a non-negative safe integer, or a
|
|
34
|
+
* {@link RateLimitCostFactory} computing it per request. `0` charges
|
|
35
|
+
* nothing: the limiter is not called and no key is built. Default `1`.
|
|
36
|
+
*
|
|
37
|
+
* @example One point per imported row
|
|
38
|
+
* ```ts
|
|
39
|
+
* points: (ctx) => (ctx.request as ImportUsersCommand).rows.length
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
points?: number | RateLimitCostFactory;
|
|
43
|
+
/**
|
|
44
|
+
* Builds the bucket key. Required whenever the behavior executes; there is
|
|
45
|
+
* no implicit shared bucket. Prefer `createPartitionedRateLimitKeyFactory`
|
|
46
|
+
* for tenant/caller-aware limits.
|
|
47
|
+
*/
|
|
48
|
+
keyFactory?: RateLimitKeyFactory;
|
|
49
|
+
/** Optional prefix prepended to the key as `"<prefix>:<key>"`. */
|
|
50
|
+
keyPrefix?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Per-handler limiter override — use a stricter/looser limiter for specific
|
|
53
|
+
* handlers without changing the global one. Defaults to the injected limiter.
|
|
54
|
+
*/
|
|
55
|
+
limiter?: RateLimiterLike;
|
|
56
|
+
/**
|
|
57
|
+
* When the backing store itself errors (e.g. Redis is unreachable — distinct
|
|
58
|
+
* from a normal limit hit), allow the request through (`true`, default) or
|
|
59
|
+
* reject it (`false`). Fail-open favors availability; fail-closed favors
|
|
60
|
+
* strict protection.
|
|
61
|
+
*/
|
|
62
|
+
failOpen?: boolean;
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=rate-limit-options.interface.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-limit-options.interface.d.ts","sourceRoot":"","sources":["../../src/interfaces/rate-limit-options.interface.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAC3D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,6BAA6B,CAAC;AAEnE;;;GAGG;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,OAAO,EAAE,gBAAgB,KAAK,MAAM,CAAC;AAExE;;;GAGG;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,OAAO,EAAE,gBAAgB,KAAK,MAAM,CAAC;AAEzE;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,WAAW,wBAAwB;IACvC;;;;;;;;;OASG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG,oBAAoB,CAAC;IACvC;;;;OAIG;IACH,UAAU,CAAC,EAAE,mBAAmB,CAAC;IACjC,kEAAkE;IAClE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,OAAO,CAAC,EAAE,eAAe,CAAC;IAC1B;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-limit-options.interface.js","sourceRoot":"","sources":["../../src/interfaces/rate-limit-options.interface.ts"],"names":[],"mappings":"AAAA,sEAAsE"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal structural shape of a `rate-limiter-flexible` result
|
|
3
|
+
* (`RateLimiterRes`). Declared locally so this package does not hard-depend on
|
|
4
|
+
* `rate-limiter-flexible` — a real result satisfies it.
|
|
5
|
+
*/
|
|
6
|
+
export interface RateLimiterResLike {
|
|
7
|
+
/** Milliseconds until the next point(s) become available. */
|
|
8
|
+
msBeforeNext: number;
|
|
9
|
+
/** Points remaining in the current window after this consumption. */
|
|
10
|
+
remainingPoints: number;
|
|
11
|
+
/** Points consumed so far in the current window. */
|
|
12
|
+
consumedPoints: number;
|
|
13
|
+
/** Whether this consumption started a fresh window. */
|
|
14
|
+
isFirstInDuration: boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Minimal structural shape of a `rate-limiter-flexible` limiter
|
|
18
|
+
* (`RateLimiterAbstract`). Any of its backends —
|
|
19
|
+
* `RateLimiterMemory`, `RateLimiterRedis`, `RateLimiterMongo`,
|
|
20
|
+
* `RateLimiterPostgres`, `RateLimiterMySQL`, … — satisfies this interface, so
|
|
21
|
+
* they are genuine drop-in replacements. Add it in your app:
|
|
22
|
+
* `pnpm add rate-limiter-flexible`.
|
|
23
|
+
*/
|
|
24
|
+
export interface RateLimiterLike {
|
|
25
|
+
/**
|
|
26
|
+
* Consume `points` (default `1`) for `key`. Resolves with the updated
|
|
27
|
+
* {@link RateLimiterResLike} when allowed, or **rejects** with a
|
|
28
|
+
* {@link RateLimiterResLike} when the limit is exceeded (and with a plain
|
|
29
|
+
* `Error` when the backing store itself fails).
|
|
30
|
+
*/
|
|
31
|
+
consume(key: string, points?: number): Promise<RateLimiterResLike>;
|
|
32
|
+
/** The configured point capacity per window, when the limiter exposes it. */
|
|
33
|
+
readonly points?: number;
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=rate-limiter.interface.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-limiter.interface.d.ts","sourceRoot":"","sources":["../../src/interfaces/rate-limiter.interface.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,MAAM,WAAW,kBAAkB;IACjC,6DAA6D;IAC7D,YAAY,EAAE,MAAM,CAAC;IACrB,qEAAqE;IACrE,eAAe,EAAE,MAAM,CAAC;IACxB,oDAAoD;IACpD,cAAc,EAAE,MAAM,CAAC;IACvB,uDAAuD;IACvD,iBAAiB,EAAE,OAAO,CAAC;CAC5B;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,eAAe;IAC9B;;;;;OAKG;IACH,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAAC;IACnE,6EAA6E;IAC7E,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-limiter.interface.js","sourceRoot":"","sources":["../../src/interfaces/rate-limiter.interface.ts"],"names":[],"mappings":"AAAA,sEAAsE"}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { type IPipelineBehavior, type IPipelineBehaviorContract, type IPipelineBehaviorOptionsResolver, type IPipelineContext, type NextDelegate, PIPELINE_BEHAVIOR_CONTRACT, type PipelineItemToken, type PipelineLogger } from '@cqrs-ddd/pipeline';
|
|
2
|
+
import type { RateLimitBehaviorOptions } from './interfaces/rate-limit-options.interface.js';
|
|
3
|
+
import type { RateLimiterLike, RateLimiterResLike } from './interfaces/rate-limiter.interface.js';
|
|
4
|
+
/**
|
|
5
|
+
* Unique symbol key set on `context.items` containing the result of a rate-limit check (or rejection payload).
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* const result = context.items.get(RATE_LIMIT_ITEM);
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
export declare const RATE_LIMIT_ITEM: unique symbol;
|
|
13
|
+
/**
|
|
14
|
+
* Typed token for {@link RATE_LIMIT_ITEM}: the rate-limit result. Reads and writes the same
|
|
15
|
+
* `context.items` entry through `getPipelineItem` / `requirePipelineItem`.
|
|
16
|
+
*/
|
|
17
|
+
export declare const RATE_LIMIT_ITEM_TOKEN: PipelineItemToken<RateLimiterResLike>;
|
|
18
|
+
/**
|
|
19
|
+
* Unique symbol key set on `context.items` containing the resolved rate limit bucket key string.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```ts
|
|
23
|
+
* const key = context.items.get(RATE_LIMIT_KEY_ITEM) as string | undefined;
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export declare const RATE_LIMIT_KEY_ITEM: unique symbol;
|
|
27
|
+
/**
|
|
28
|
+
* Typed token for {@link RATE_LIMIT_KEY_ITEM}: the resolved rate-limit key. Reads and writes the same
|
|
29
|
+
* `context.items` entry through `getPipelineItem` / `requirePipelineItem`.
|
|
30
|
+
*/
|
|
31
|
+
export declare const RATE_LIMIT_KEY_ITEM_TOKEN: PipelineItemToken<string>;
|
|
32
|
+
/**
|
|
33
|
+
* Pipeline behavior that enforces rate limits before a handler runs.
|
|
34
|
+
*
|
|
35
|
+
* For each request it consumes `points` (default `1`, or computed per request
|
|
36
|
+
* when `points` is a function) from a bucket keyed by
|
|
37
|
+
* {@link buildRateLimitKey}. A cost of `0` charges nothing and skips the
|
|
38
|
+
* limiter. If the bucket is exhausted it throws
|
|
39
|
+
* {@link RateLimitExceededError} (map to HTTP 429 with
|
|
40
|
+
* `toHttpResponse` of `@cqrs-ddd/pipeline-rate-limit/http`); otherwise the handler proceeds.
|
|
41
|
+
*
|
|
42
|
+
* `context.request` is the CQRS command/query/event, not an Express/Fastify
|
|
43
|
+
* request. Transport metadata needed for keying should be copied into the CQRS
|
|
44
|
+
* request or written to `context.items` by an earlier behavior.
|
|
45
|
+
*
|
|
46
|
+
* Backend-agnostic: it depends only on {@link RateLimiterLike}, so any
|
|
47
|
+
* `rate-limiter-flexible` backend (memory, Redis/Valkey, Mongo, SQL) is a
|
|
48
|
+
* one-line swap in the constructor.
|
|
49
|
+
*
|
|
50
|
+
* @example Per-handler limit, partitioned by tenant and authenticated caller
|
|
51
|
+
* ```ts
|
|
52
|
+
* const perUser = createPartitionedRateLimitKeyFactory(
|
|
53
|
+
* (ctx) => ctx.items.get('userId') as string | undefined,
|
|
54
|
+
* );
|
|
55
|
+
*
|
|
56
|
+
* class CreateUserHandler {
|
|
57
|
+
* @pipeline.wrap({ kind: 'command' }, [RateLimitBehavior, { points: 1, keyFactory: perUser }])
|
|
58
|
+
* async handle(command: CreateUserCommand) {}
|
|
59
|
+
* }
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
export declare class RateLimitBehavior implements IPipelineBehavior, IPipelineBehaviorOptionsResolver<RateLimitBehaviorOptions> {
|
|
63
|
+
private readonly limiter;
|
|
64
|
+
static readonly [PIPELINE_BEHAVIOR_CONTRACT]: IPipelineBehaviorContract;
|
|
65
|
+
private readonly logger;
|
|
66
|
+
private readonly defaults;
|
|
67
|
+
constructor(limiter: RateLimiterLike, defaults?: RateLimitBehaviorOptions, logger?: PipelineLogger);
|
|
68
|
+
handle(context: IPipelineContext, next: NextDelegate): Promise<unknown>;
|
|
69
|
+
/** Fail-open (default) or fail-closed when the backing store itself errors. */
|
|
70
|
+
private handleStoreError;
|
|
71
|
+
/** Shallow-merges pipeline-level options over the constructor defaults. */
|
|
72
|
+
resolveEffectiveOptions(options?: RateLimitBehaviorOptions): RateLimitBehaviorOptions;
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=rate-limit.behavior.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-limit.behavior.d.ts","sourceRoot":"","sources":["../src/rate-limit.behavior.ts"],"names":[],"mappings":"AAEA,OAAO,EAEL,KAAK,iBAAiB,EACtB,KAAK,yBAAyB,EAC9B,KAAK,gCAAgC,EACrC,KAAK,gBAAgB,EACrB,KAAK,YAAY,EACjB,0BAA0B,EAG1B,KAAK,iBAAiB,EACtB,KAAK,cAAc,EAEpB,MAAM,oBAAoB,CAAC;AAG5B,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,8CAA8C,CAAC;AAC7F,OAAO,KAAK,EACV,eAAe,EACf,kBAAkB,EACnB,MAAM,wCAAwC,CAAC;AAEhD;;;;;;;GAOG;AACH,eAAO,MAAM,eAAe,eAA4B,CAAC;AAEzD;;;GAGG;AACH,eAAO,MAAM,qBAAqB,EAAE,iBAAiB,CAAC,kBAAkB,CACI,CAAC;AAE7E;;;;;;;GAOG;AACH,eAAO,MAAM,mBAAmB,eAAgC,CAAC;AAEjE;;;GAGG;AACH,eAAO,MAAM,yBAAyB,EAAE,iBAAiB,CAAC,MAAM,CACQ,CAAC;AAYzE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,qBAAa,iBACX,YACE,iBAAiB,EACjB,gCAAgC,CAAC,wBAAwB,CAAC;IA2D1D,OAAO,CAAC,QAAQ,CAAC,OAAO;IAzD1B,MAAM,CAAC,QAAQ,CAAC,CAAC,0BAA0B,CAAC,EAAE,yBAAyB,CAmDrE;IAEF,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiB;IACxC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA2B;IAEpD,YACmB,OAAO,EAAE,eAAe,EACzC,QAAQ,CAAC,EAAE,wBAAwB,EACnC,MAAM,CAAC,EAAE,cAAc,EAUxB;IAEK,MAAM,CACV,OAAO,EAAE,gBAAgB,EACzB,IAAI,EAAE,YAAY,GACjB,OAAO,CAAC,OAAO,CAAC,CAgClB;IAED,+EAA+E;IAC/E,OAAO,CAAC,gBAAgB;IA0BxB,2EAA2E;IAC3E,uBAAuB,CACrB,OAAO,CAAC,EAAE,wBAAwB,GACjC,wBAAwB,CAG1B;CACF"}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/* Copyright (C) 2026-present Aristotelis — see repository license. */
|
|
2
|
+
import { createPipelineItem, PIPELINE_BEHAVIOR_CONTRACT, setPipelineItem, } from '@cqrs-ddd/pipeline';
|
|
3
|
+
import { RateLimitExceededError } from './errors/rate-limit-exceeded.error.js';
|
|
4
|
+
import { buildRateLimitKey } from './helpers/build-key.js';
|
|
5
|
+
/**
|
|
6
|
+
* Unique symbol key set on `context.items` containing the result of a rate-limit check (or rejection payload).
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* const result = context.items.get(RATE_LIMIT_ITEM);
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
export const RATE_LIMIT_ITEM = Symbol('RATE_LIMIT_ITEM');
|
|
14
|
+
/**
|
|
15
|
+
* Typed token for {@link RATE_LIMIT_ITEM}: the rate-limit result. Reads and writes the same
|
|
16
|
+
* `context.items` entry through `getPipelineItem` / `requirePipelineItem`.
|
|
17
|
+
*/
|
|
18
|
+
export const RATE_LIMIT_ITEM_TOKEN = createPipelineItem('RATE_LIMIT_ITEM', RATE_LIMIT_ITEM);
|
|
19
|
+
/**
|
|
20
|
+
* Unique symbol key set on `context.items` containing the resolved rate limit bucket key string.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* const key = context.items.get(RATE_LIMIT_KEY_ITEM) as string | undefined;
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export const RATE_LIMIT_KEY_ITEM = Symbol('RATE_LIMIT_KEY_ITEM');
|
|
28
|
+
/**
|
|
29
|
+
* Typed token for {@link RATE_LIMIT_KEY_ITEM}: the resolved rate-limit key. Reads and writes the same
|
|
30
|
+
* `context.items` entry through `getPipelineItem` / `requirePipelineItem`.
|
|
31
|
+
*/
|
|
32
|
+
export const RATE_LIMIT_KEY_ITEM_TOKEN = createPipelineItem('RATE_LIMIT_KEY_ITEM', RATE_LIMIT_KEY_ITEM);
|
|
33
|
+
/** Whether a rejection value is a `rate-limiter-flexible` result (a limit hit). */
|
|
34
|
+
function isRateLimiterRes(value) {
|
|
35
|
+
return (typeof value === 'object' &&
|
|
36
|
+
value !== null &&
|
|
37
|
+
typeof value.msBeforeNext === 'number' &&
|
|
38
|
+
typeof value.remainingPoints === 'number');
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Pipeline behavior that enforces rate limits before a handler runs.
|
|
42
|
+
*
|
|
43
|
+
* For each request it consumes `points` (default `1`, or computed per request
|
|
44
|
+
* when `points` is a function) from a bucket keyed by
|
|
45
|
+
* {@link buildRateLimitKey}. A cost of `0` charges nothing and skips the
|
|
46
|
+
* limiter. If the bucket is exhausted it throws
|
|
47
|
+
* {@link RateLimitExceededError} (map to HTTP 429 with
|
|
48
|
+
* `toHttpResponse` of `@cqrs-ddd/pipeline-rate-limit/http`); otherwise the handler proceeds.
|
|
49
|
+
*
|
|
50
|
+
* `context.request` is the CQRS command/query/event, not an Express/Fastify
|
|
51
|
+
* request. Transport metadata needed for keying should be copied into the CQRS
|
|
52
|
+
* request or written to `context.items` by an earlier behavior.
|
|
53
|
+
*
|
|
54
|
+
* Backend-agnostic: it depends only on {@link RateLimiterLike}, so any
|
|
55
|
+
* `rate-limiter-flexible` backend (memory, Redis/Valkey, Mongo, SQL) is a
|
|
56
|
+
* one-line swap in the constructor.
|
|
57
|
+
*
|
|
58
|
+
* @example Per-handler limit, partitioned by tenant and authenticated caller
|
|
59
|
+
* ```ts
|
|
60
|
+
* const perUser = createPartitionedRateLimitKeyFactory(
|
|
61
|
+
* (ctx) => ctx.items.get('userId') as string | undefined,
|
|
62
|
+
* );
|
|
63
|
+
*
|
|
64
|
+
* class CreateUserHandler {
|
|
65
|
+
* @pipeline.wrap({ kind: 'command' }, [RateLimitBehavior, { points: 1, keyFactory: perUser }])
|
|
66
|
+
* async handle(command: CreateUserCommand) {}
|
|
67
|
+
* }
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export class RateLimitBehavior {
|
|
71
|
+
limiter;
|
|
72
|
+
static [PIPELINE_BEHAVIOR_CONTRACT] = {
|
|
73
|
+
validate: (context) => {
|
|
74
|
+
const options = context.effectiveOptions;
|
|
75
|
+
if (!options?.keyFactory) {
|
|
76
|
+
return [
|
|
77
|
+
{
|
|
78
|
+
handlerName: context.handlerName,
|
|
79
|
+
behaviorName: RateLimitBehavior.name,
|
|
80
|
+
message: 'Active RateLimitBehavior requires an explicit `keyFactory`',
|
|
81
|
+
fix: 'Provide keyFactory in the [RateLimitBehavior, { keyFactory: ... }] entry, ' +
|
|
82
|
+
'the constructor defaults, or use createPartitionedRateLimitKeyFactory(...).',
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
if (typeof options.keyFactory !== 'function') {
|
|
87
|
+
return [
|
|
88
|
+
{
|
|
89
|
+
handlerName: context.handlerName,
|
|
90
|
+
behaviorName: RateLimitBehavior.name,
|
|
91
|
+
message: `RateLimitBehavior keyFactory must be a callable function, received ${typeof options.keyFactory}`,
|
|
92
|
+
fix: 'Pass a valid function (ctx) => string to the keyFactory option of the [RateLimitBehavior, { keyFactory: ... }] entry.',
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
}
|
|
96
|
+
const { points } = options;
|
|
97
|
+
if (points !== undefined &&
|
|
98
|
+
typeof points !== 'function' &&
|
|
99
|
+
!isValidPoints(points)) {
|
|
100
|
+
return [
|
|
101
|
+
{
|
|
102
|
+
handlerName: context.handlerName,
|
|
103
|
+
behaviorName: RateLimitBehavior.name,
|
|
104
|
+
message: `RateLimitBehavior points must be a non-negative safe integer or a function, received ${String(points)}`,
|
|
105
|
+
fix: 'Pass an integer from 0 up (0 charges nothing) or a function (ctx) => number as the points option.',
|
|
106
|
+
},
|
|
107
|
+
];
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
logger;
|
|
113
|
+
defaults;
|
|
114
|
+
constructor(limiter, defaults, logger) {
|
|
115
|
+
this.limiter = limiter;
|
|
116
|
+
if (typeof limiter?.consume !== 'function') {
|
|
117
|
+
throw new TypeError('RateLimitBehavior requires a rate limiter with a consume() method, such as RateLimiterMemory of rate-limiter-flexible.');
|
|
118
|
+
}
|
|
119
|
+
this.defaults = defaults ?? {};
|
|
120
|
+
this.logger = logger ?? console;
|
|
121
|
+
}
|
|
122
|
+
async handle(context, next) {
|
|
123
|
+
const options = this.resolveEffectiveOptions(context.getBehaviorOptions(RateLimitBehavior));
|
|
124
|
+
const points = resolvePoints(context, options);
|
|
125
|
+
if (points === 0)
|
|
126
|
+
return next();
|
|
127
|
+
const limiter = options.limiter ?? this.limiter;
|
|
128
|
+
const key = buildRateLimitKey(context, options);
|
|
129
|
+
setPipelineItem(context, RATE_LIMIT_KEY_ITEM_TOKEN, key);
|
|
130
|
+
let result;
|
|
131
|
+
try {
|
|
132
|
+
result = await limiter.consume(key, points);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
if (isRateLimiterRes(error)) {
|
|
136
|
+
setPipelineItem(context, RATE_LIMIT_ITEM_TOKEN, error);
|
|
137
|
+
throw new RateLimitExceededError({
|
|
138
|
+
key,
|
|
139
|
+
requestName: context.requestName,
|
|
140
|
+
msBeforeNext: error.msBeforeNext,
|
|
141
|
+
remainingPoints: error.remainingPoints,
|
|
142
|
+
limit: limiter.points,
|
|
143
|
+
points,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return this.handleStoreError(context, options, key, error, next);
|
|
147
|
+
}
|
|
148
|
+
setPipelineItem(context, RATE_LIMIT_ITEM_TOKEN, result);
|
|
149
|
+
return next();
|
|
150
|
+
}
|
|
151
|
+
/** Fail-open (default) or fail-closed when the backing store itself errors. */
|
|
152
|
+
handleStoreError(context, options, key, error, next) {
|
|
153
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
154
|
+
if (options.failOpen ?? true) {
|
|
155
|
+
this.logger.warn?.(`Rate limiter store error for ${context.requestName} ` +
|
|
156
|
+
`(key: ${key}); failing open: ${message}`, RateLimitBehavior.name);
|
|
157
|
+
return next();
|
|
158
|
+
}
|
|
159
|
+
this.logger.error?.(`Rate limiter store error for ${context.requestName} ` +
|
|
160
|
+
`(key: ${key}); failing closed: ${message}`, RateLimitBehavior.name);
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
/** Shallow-merges pipeline-level options over the constructor defaults. */
|
|
164
|
+
resolveEffectiveOptions(options) {
|
|
165
|
+
if (!options)
|
|
166
|
+
return this.defaults;
|
|
167
|
+
return { ...this.defaults, ...options };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function isValidPoints(points) {
|
|
171
|
+
return Number.isSafeInteger(points) && points >= 0;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Resolves the request's cost: the `points` option, computed when it is a
|
|
175
|
+
* function, `1` when omitted.
|
|
176
|
+
*
|
|
177
|
+
* @throws {TypeError} When the cost is not a non-negative safe integer.
|
|
178
|
+
*/
|
|
179
|
+
function resolvePoints(context, options) {
|
|
180
|
+
const points = typeof options.points === 'function'
|
|
181
|
+
? options.points(context)
|
|
182
|
+
: (options.points ?? 1);
|
|
183
|
+
if (!isValidPoints(points)) {
|
|
184
|
+
throw new TypeError(`Rate-limit points for ${context.handlerName} must be a non-negative safe integer, received ${String(points)}.`);
|
|
185
|
+
}
|
|
186
|
+
return points;
|
|
187
|
+
}
|
|
188
|
+
//# sourceMappingURL=rate-limit.behavior.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-limit.behavior.js","sourceRoot":"","sources":["../src/rate-limit.behavior.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cqrs-ddd/pipeline-rate-limit",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Rate limiting for @cqrs-ddd/pipeline, with keys partitioned by tenant, principal and operation",
|
|
5
|
+
"author": "Aristotelis",
|
|
6
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=22.12.0"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"pipeline",
|
|
13
|
+
"cqrs",
|
|
14
|
+
"rate-limit",
|
|
15
|
+
"rate-limiter",
|
|
16
|
+
"throttle",
|
|
17
|
+
"rate-limiter-flexible",
|
|
18
|
+
"redis",
|
|
19
|
+
"ddos",
|
|
20
|
+
"behavior"
|
|
21
|
+
],
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/aristoteliss/ddd-cqrs.git",
|
|
25
|
+
"directory": "packages/pipeline-rate-limit"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://aristoteliss.github.io/ddd-cqrs/packages/pipeline-rate-limit/",
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/aristoteliss/ddd-cqrs/issues"
|
|
30
|
+
},
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"default": "./dist/index.js"
|
|
35
|
+
},
|
|
36
|
+
"./http": {
|
|
37
|
+
"types": "./dist/http.d.ts",
|
|
38
|
+
"default": "./dist/http.js"
|
|
39
|
+
},
|
|
40
|
+
"./package.json": "./package.json"
|
|
41
|
+
},
|
|
42
|
+
"main": "dist/index.js",
|
|
43
|
+
"types": "dist/index.d.ts",
|
|
44
|
+
"files": [
|
|
45
|
+
"dist",
|
|
46
|
+
"LICENSE",
|
|
47
|
+
"COMMERCIAL_LICENSE.txt"
|
|
48
|
+
],
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"@cqrs-ddd/pipeline": "^0.5.0"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@vitest/coverage-v8": "^5.0.3",
|
|
57
|
+
"rate-limiter-flexible": "^11.2.1",
|
|
58
|
+
"vitest": "^5.0.3",
|
|
59
|
+
"@cqrs-ddd/pipeline": "0.5.0"
|
|
60
|
+
},
|
|
61
|
+
"dependencies": {
|
|
62
|
+
"@cqrs-ddd/safe-stringify": "^0.5.0"
|
|
63
|
+
},
|
|
64
|
+
"scripts": {
|
|
65
|
+
"build": "tsc -p tsconfig.build.json",
|
|
66
|
+
"build:watch": "tsc -p tsconfig.build.json --watch",
|
|
67
|
+
"clean": "rm -rf dist tsconfig.tsbuildinfo",
|
|
68
|
+
"rebuild": "pnpm run clean && pnpm run build",
|
|
69
|
+
"lint": "tsc --noEmit",
|
|
70
|
+
"test": "vitest run",
|
|
71
|
+
"test:watch": "vitest"
|
|
72
|
+
}
|
|
73
|
+
}
|