@fluojs/throttler 1.0.0-beta.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 fluo contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.ko.md ADDED
@@ -0,0 +1,135 @@
1
+ # @fluojs/throttler
2
+
3
+ <p><a href="./README.md"><kbd>English</kbd></a> <strong><kbd>한국어</kbd></strong></p>
4
+
5
+ 메모리 내(In-memory) 및 Redis 저장소 어댑터를 지원하는 fluo 애플리케이션용 데코레이터 기반 속도 제한(Rate Limiting) 패키지입니다.
6
+
7
+ ## 목차
8
+
9
+ - [설치](#설치)
10
+ - [사용 시점](#사용-시점)
11
+ - [빠른 시작](#빠른-시작)
12
+ - [공통 패턴](#공통-패턴)
13
+ - [Redis 저장소 사용](#redis-저장소-사용)
14
+ - [커스텀 키 생성](#커스텀-키-생성)
15
+ - [공개 API 개요](#공개-api-개요)
16
+ - [관련 패키지](#관련-패키지)
17
+ - [예제 소스](#예제-소스)
18
+
19
+ ## 설치
20
+
21
+ ```bash
22
+ npm install @fluojs/throttler
23
+ ```
24
+
25
+ ## 사용 시점
26
+
27
+ - 로그인, 회원가입 등 민감한 엔드포인트에 대한 브루트 포스 공격을 방지하고 싶을 때 사용합니다.
28
+ - 단일 클라이언트의 과도한 요청으로부터 API 서버를 보호하고 싶을 때 적합합니다.
29
+ - 사용자 유형별로 사용량 할당량이나 계층화된 속도 제한을 구현할 때 사용합니다.
30
+ - 컨트롤러나 메서드에 데코레이터를 사용하여 간편하게 속도 제한을 적용하고 싶을 때 사용합니다.
31
+
32
+ ## 빠른 시작
33
+
34
+ `ThrottlerModule`을 등록하고 컨트롤러나 메서드에 `Throttle` 데코레이터를 적용합니다.
35
+
36
+ ```typescript
37
+ import { Module } from '@fluojs/core';
38
+ import { ThrottlerModule, Throttle, SkipThrottle } from '@fluojs/throttler';
39
+ import { Controller, Post } from '@fluojs/http';
40
+
41
+ @Module({
42
+ imports: [
43
+ ThrottlerModule.forRoot({
44
+ ttl: 60, // 60초
45
+ limit: 10, // 10회 요청
46
+ }),
47
+ ],
48
+ })
49
+ class AppModule {}
50
+
51
+ @Controller('/auth')
52
+ class AuthController {
53
+ @Post('/login')
54
+ @Throttle({ ttl: 60, limit: 5 }) // 오버라이드: 분당 5회 요청
55
+ login() {
56
+ return { success: true };
57
+ }
58
+
59
+ @Post('/public-info')
60
+ @SkipThrottle() // 속도 제한 제외
61
+ getInfo() {
62
+ return { info: '...' };
63
+ }
64
+ }
65
+ ```
66
+
67
+ ## 공통 패턴
68
+
69
+ ### Redis 저장소 사용
70
+
71
+ 다중 인스턴스 배포 환경에서는 `RedisThrottlerStore`를 사용하여 모든 인스턴스 간에 속도 제한 상태를 공유하세요.
72
+
73
+ ```typescript
74
+ import { ThrottlerModule, RedisThrottlerStore } from '@fluojs/throttler';
75
+ import { REDIS_CLIENT } from '@fluojs/redis';
76
+
77
+ // 프로바이더 또는 모듈 팩토리 내부에서
78
+ const redisStore = new RedisThrottlerStore(redisClient);
79
+
80
+ ThrottlerModule.forRoot({
81
+ ttl: 60,
82
+ limit: 100,
83
+ store: redisStore,
84
+ });
85
+ ```
86
+
87
+ ### 커스텀 키 생성
88
+
89
+ 기본적으로 throttler는 raw socket `remoteAddress`만으로 클라이언트 식별자를 해석합니다. 배포가 `Forwarded`, `X-Forwarded-For`, `X-Real-IP`를 덮어쓰는 신뢰 가능한 리버스 프록시 뒤에 있다면 `trustProxyHeaders: true`로 명시적으로 opt-in 하세요. 신뢰 가능한 소켓 식별자나 프록시 식별자가 없으면 서로 다른 호출자를 같은 버킷으로 합치지 않도록 예외를 던집니다. API 키나 사용자 ID 등 다른 식별자를 사용하도록 커스터마이징할 수도 있습니다.
90
+
91
+ ```typescript
92
+ ThrottlerModule.forRoot({
93
+ ttl: 60,
94
+ limit: 100,
95
+ trustProxyHeaders: true,
96
+ });
97
+ ```
98
+
99
+ ```typescript
100
+ ThrottlerModule.forRoot({
101
+ ttl: 60,
102
+ limit: 100,
103
+ keyGenerator: (context) => {
104
+ const request = context.switchToHttp().getRequest();
105
+ return request.headers['x-api-key'] || request.ip;
106
+ },
107
+ });
108
+ ```
109
+
110
+ ## 공개 API 개요
111
+
112
+ ### 모듈
113
+ - `ThrottlerModule.forRoot(options)`: 글로벌 속도 제한 동작 및 저장소를 설정합니다.
114
+ - 패키지 수준 등록은 `ThrottlerModule.forRoot(options)`를 통해 지원합니다. 내부 프로바이더 조합 헬퍼는 공개 계약에 포함되지 않습니다.
115
+
116
+ ### 데코레이터
117
+ - `@Throttle({ ttl, limit })`: 클래스나 메서드에 특정 속도 제한을 설정합니다.
118
+ - `@SkipThrottle()`: 클래스나 메서드에 대해 속도 제한을 비활성화합니다.
119
+
120
+ ### 가드
121
+ - `ThrottlerGuard`: 속도 제한을 강제하는 가드입니다. `ThrottlerModule.forRoot()` 사용 시 자동으로 등록됩니다.
122
+
123
+ ### 저장소(Store)
124
+ - `createMemoryThrottlerStore()`: 간단한 메모리 내 저장소를 생성합니다 (기본값).
125
+ - `RedisThrottlerStore`: Redis용 저장소 어댑터입니다.
126
+
127
+ ## 관련 패키지
128
+
129
+ - `@fluojs/http`: HTTP 컨텍스트 및 예외 처리를 위해 필요합니다.
130
+ - `@fluojs/redis`: `RedisThrottlerStore` 사용 시 필요합니다.
131
+
132
+ ## 예제 소스
133
+
134
+ - `packages/throttler/src/module.test.ts`: 모듈 설정 및 데코레이터 오버라이드 테스트.
135
+ - `packages/throttler/src/guard.ts`: 요청 제한 및 헤더 관리 코어 로직.
package/README.md ADDED
@@ -0,0 +1,135 @@
1
+ # @fluojs/throttler
2
+
3
+ <p><strong><kbd>English</kbd></strong> <a href="./README.ko.md"><kbd>한국어</kbd></a></p>
4
+
5
+ Decorator-based rate limiting for fluo applications with in-memory and Redis store adapters.
6
+
7
+ ## Table of Contents
8
+
9
+ - [Installation](#installation)
10
+ - [When to Use](#when-to-use)
11
+ - [Quick Start](#quick-start)
12
+ - [Common Patterns](#common-patterns)
13
+ - [Redis Storage](#redis-storage)
14
+ - [Custom Key Generation](#custom-key-generation)
15
+ - [Public API Overview](#public-api-overview)
16
+ - [Related Packages](#related-packages)
17
+ - [Example Sources](#example-sources)
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ npm install @fluojs/throttler
23
+ ```
24
+
25
+ ## When to Use
26
+
27
+ - To prevent brute-force attacks on sensitive endpoints (e.g., login, registration).
28
+ - To protect your API from being overwhelmed by too many requests from a single client.
29
+ - To implement usage quotas or tiered rate limits for different types of users.
30
+ - When you need a simple way to apply rate limits using decorators on controllers or methods.
31
+
32
+ ## Quick Start
33
+
34
+ Register the `ThrottlerModule` and apply the `Throttle` decorator to your controllers or methods.
35
+
36
+ ```typescript
37
+ import { Module } from '@fluojs/core';
38
+ import { ThrottlerModule, Throttle, SkipThrottle } from '@fluojs/throttler';
39
+ import { Controller, Post } from '@fluojs/http';
40
+
41
+ @Module({
42
+ imports: [
43
+ ThrottlerModule.forRoot({
44
+ ttl: 60, // 60 seconds
45
+ limit: 10, // 10 requests
46
+ }),
47
+ ],
48
+ })
49
+ class AppModule {}
50
+
51
+ @Controller('/auth')
52
+ class AuthController {
53
+ @Post('/login')
54
+ @Throttle({ ttl: 60, limit: 5 }) // Override: 5 requests per minute
55
+ login() {
56
+ return { success: true };
57
+ }
58
+
59
+ @Post('/public-info')
60
+ @SkipThrottle() // Bypass throttling
61
+ getInfo() {
62
+ return { info: '...' };
63
+ }
64
+ }
65
+ ```
66
+
67
+ ## Common Patterns
68
+
69
+ ### Redis Storage
70
+
71
+ For multi-instance deployments, use `RedisThrottlerStore` to share the rate limit state across all instances.
72
+
73
+ ```typescript
74
+ import { ThrottlerModule, RedisThrottlerStore } from '@fluojs/throttler';
75
+ import { REDIS_CLIENT } from '@fluojs/redis';
76
+
77
+ // Inside a provider or module factory
78
+ const redisStore = new RedisThrottlerStore(redisClient);
79
+
80
+ ThrottlerModule.forRoot({
81
+ ttl: 60,
82
+ limit: 100,
83
+ store: redisStore,
84
+ });
85
+ ```
86
+
87
+ ### Custom Key Generation
88
+
89
+ By default, the throttler resolves client identity from the raw socket `remoteAddress` only. If your deployment sits behind a trusted reverse proxy that rewrites `Forwarded`, `X-Forwarded-For`, or `X-Real-IP`, opt in with `trustProxyHeaders: true`. If no trusted socket or proxy identity is available, it throws instead of collapsing unrelated callers into a shared bucket. You can also customize this to use API keys, user IDs, or other identifiers.
90
+
91
+ ```typescript
92
+ ThrottlerModule.forRoot({
93
+ ttl: 60,
94
+ limit: 100,
95
+ trustProxyHeaders: true,
96
+ });
97
+ ```
98
+
99
+ ```typescript
100
+ ThrottlerModule.forRoot({
101
+ ttl: 60,
102
+ limit: 100,
103
+ keyGenerator: (context) => {
104
+ const request = context.switchToHttp().getRequest();
105
+ return request.headers['x-api-key'] || request.ip;
106
+ },
107
+ });
108
+ ```
109
+
110
+ ## Public API Overview
111
+
112
+ ### Modules
113
+ - `ThrottlerModule.forRoot(options)`: Configures the global throttling behavior and storage.
114
+ - Package-level registration is supported through `ThrottlerModule.forRoot(options)`. Internal provider-composition helpers are not part of the public contract.
115
+
116
+ ### Decorators
117
+ - `@Throttle({ ttl, limit })`: Sets a specific rate limit for a class or method.
118
+ - `@SkipThrottle()`: Disables throttling for a class or method.
119
+
120
+ ### Guards
121
+ - `ThrottlerGuard`: The guard responsible for enforcing the rate limits. Automatically registered when using `ThrottlerModule.forRoot()`.
122
+
123
+ ### Stores
124
+ - `createMemoryThrottlerStore()`: Creates a simple in-memory store (default).
125
+ - `RedisThrottlerStore`: Store adapter for Redis.
126
+
127
+ ## Related Packages
128
+
129
+ - `@fluojs/http`: Required for HTTP context and Exception handling.
130
+ - `@fluojs/redis`: Required when using `RedisThrottlerStore`.
131
+
132
+ ## Example Sources
133
+
134
+ - `packages/throttler/src/module.test.ts`: Tests for module configuration and decorator overrides.
135
+ - `packages/throttler/src/guard.ts`: The core logic for request throttling and header management.
@@ -0,0 +1,50 @@
1
+ import type { ThrottlerHandlerOptions } from './types.js';
2
+ /** Shared controller metadata key used to store per-route throttling metadata records. */
3
+ export declare const throttleRouteMetadataKey: unique symbol;
4
+ type StandardMetadataBag = Record<PropertyKey, unknown>;
5
+ type StandardMethodDecoratorFn = (value: Function, context: ClassMethodDecoratorContext) => void;
6
+ type StandardClassDecoratorFn = (value: Function, context: ClassDecoratorContext) => void;
7
+ type ClassOrMethodDecoratorLike = StandardClassDecoratorFn & StandardMethodDecoratorFn;
8
+ /**
9
+ * Override throttling policy for a controller class or handler method.
10
+ *
11
+ * @param options Rate-limit window and request cap for the decorated target.
12
+ * @returns A decorator that stores throttling metadata on the class or method.
13
+ */
14
+ export declare function Throttle(options: ThrottlerHandlerOptions): ClassOrMethodDecoratorLike;
15
+ /**
16
+ * Disable throttling for a controller class or handler method.
17
+ *
18
+ * @returns A decorator that marks the target as exempt from `ThrottlerGuard`.
19
+ */
20
+ export declare function SkipThrottle(): ClassOrMethodDecoratorLike;
21
+ /**
22
+ * Read method-level throttle metadata from a metadata bag.
23
+ *
24
+ * @param bag Route-level metadata bag captured from the controller.
25
+ * @returns A defensive copy of the stored throttle options, if present.
26
+ */
27
+ export declare function getThrottleMetadata(bag: StandardMetadataBag): ThrottlerHandlerOptions | undefined;
28
+ /**
29
+ * Read method-level skip metadata from a metadata bag.
30
+ *
31
+ * @param bag Route-level metadata bag captured from the controller.
32
+ * @returns `true` when throttling should be skipped for the handler.
33
+ */
34
+ export declare function getSkipThrottleMetadata(bag: StandardMetadataBag): boolean;
35
+ /**
36
+ * Read class-level throttle metadata from a metadata bag.
37
+ *
38
+ * @param bag Controller metadata bag.
39
+ * @returns A defensive copy of the stored throttle options, if present.
40
+ */
41
+ export declare function getClassThrottleMetadata(bag: StandardMetadataBag): ThrottlerHandlerOptions | undefined;
42
+ /**
43
+ * Read class-level skip metadata from a metadata bag.
44
+ *
45
+ * @param bag Controller metadata bag.
46
+ * @returns `true` when throttling should be skipped for the controller.
47
+ */
48
+ export declare function getClassSkipThrottleMetadata(bag: StandardMetadataBag): boolean;
49
+ export {};
50
+ //# sourceMappingURL=decorators.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"decorators.d.ts","sourceRoot":"","sources":["../src/decorators.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAG1D,0FAA0F;AAC1F,eAAO,MAAM,wBAAwB,eAAoC,CAAC;AAM1E,KAAK,mBAAmB,GAAG,MAAM,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC;AACxD,KAAK,yBAAyB,GAAG,CAAC,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,2BAA2B,KAAK,IAAI,CAAC;AACjG,KAAK,wBAAwB,GAAG,CAAC,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,qBAAqB,KAAK,IAAI,CAAC;AAC1F,KAAK,0BAA0B,GAAG,wBAAwB,GAAG,yBAAyB,CAAC;AAgCvF;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,OAAO,EAAE,uBAAuB,GAAG,0BAA0B,CAUrF;AAED;;;;GAIG;AACH,wBAAgB,YAAY,IAAI,0BAA0B,CAUzD;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,mBAAmB,GAAG,uBAAuB,GAAG,SAAS,CAGjG;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,mBAAmB,GAAG,OAAO,CAEzE;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,mBAAmB,GAAG,uBAAuB,GAAG,SAAS,CAGtG;AAED;;;;;GAKG;AACH,wBAAgB,4BAA4B,CAAC,GAAG,EAAE,mBAAmB,GAAG,OAAO,CAE9E"}
@@ -0,0 +1,106 @@
1
+ import { validateThrottleOptions } from './validation.js';
2
+
3
+ /** Shared controller metadata key used to store per-route throttling metadata records. */
4
+ export const throttleRouteMetadataKey = Symbol.for('fluo.standard.route');
5
+ const throttleKey = Symbol.for('fluo.throttler.throttle');
6
+ const skipThrottleKey = Symbol.for('fluo.throttler.skip');
7
+ const classThrottleKey = Symbol.for('fluo.throttler.class-throttle');
8
+ const classSkipThrottleKey = Symbol.for('fluo.throttler.class-skip');
9
+ function getMetadataBag(metadata) {
10
+ return metadata;
11
+ }
12
+ function cloneThrottleOptions(options) {
13
+ return validateThrottleOptions({
14
+ limit: options.limit,
15
+ ttl: options.ttl
16
+ });
17
+ }
18
+ function getRouteRecord(metadata, name) {
19
+ const bag = getMetadataBag(metadata);
20
+ let routeMap = bag[throttleRouteMetadataKey];
21
+ if (!routeMap) {
22
+ routeMap = new Map();
23
+ bag[throttleRouteMetadataKey] = routeMap;
24
+ }
25
+ let record = routeMap.get(name);
26
+ if (!record) {
27
+ record = {};
28
+ routeMap.set(name, record);
29
+ }
30
+ return record;
31
+ }
32
+
33
+ /**
34
+ * Override throttling policy for a controller class or handler method.
35
+ *
36
+ * @param options Rate-limit window and request cap for the decorated target.
37
+ * @returns A decorator that stores throttling metadata on the class or method.
38
+ */
39
+ export function Throttle(options) {
40
+ const decorator = (_value, context) => {
41
+ if (context.kind === 'class') {
42
+ getMetadataBag(context.metadata)[classThrottleKey] = cloneThrottleOptions(options);
43
+ } else {
44
+ getRouteRecord(context.metadata, context.name)[throttleKey] = cloneThrottleOptions(options);
45
+ }
46
+ };
47
+ return decorator;
48
+ }
49
+
50
+ /**
51
+ * Disable throttling for a controller class or handler method.
52
+ *
53
+ * @returns A decorator that marks the target as exempt from `ThrottlerGuard`.
54
+ */
55
+ export function SkipThrottle() {
56
+ const decorator = (_value, context) => {
57
+ if (context.kind === 'class') {
58
+ getMetadataBag(context.metadata)[classSkipThrottleKey] = true;
59
+ } else {
60
+ getRouteRecord(context.metadata, context.name)[skipThrottleKey] = true;
61
+ }
62
+ };
63
+ return decorator;
64
+ }
65
+
66
+ /**
67
+ * Read method-level throttle metadata from a metadata bag.
68
+ *
69
+ * @param bag Route-level metadata bag captured from the controller.
70
+ * @returns A defensive copy of the stored throttle options, if present.
71
+ */
72
+ export function getThrottleMetadata(bag) {
73
+ const metadata = bag[throttleKey];
74
+ return metadata ? cloneThrottleOptions(metadata) : undefined;
75
+ }
76
+
77
+ /**
78
+ * Read method-level skip metadata from a metadata bag.
79
+ *
80
+ * @param bag Route-level metadata bag captured from the controller.
81
+ * @returns `true` when throttling should be skipped for the handler.
82
+ */
83
+ export function getSkipThrottleMetadata(bag) {
84
+ return bag[skipThrottleKey] === true;
85
+ }
86
+
87
+ /**
88
+ * Read class-level throttle metadata from a metadata bag.
89
+ *
90
+ * @param bag Controller metadata bag.
91
+ * @returns A defensive copy of the stored throttle options, if present.
92
+ */
93
+ export function getClassThrottleMetadata(bag) {
94
+ const metadata = bag[classThrottleKey];
95
+ return metadata ? cloneThrottleOptions(metadata) : undefined;
96
+ }
97
+
98
+ /**
99
+ * Read class-level skip metadata from a metadata bag.
100
+ *
101
+ * @param bag Controller metadata bag.
102
+ * @returns `true` when throttling should be skipped for the controller.
103
+ */
104
+ export function getClassSkipThrottleMetadata(bag) {
105
+ return bag[classSkipThrottleKey] === true;
106
+ }
@@ -0,0 +1,19 @@
1
+ import { type Guard, type GuardContext } from '@fluojs/http';
2
+ import type { ThrottlerModuleOptions } from './types.js';
3
+ /**
4
+ * Guard that enforces module-, class-, and method-level throttling policies.
5
+ */
6
+ export declare class ThrottlerGuard implements Guard {
7
+ private readonly options;
8
+ private readonly store;
9
+ constructor(options: ThrottlerModuleOptions);
10
+ /**
11
+ * Evaluate whether the current request is still within its allowed rate-limit window.
12
+ *
13
+ * @param context Guard execution context for the current handler invocation.
14
+ * @returns `true` when the request is allowed to proceed.
15
+ * @throws TooManyRequestsException When the request exceeds the configured limit.
16
+ */
17
+ canActivate(context: GuardContext): Promise<boolean>;
18
+ }
19
+ //# sourceMappingURL=guard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"guard.d.ts","sourceRoot":"","sources":["../src/guard.ts"],"names":[],"mappings":"AAEA,OAAO,EAA4B,KAAK,KAAK,EAAE,KAAK,YAAY,EAA0B,MAAM,cAAc,CAAC;AAY/G,OAAO,KAAK,EAAE,sBAAsB,EAAkB,MAAM,YAAY,CAAC;AA2CzE;;GAEG;AACH,qBACa,cAAe,YAAW,KAAK;IAG9B,OAAO,CAAC,QAAQ,CAAC,OAAO;IAFpC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAiB;gBAEV,OAAO,EAAE,sBAAsB;IAK5D;;;;;;OAMG;IACG,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,OAAO,CAAC;CAiD3D"}
package/dist/guard.js ADDED
@@ -0,0 +1,111 @@
1
+ let _initClass;
2
+ function _applyDecs(e, t, n, r, o, i) { var a, c, u, s, f, l, p, d = Symbol.metadata || Symbol.for("Symbol.metadata"), m = Object.defineProperty, h = Object.create, y = [h(null), h(null)], v = t.length; function g(t, n, r) { return function (o, i) { n && (i = o, o = e); for (var a = 0; a < t.length; a++) i = t[a].apply(o, r ? [i] : []); return r ? i : o; }; } function b(e, t, n, r) { if ("function" != typeof e && (r || void 0 !== e)) throw new TypeError(t + " must " + (n || "be") + " a function" + (r ? "" : " or undefined")); return e; } function applyDec(e, t, n, r, o, i, u, s, f, l, p) { function d(e) { if (!p(e)) throw new TypeError("Attempted to access private element on non-instance"); } var h = [].concat(t[0]), v = t[3], w = !u, D = 1 === o, S = 3 === o, j = 4 === o, E = 2 === o; function I(t, n, r) { return function (o, i) { return n && (i = o, o = e), r && r(o), P[t].call(o, i); }; } if (!w) { var P = {}, k = [], F = S ? "get" : j || D ? "set" : "value"; if (f ? (l || D ? P = { get: _setFunctionName(function () { return v(this); }, r, "get"), set: function (e) { t[4](this, e); } } : P[F] = v, l || _setFunctionName(P[F], r, E ? "" : F)) : l || (P = Object.getOwnPropertyDescriptor(e, r)), !l && !f) { if ((c = y[+s][r]) && 7 !== (c ^ o)) throw Error("Decorating two elements with the same name (" + P[F].name + ") is not supported yet"); y[+s][r] = o < 3 ? 1 : o; } } for (var N = e, O = h.length - 1; O >= 0; O -= n ? 2 : 1) { var T = b(h[O], "A decorator", "be", !0), z = n ? h[O - 1] : void 0, A = {}, H = { kind: ["field", "accessor", "method", "getter", "setter", "class"][o], name: r, metadata: a, addInitializer: function (e, t) { if (e.v) throw new TypeError("attempted to call addInitializer after decoration was finished"); b(t, "An initializer", "be", !0), i.push(t); }.bind(null, A) }; if (w) c = T.call(z, N, H), A.v = 1, b(c, "class decorators", "return") && (N = c);else if (H.static = s, H.private = f, c = H.access = { has: f ? p.bind() : function (e) { return r in e; } }, j || (c.get = f ? E ? function (e) { return d(e), P.value; } : I("get", 0, d) : function (e) { return e[r]; }), E || S || (c.set = f ? I("set", 0, d) : function (e, t) { e[r] = t; }), N = T.call(z, D ? { get: P.get, set: P.set } : P[F], H), A.v = 1, D) { if ("object" == typeof N && N) (c = b(N.get, "accessor.get")) && (P.get = c), (c = b(N.set, "accessor.set")) && (P.set = c), (c = b(N.init, "accessor.init")) && k.unshift(c);else if (void 0 !== N) throw new TypeError("accessor decorators must return an object with get, set, or init properties or undefined"); } else b(N, (l ? "field" : "method") + " decorators", "return") && (l ? k.unshift(N) : P[F] = N); } return o < 2 && u.push(g(k, s, 1), g(i, s, 0)), l || w || (f ? D ? u.splice(-1, 0, I("get", s), I("set", s)) : u.push(E ? P[F] : b.call.bind(P[F])) : m(e, r, P)), N; } function w(e) { return m(e, d, { configurable: !0, enumerable: !0, value: a }); } return void 0 !== i && (a = i[d]), a = h(null == a ? null : a), f = [], l = function (e) { e && f.push(g(e)); }, p = function (t, r) { for (var i = 0; i < n.length; i++) { var a = n[i], c = a[1], l = 7 & c; if ((8 & c) == t && !l == r) { var p = a[2], d = !!a[3], m = 16 & c; applyDec(t ? e : e.prototype, a, m, d ? "#" + p : _toPropertyKey(p), l, l < 2 ? [] : t ? s = s || [] : u = u || [], f, !!t, d, r, t && d ? function (t) { return _checkInRHS(t) === e; } : o); } } }, p(8, 0), p(0, 0), p(8, 1), p(0, 1), l(u), l(s), c = f, v || w(e), { e: c, get c() { var n = []; return v && [w(e = applyDec(e, [t], r, e.name, 5, n)), g(n, 1)]; } }; }
3
+ function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == typeof i ? i : i + ""; }
4
+ function _toPrimitive(t, r) { if ("object" != typeof t || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != typeof i) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); }
5
+ function _setFunctionName(e, t, n) { "symbol" == typeof t && (t = (t = t.description) ? "[" + t + "]" : ""); try { Object.defineProperty(e, "name", { configurable: !0, value: n ? n + " " + t : t }); } catch (e) {} return e; }
6
+ function _checkInRHS(e) { if (Object(e) !== e) throw TypeError("right-hand side of 'in' should be an object, got " + (null !== e ? typeof e : "null")); return e; }
7
+ import { Inject } from '@fluojs/core';
8
+ import { metadataSymbol } from '@fluojs/core/internal';
9
+ import { TooManyRequestsException } from '@fluojs/http';
10
+ import { resolveClientIdentity } from '@fluojs/http/internal';
11
+ import { getClassSkipThrottleMetadata, getClassThrottleMetadata, getSkipThrottleMetadata, getThrottleMetadata, throttleRouteMetadataKey } from './decorators.js';
12
+ import { createMemoryThrottlerStore } from './store.js';
13
+ import { THROTTLER_OPTIONS } from './tokens.js';
14
+ import { validateThrottleOptions, validateThrottlerModuleOptions, validateThrottlerStoreEntry } from './validation.js';
15
+ function getClassMetadataBag(target) {
16
+ return target[metadataSymbol];
17
+ }
18
+ function getMethodMetadataBag(controllerToken, methodName) {
19
+ const classBag = getClassMetadataBag(controllerToken);
20
+ if (!classBag) {
21
+ return undefined;
22
+ }
23
+ const routeMap = classBag[throttleRouteMetadataKey];
24
+ return routeMap?.get(methodName);
25
+ }
26
+ function defaultKeyGenerator(ctx, trustProxyHeaders) {
27
+ return resolveClientIdentity(ctx.request, {
28
+ trustProxyHeaders
29
+ });
30
+ }
31
+ function buildStoreKey(handlerKey, clientKey) {
32
+ const encodedHandlerKey = encodeURIComponent(handlerKey);
33
+ const encodedClientKey = encodeURIComponent(clientKey);
34
+ return `throttler:${encodedHandlerKey}:${encodedClientKey}`;
35
+ }
36
+ function buildHandlerKey(handler) {
37
+ const version = handler.route.version ?? handler.metadata.effectiveVersion ?? 'unversioned';
38
+ return [`method:${handler.route.method}`, `path:${encodeURIComponent(handler.route.path)}`, `version:${encodeURIComponent(version)}`, `handler:${encodeURIComponent(handler.methodName)}`].join('|');
39
+ }
40
+
41
+ /**
42
+ * Guard that enforces module-, class-, and method-level throttling policies.
43
+ */
44
+ let _ThrottlerGuard;
45
+ class ThrottlerGuard {
46
+ static {
47
+ [_ThrottlerGuard, _initClass] = _applyDecs(this, [Inject(THROTTLER_OPTIONS)], []).c;
48
+ }
49
+ store;
50
+ constructor(options) {
51
+ this.options = options;
52
+ validateThrottlerModuleOptions(options);
53
+ this.store = options.store ?? createMemoryThrottlerStore();
54
+ }
55
+
56
+ /**
57
+ * Evaluate whether the current request is still within its allowed rate-limit window.
58
+ *
59
+ * @param context Guard execution context for the current handler invocation.
60
+ * @returns `true` when the request is allowed to proceed.
61
+ * @throws TooManyRequestsException When the request exceeds the configured limit.
62
+ */
63
+ async canActivate(context) {
64
+ const {
65
+ handler,
66
+ requestContext
67
+ } = context;
68
+ const classBag = getClassMetadataBag(handler.controllerToken);
69
+ const methodBag = getMethodMetadataBag(handler.controllerToken, handler.methodName);
70
+ const classSkip = classBag ? getClassSkipThrottleMetadata(classBag) : false;
71
+ const methodSkip = methodBag ? getSkipThrottleMetadata(methodBag) : false;
72
+ if (classSkip || methodSkip) {
73
+ return true;
74
+ }
75
+ const methodThrottle = methodBag ? getThrottleMetadata(methodBag) : undefined;
76
+ const classThrottle = classBag ? getClassThrottleMetadata(classBag) : undefined;
77
+ const resolvedThrottle = validateThrottleOptions({
78
+ limit: methodThrottle?.limit ?? classThrottle?.limit ?? this.options.limit,
79
+ ttl: methodThrottle?.ttl ?? classThrottle?.ttl ?? this.options.ttl
80
+ });
81
+ const ttlSeconds = resolvedThrottle.ttl;
82
+ const limit = resolvedThrottle.limit;
83
+ const middlewareCtx = {
84
+ request: requestContext.request,
85
+ requestContext,
86
+ response: requestContext.response
87
+ };
88
+ const clientKey = this.options.keyGenerator ? this.options.keyGenerator(middlewareCtx) : defaultKeyGenerator(middlewareCtx, this.options.trustProxyHeaders ?? false);
89
+ const handlerKey = buildHandlerKey(handler);
90
+ const storeKey = buildStoreKey(handlerKey, clientKey);
91
+ const now = Date.now();
92
+ const entry = validateThrottlerStoreEntry(await this.store.consume(storeKey, {
93
+ now,
94
+ ttlSeconds
95
+ }));
96
+ if (entry.count > limit) {
97
+ const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
98
+ requestContext.response.setHeader('Retry-After', String(retryAfter));
99
+ throw new TooManyRequestsException('Too Many Requests', {
100
+ meta: {
101
+ retryAfter
102
+ }
103
+ });
104
+ }
105
+ return true;
106
+ }
107
+ static {
108
+ _initClass();
109
+ }
110
+ }
111
+ export { _ThrottlerGuard as ThrottlerGuard };
@@ -0,0 +1,9 @@
1
+ export * from './decorators.js';
2
+ export { ThrottlerGuard } from './guard.js';
3
+ export { RedisThrottlerStore } from './redis-store.js';
4
+ export * from './module.js';
5
+ export * from './status.js';
6
+ export { createMemoryThrottlerStore } from './store.js';
7
+ export { THROTTLER_OPTIONS } from './tokens.js';
8
+ export type { ThrottlerHandlerOptions, ThrottlerModuleOptions, ThrottlerStore, ThrottlerStoreEntry } from './types.js';
9
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC;AAChC,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAC5C,OAAO,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AACvD,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,OAAO,EAAE,0BAA0B,EAAE,MAAM,YAAY,CAAC;AACxD,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAChD,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export * from './decorators.js';
2
+ export { ThrottlerGuard } from './guard.js';
3
+ export { RedisThrottlerStore } from './redis-store.js';
4
+ export * from './module.js';
5
+ export * from './status.js';
6
+ export { createMemoryThrottlerStore } from './store.js';
7
+ export { THROTTLER_OPTIONS } from './tokens.js';
@@ -0,0 +1,27 @@
1
+ import { type ModuleType } from '@fluojs/runtime';
2
+ import type { ThrottlerModuleOptions } from './types.js';
3
+ /**
4
+ * Runtime module entrypoint for global throttling.
5
+ *
6
+ * @remarks
7
+ * The module wires one global `ThrottlerGuard`; route-level overrides still come
8
+ * from `@Throttle(...)` and `@SkipThrottle()` metadata.
9
+ */
10
+ export declare class ThrottlerModule {
11
+ /**
12
+ * Register the global throttling guard with validated module options.
13
+ *
14
+ * @param options Module-wide throttling policy.
15
+ * @returns A runtime module exporting `ThrottlerGuard`.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * ThrottlerModule.forRoot({
20
+ * ttl: 60,
21
+ * limit: 10,
22
+ * });
23
+ * ```
24
+ */
25
+ static forRoot(options: ThrottlerModuleOptions): ModuleType;
26
+ }
27
+ //# sourceMappingURL=module.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"module.d.ts","sourceRoot":"","sources":["../src/module.ts"],"names":[],"mappings":"AACA,OAAO,EAAgB,KAAK,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAIhE,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,YAAY,CAAC;AAkBzD;;;;;;GAMG;AACH,qBAAa,eAAe;IAC1B;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,sBAAsB,GAAG,UAAU;CAS5D"}
package/dist/module.js ADDED
@@ -0,0 +1,46 @@
1
+ import { defineModule } from '@fluojs/runtime';
2
+ import { ThrottlerGuard } from './guard.js';
3
+ import { THROTTLER_OPTIONS } from './tokens.js';
4
+ import { validateThrottlerModuleOptions } from './validation.js';
5
+ function createThrottlerProviders(options) {
6
+ const validatedOptions = validateThrottlerModuleOptions(options);
7
+ return [{
8
+ provide: THROTTLER_OPTIONS,
9
+ useValue: validatedOptions
10
+ }, {
11
+ provide: ThrottlerGuard,
12
+ useClass: ThrottlerGuard
13
+ }];
14
+ }
15
+
16
+ /**
17
+ * Runtime module entrypoint for global throttling.
18
+ *
19
+ * @remarks
20
+ * The module wires one global `ThrottlerGuard`; route-level overrides still come
21
+ * from `@Throttle(...)` and `@SkipThrottle()` metadata.
22
+ */
23
+ export class ThrottlerModule {
24
+ /**
25
+ * Register the global throttling guard with validated module options.
26
+ *
27
+ * @param options Module-wide throttling policy.
28
+ * @returns A runtime module exporting `ThrottlerGuard`.
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * ThrottlerModule.forRoot({
33
+ * ttl: 60,
34
+ * limit: 10,
35
+ * });
36
+ * ```
37
+ */
38
+ static forRoot(options) {
39
+ class ThrottlerRootModule extends ThrottlerModule {}
40
+ return defineModule(ThrottlerRootModule, {
41
+ exports: [ThrottlerGuard],
42
+ global: true,
43
+ providers: createThrottlerProviders(options)
44
+ });
45
+ }
46
+ }
@@ -0,0 +1,22 @@
1
+ import type Redis from 'ioredis';
2
+ import type { ThrottlerConsumeInput, ThrottlerStore, ThrottlerStoreEntry } from './types.js';
3
+ /**
4
+ * Redis-backed throttler store for distributed rate limits.
5
+ *
6
+ * @remarks
7
+ * This store uses one atomic Lua script per consume operation so concurrent
8
+ * requests across instances observe the same counter and reset window.
9
+ */
10
+ export declare class RedisThrottlerStore implements ThrottlerStore {
11
+ private readonly client;
12
+ constructor(client: Redis);
13
+ /**
14
+ * Consume one throttle slot for the provided key.
15
+ *
16
+ * @param key Stable throttle key derived from the current request.
17
+ * @param input Current timestamp and TTL window in seconds.
18
+ * @returns The updated counter value and reset timestamp for the current window.
19
+ */
20
+ consume(key: string, input: ThrottlerConsumeInput): Promise<ThrottlerStoreEntry>;
21
+ }
22
+ //# sourceMappingURL=redis-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redis-store.d.ts","sourceRoot":"","sources":["../src/redis-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,SAAS,CAAC;AAEjC,OAAO,KAAK,EAAE,qBAAqB,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AA4C7F;;;;;;GAMG;AACH,qBAAa,mBAAoB,YAAW,cAAc;IAC5C,OAAO,CAAC,QAAQ,CAAC,MAAM;gBAAN,MAAM,EAAE,KAAK;IAE1C;;;;;;OAMG;IACG,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,qBAAqB,GAAG,OAAO,CAAC,mBAAmB,CAAC;CAWvF"}
@@ -0,0 +1,41 @@
1
+ import { validateThrottlerStoreEntry } from './validation.js';
2
+ const CONSUME_LUA = ["local key = KEYS[1]", "local now = tonumber(ARGV[1])", "local ttlMs = tonumber(ARGV[2])", "local raw = redis.call('GET', key)", "local count", "local resetAt", 'if not raw then', ' count = 1', ' resetAt = now + ttlMs', 'else', ' local decoded = cjson.decode(raw)', " count = tonumber(decoded['count']) or 0", " resetAt = tonumber(decoded['resetAt']) or (now + ttlMs)", ' if now >= resetAt then', ' count = 1', ' resetAt = now + ttlMs', ' else', ' count = count + 1', ' end', 'end', 'local ttlMsLeft = math.max(resetAt - now, 1)', "redis.call('SET', key, cjson.encode({ count = count, resetAt = resetAt }), 'PX', ttlMsLeft)", 'return {count, resetAt}'].join('\n');
3
+ function parseConsumeResult(result) {
4
+ if (!Array.isArray(result) || result.length < 2) {
5
+ throw new Error('Redis throttler consume script returned an invalid response.');
6
+ }
7
+ const count = Number(result[0]);
8
+ const resetAt = Number(result[1]);
9
+ if (!Number.isFinite(count) || !Number.isFinite(resetAt)) {
10
+ throw new Error('Redis throttler consume script returned non-numeric counters.');
11
+ }
12
+ return validateThrottlerStoreEntry({
13
+ count,
14
+ resetAt
15
+ });
16
+ }
17
+
18
+ /**
19
+ * Redis-backed throttler store for distributed rate limits.
20
+ *
21
+ * @remarks
22
+ * This store uses one atomic Lua script per consume operation so concurrent
23
+ * requests across instances observe the same counter and reset window.
24
+ */
25
+ export class RedisThrottlerStore {
26
+ constructor(client) {
27
+ this.client = client;
28
+ }
29
+
30
+ /**
31
+ * Consume one throttle slot for the provided key.
32
+ *
33
+ * @param key Stable throttle key derived from the current request.
34
+ * @param input Current timestamp and TTL window in seconds.
35
+ * @returns The updated counter value and reset timestamp for the current window.
36
+ */
37
+ async consume(key, input) {
38
+ const result = await this.client.eval(CONSUME_LUA, 1, key, String(input.now), String(input.ttlSeconds * 1000));
39
+ return parseConsumeResult(result);
40
+ }
41
+ }
@@ -0,0 +1,50 @@
1
+ import type { PlatformDiagnosticIssue, PlatformHealthReport, PlatformReadinessReport, PlatformSnapshot } from '@fluojs/runtime';
2
+ /**
3
+ * Snapshot shape produced by the throttler platform status helpers.
4
+ */
5
+ export interface ThrottlerPlatformStatusSnapshot {
6
+ readiness: PlatformReadinessReport;
7
+ health: PlatformHealthReport;
8
+ ownership: PlatformSnapshot['ownership'];
9
+ details: Record<string, unknown>;
10
+ }
11
+ /**
12
+ * Backing store categories recognized by the throttler status adapter.
13
+ */
14
+ export type ThrottlerStoreKind = 'memory' | 'redis' | 'custom';
15
+ /**
16
+ * Ownership modes used to describe who is responsible for the throttler store lifecycle.
17
+ */
18
+ export type ThrottlerStoreOwnershipMode = 'framework' | 'external';
19
+ /**
20
+ * Deployment modes used to describe whether throttling is local or distributed.
21
+ */
22
+ export type ThrottlerOperationMode = 'local-only' | 'distributed' | 'local-fallback' | 'custom';
23
+ /**
24
+ * Input consumed by throttler status and diagnostic helpers.
25
+ */
26
+ export interface ThrottlerStatusAdapterInput {
27
+ componentId?: string;
28
+ storeKind: ThrottlerStoreKind;
29
+ storeOwnershipMode?: ThrottlerStoreOwnershipMode;
30
+ operationMode?: ThrottlerOperationMode;
31
+ backingStoreReady?: boolean;
32
+ backingStoreReason?: string;
33
+ dependencyId?: string;
34
+ readinessCritical?: boolean;
35
+ }
36
+ /**
37
+ * Create a platform status snapshot for throttler readiness, health, and telemetry.
38
+ *
39
+ * @param input Store metadata and readiness hints collected during bootstrap.
40
+ * @returns A throttler status snapshot suitable for platform diagnostics.
41
+ */
42
+ export declare function createThrottlerPlatformStatusSnapshot(input: ThrottlerStatusAdapterInput): ThrottlerPlatformStatusSnapshot;
43
+ /**
44
+ * Translate throttler readiness input into platform diagnostic issues.
45
+ *
46
+ * @param input Store metadata and readiness hints collected during bootstrap.
47
+ * @returns Zero or more diagnostic issues describing degraded or unavailable throttler backing stores.
48
+ */
49
+ export declare function createThrottlerPlatformDiagnosticIssues(input: ThrottlerStatusAdapterInput): PlatformDiagnosticIssue[];
50
+ //# sourceMappingURL=status.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../src/status.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,uBAAuB,EAAE,oBAAoB,EAAE,uBAAuB,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAEhI;;GAEG;AACH,MAAM,WAAW,+BAA+B;IAC9C,SAAS,EAAE,uBAAuB,CAAC;IACnC,MAAM,EAAE,oBAAoB,CAAC;IAC7B,SAAS,EAAE,gBAAgB,CAAC,WAAW,CAAC,CAAC;IACzC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAED;;GAEG;AACH,MAAM,MAAM,kBAAkB,GAAG,QAAQ,GAAG,OAAO,GAAG,QAAQ,CAAC;AAE/D;;GAEG;AACH,MAAM,MAAM,2BAA2B,GAAG,WAAW,GAAG,UAAU,CAAC;AAEnE;;GAEG;AACH,MAAM,MAAM,sBAAsB,GAAG,YAAY,GAAG,aAAa,GAAG,gBAAgB,GAAG,QAAQ,CAAC;AAEhG;;GAEG;AACH,MAAM,WAAW,2BAA2B;IAC1C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,kBAAkB,CAAC;IAC9B,kBAAkB,CAAC,EAAE,2BAA2B,CAAC;IACjD,aAAa,CAAC,EAAE,sBAAsB,CAAC;IACvC,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B;AAgED;;;;;GAKG;AACH,wBAAgB,qCAAqC,CAAC,KAAK,EAAE,2BAA2B,GAAG,+BAA+B,CAiCzH;AAED;;;;;GAKG;AACH,wBAAgB,uCAAuC,CAAC,KAAK,EAAE,2BAA2B,GAAG,uBAAuB,EAAE,CAuBrH"}
package/dist/status.js ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Snapshot shape produced by the throttler platform status helpers.
3
+ */
4
+
5
+ /**
6
+ * Backing store categories recognized by the throttler status adapter.
7
+ */
8
+
9
+ /**
10
+ * Ownership modes used to describe who is responsible for the throttler store lifecycle.
11
+ */
12
+
13
+ /**
14
+ * Deployment modes used to describe whether throttling is local or distributed.
15
+ */
16
+
17
+ /**
18
+ * Input consumed by throttler status and diagnostic helpers.
19
+ */
20
+
21
+ function resolveStoreOwnershipMode(input) {
22
+ if (input.storeOwnershipMode) {
23
+ return input.storeOwnershipMode;
24
+ }
25
+ return input.storeKind === 'memory' ? 'framework' : 'external';
26
+ }
27
+ function resolveOperationMode(input) {
28
+ if (input.operationMode) {
29
+ return input.operationMode;
30
+ }
31
+ if (input.storeKind === 'redis') {
32
+ return 'distributed';
33
+ }
34
+ if (input.storeKind === 'memory') {
35
+ return 'local-only';
36
+ }
37
+ return 'custom';
38
+ }
39
+ function isBackingStoreReady(input) {
40
+ if (input.backingStoreReady !== undefined) {
41
+ return input.backingStoreReady;
42
+ }
43
+ return true;
44
+ }
45
+ function createReadiness(input) {
46
+ const critical = input.readinessCritical ?? false;
47
+ if (isBackingStoreReady(input)) {
48
+ return {
49
+ critical,
50
+ status: 'ready'
51
+ };
52
+ }
53
+ return {
54
+ critical,
55
+ reason: input.backingStoreReason ?? 'Throttler backing store is unavailable.',
56
+ status: critical ? 'not-ready' : 'degraded'
57
+ };
58
+ }
59
+ function createHealth(input) {
60
+ if (!isBackingStoreReady(input)) {
61
+ return {
62
+ reason: input.backingStoreReason ?? 'Throttler backing store is unavailable.',
63
+ status: 'degraded'
64
+ };
65
+ }
66
+ return {
67
+ status: 'healthy'
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Create a platform status snapshot for throttler readiness, health, and telemetry.
73
+ *
74
+ * @param input Store metadata and readiness hints collected during bootstrap.
75
+ * @returns A throttler status snapshot suitable for platform diagnostics.
76
+ */
77
+ export function createThrottlerPlatformStatusSnapshot(input) {
78
+ const storeOwnershipMode = resolveStoreOwnershipMode(input);
79
+ const operationMode = resolveOperationMode(input);
80
+ const backingReady = isBackingStoreReady(input);
81
+ const componentId = input.componentId ?? 'throttler.default';
82
+ return {
83
+ details: {
84
+ backingStore: {
85
+ dependencyId: input.dependencyId,
86
+ reason: input.backingStoreReason,
87
+ ready: backingReady
88
+ },
89
+ operationMode,
90
+ storeKind: input.storeKind,
91
+ storeOwnershipMode,
92
+ telemetry: {
93
+ labels: {
94
+ component_id: componentId,
95
+ component_kind: 'throttler',
96
+ operation: 'request-throttle',
97
+ result: backingReady ? 'ready' : 'degraded'
98
+ },
99
+ namespace: 'throttler'
100
+ }
101
+ },
102
+ health: createHealth(input),
103
+ ownership: {
104
+ externallyManaged: storeOwnershipMode === 'external',
105
+ ownsResources: storeOwnershipMode === 'framework'
106
+ },
107
+ readiness: createReadiness(input)
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Translate throttler readiness input into platform diagnostic issues.
113
+ *
114
+ * @param input Store metadata and readiness hints collected during bootstrap.
115
+ * @returns Zero or more diagnostic issues describing degraded or unavailable throttler backing stores.
116
+ */
117
+ export function createThrottlerPlatformDiagnosticIssues(input) {
118
+ if (isBackingStoreReady(input)) {
119
+ return [];
120
+ }
121
+ const componentId = input.componentId ?? 'throttler.default';
122
+ const critical = input.readinessCritical ?? false;
123
+ return [{
124
+ code: 'THROTTLER_BACKING_STORE_NOT_READY',
125
+ componentId,
126
+ cause: input.backingStoreReason,
127
+ dependsOn: input.dependencyId ? [input.dependencyId] : undefined,
128
+ fixHint: input.storeKind === 'redis' ? 'Verify Redis connectivity or switch to local throttler store for non-critical environments.' : 'Restore the throttler backing store or disable throttling for this environment.',
129
+ message: critical ? 'Throttler is configured as critical, but its backing store is not ready.' : 'Throttler backing store is degraded; request traffic can continue in non-critical mode.',
130
+ severity: critical ? 'error' : 'warning'
131
+ }];
132
+ }
@@ -0,0 +1,17 @@
1
+ import type { ThrottlerStore } from './types.js';
2
+ /**
3
+ * Create the default in-memory throttler store used by `ThrottlerModule`.
4
+ *
5
+ * @returns A store that keeps throttle counters isolated to the current process.
6
+ *
7
+ * @remarks
8
+ * This store is intentionally local-only. Use `RedisThrottlerStore` when rate
9
+ * limits must be shared across multiple application instances.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * const store = createMemoryThrottlerStore();
14
+ * ```
15
+ */
16
+ export declare function createMemoryThrottlerStore(): ThrottlerStore;
17
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAyB,cAAc,EAAuB,MAAM,YAAY,CAAC;AAoC7F;;;;;;;;;;;;;GAaG;AACH,wBAAgB,0BAA0B,IAAI,cAAc,CAkB3D"}
package/dist/store.js ADDED
@@ -0,0 +1,60 @@
1
+ function consumeWindow(entry, {
2
+ now,
3
+ ttlSeconds
4
+ }) {
5
+ const resetAt = now + ttlSeconds * 1000;
6
+ if (!entry || now >= entry.resetAt) {
7
+ return {
8
+ count: 1,
9
+ resetAt
10
+ };
11
+ }
12
+ return {
13
+ count: entry.count + 1,
14
+ resetAt: entry.resetAt
15
+ };
16
+ }
17
+ function sweepExpiredEntries(map, now) {
18
+ let nextSweepAt = Number.POSITIVE_INFINITY;
19
+ for (const [entryKey, entry] of map) {
20
+ if (now >= entry.resetAt) {
21
+ map.delete(entryKey);
22
+ continue;
23
+ }
24
+ nextSweepAt = Math.min(nextSweepAt, entry.resetAt);
25
+ }
26
+ return Number.isFinite(nextSweepAt) ? nextSweepAt : 0;
27
+ }
28
+
29
+ /**
30
+ * Create the default in-memory throttler store used by `ThrottlerModule`.
31
+ *
32
+ * @returns A store that keeps throttle counters isolated to the current process.
33
+ *
34
+ * @remarks
35
+ * This store is intentionally local-only. Use `RedisThrottlerStore` when rate
36
+ * limits must be shared across multiple application instances.
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * const store = createMemoryThrottlerStore();
41
+ * ```
42
+ */
43
+ export function createMemoryThrottlerStore() {
44
+ const map = new Map();
45
+ let nextSweepAt = 0;
46
+ return {
47
+ consume(key, input) {
48
+ const {
49
+ now
50
+ } = input;
51
+ if (now >= nextSweepAt) {
52
+ nextSweepAt = sweepExpiredEntries(map, now);
53
+ }
54
+ const nextEntry = consumeWindow(map.get(key), input);
55
+ map.set(key, nextEntry);
56
+ nextSweepAt = nextSweepAt === 0 ? nextEntry.resetAt : Math.min(nextSweepAt, nextEntry.resetAt);
57
+ return nextEntry;
58
+ }
59
+ };
60
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Injection token for the throttler module options.
3
+ */
4
+ export declare const THROTTLER_OPTIONS: unique symbol;
5
+ //# sourceMappingURL=tokens.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../src/tokens.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,eAAO,MAAM,iBAAiB,eAAuC,CAAC"}
package/dist/tokens.js ADDED
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Injection token for the throttler module options.
3
+ */
4
+ export const THROTTLER_OPTIONS = Symbol.for('fluo.throttler.options');
@@ -0,0 +1,52 @@
1
+ import type { MiddlewareContext } from '@fluojs/http';
2
+ /**
3
+ * Snapshot of a client's current rate-limit window state.
4
+ */
5
+ export interface ThrottlerStoreEntry {
6
+ count: number;
7
+ resetAt: number;
8
+ }
9
+ /**
10
+ * Input passed to a `ThrottlerStore` when consuming a request slot.
11
+ */
12
+ export interface ThrottlerConsumeInput {
13
+ now: number;
14
+ ttlSeconds: number;
15
+ }
16
+ /**
17
+ * Store contract used by `ThrottlerGuard` to track request windows.
18
+ */
19
+ export interface ThrottlerStore {
20
+ consume(key: string, input: ThrottlerConsumeInput): ThrottlerStoreEntry | Promise<ThrottlerStoreEntry>;
21
+ }
22
+ /**
23
+ * Per-handler or per-controller throttle override.
24
+ */
25
+ export interface ThrottlerHandlerOptions {
26
+ /** Seconds in the rate-limit window. */
27
+ ttl: number;
28
+ /** Maximum number of requests allowed within the window. */
29
+ limit: number;
30
+ }
31
+ /**
32
+ * Public configuration options for `ThrottlerModule.forRoot(...)`.
33
+ */
34
+ export interface ThrottlerModuleOptions {
35
+ /** Seconds in the rate-limit window (module-wide default). */
36
+ ttl: number;
37
+ /** Maximum number of requests allowed within the window (module-wide default). */
38
+ limit: number;
39
+ /**
40
+ * Trust `Forwarded`, `X-Forwarded-For`, and `X-Real-IP` before the raw socket address.
41
+ * Enable this only when the adapter sits behind a trusted proxy that rewrites those headers.
42
+ */
43
+ trustProxyHeaders?: boolean;
44
+ /**
45
+ * Key generator function. Defaults to conservative client identity resolution.
46
+ * Receives the raw middleware context so custom headers (e.g. x-api-key) can be used.
47
+ */
48
+ keyGenerator?: (ctx: MiddlewareContext) => string;
49
+ /** Store adapter. Defaults to the built-in in-memory store. */
50
+ store?: ThrottlerStore;
51
+ }
52
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AAEtD;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,qBAAqB,GAAG,mBAAmB,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAAC;CACxG;AAED;;GAEG;AACH,MAAM,WAAW,uBAAuB;IACtC,wCAAwC;IACxC,GAAG,EAAE,MAAM,CAAC;IACZ,4DAA4D;IAC5D,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;GAEG;AACH,MAAM,WAAW,sBAAsB;IACrC,8DAA8D;IAC9D,GAAG,EAAE,MAAM,CAAC;IACZ,kFAAkF;IAClF,KAAK,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B;;;OAGG;IACH,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,iBAAiB,KAAK,MAAM,CAAC;IAClD,+DAA+D;IAC/D,KAAK,CAAC,EAAE,cAAc,CAAC;CACxB"}
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ import type { ThrottlerHandlerOptions, ThrottlerModuleOptions, ThrottlerStoreEntry } from './types.js';
2
+ /**
3
+ * Validate one per-handler or module-level throttle policy.
4
+ *
5
+ * @param options Candidate throttle settings.
6
+ * @returns A normalized throttle policy safe for runtime use.
7
+ */
8
+ export declare function validateThrottleOptions(options: ThrottlerHandlerOptions): ThrottlerHandlerOptions;
9
+ /**
10
+ * Validate the public module options passed to `ThrottlerModule.forRoot(...)`.
11
+ *
12
+ * @param options Candidate module-wide throttler settings.
13
+ * @returns A validated copy of the throttler module options.
14
+ */
15
+ export declare function validateThrottlerModuleOptions(options: ThrottlerModuleOptions): ThrottlerModuleOptions;
16
+ /**
17
+ * Validate one store-consume result before enforcing throttling decisions.
18
+ *
19
+ * @param entry Candidate store state returned by a throttler store.
20
+ * @returns A validated throttler store entry.
21
+ */
22
+ export declare function validateThrottlerStoreEntry(entry: ThrottlerStoreEntry): ThrottlerStoreEntry;
23
+ //# sourceMappingURL=validation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../src/validation.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAoBvG;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,uBAAuB,GAAG,uBAAuB,CAOjG;AAED;;;;;GAKG;AACH,wBAAgB,8BAA8B,CAAC,OAAO,EAAE,sBAAsB,GAAG,sBAAsB,CAWtG;AAED;;;;;GAKG;AACH,wBAAgB,2BAA2B,CAAC,KAAK,EAAE,mBAAmB,GAAG,mBAAmB,CAQ3F"}
@@ -0,0 +1,63 @@
1
+ function assertPositiveFiniteInteger(value, field) {
2
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
3
+ throw new Error(`Invalid throttler ${field}: expected a positive finite integer.`);
4
+ }
5
+ }
6
+ function assertFiniteInteger(value, field) {
7
+ if (!Number.isFinite(value) || !Number.isInteger(value)) {
8
+ throw new Error(`Invalid throttler ${field}: expected a finite integer.`);
9
+ }
10
+ }
11
+ function assertOptionalBoolean(value, field) {
12
+ if (value !== undefined && typeof value !== 'boolean') {
13
+ throw new Error(`Invalid throttler ${field}: expected a boolean when provided.`);
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Validate one per-handler or module-level throttle policy.
19
+ *
20
+ * @param options Candidate throttle settings.
21
+ * @returns A normalized throttle policy safe for runtime use.
22
+ */
23
+ export function validateThrottleOptions(options) {
24
+ assertPositiveFiniteInteger(options.limit, 'limit');
25
+ assertPositiveFiniteInteger(options.ttl, 'ttl');
26
+ return {
27
+ limit: options.limit,
28
+ ttl: options.ttl
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Validate the public module options passed to `ThrottlerModule.forRoot(...)`.
34
+ *
35
+ * @param options Candidate module-wide throttler settings.
36
+ * @returns A validated copy of the throttler module options.
37
+ */
38
+ export function validateThrottlerModuleOptions(options) {
39
+ validateThrottleOptions(options);
40
+ assertOptionalBoolean(options.trustProxyHeaders, 'trustProxyHeaders');
41
+ return {
42
+ keyGenerator: options.keyGenerator,
43
+ limit: options.limit,
44
+ store: options.store,
45
+ trustProxyHeaders: options.trustProxyHeaders,
46
+ ttl: options.ttl
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Validate one store-consume result before enforcing throttling decisions.
52
+ *
53
+ * @param entry Candidate store state returned by a throttler store.
54
+ * @returns A validated throttler store entry.
55
+ */
56
+ export function validateThrottlerStoreEntry(entry) {
57
+ assertPositiveFiniteInteger(entry.count, 'store count');
58
+ assertFiniteInteger(entry.resetAt, 'store resetAt');
59
+ return {
60
+ count: entry.count,
61
+ resetAt: entry.resetAt
62
+ };
63
+ }
File without changes
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@fluojs/throttler",
3
+ "description": "Decorator-based rate limiting for Fluo applications with in-memory and Redis store adapters.",
4
+ "keywords": [
5
+ "fluo",
6
+ "throttler",
7
+ "rate-limit",
8
+ "rate-limiting",
9
+ "redis",
10
+ "decorator"
11
+ ],
12
+ "version": "1.0.0-beta.1",
13
+ "private": false,
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/fluojs/fluo.git",
18
+ "directory": "packages/throttler"
19
+ },
20
+ "engines": {
21
+ "node": ">=20.0.0"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "type": "module",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js"
31
+ }
32
+ },
33
+ "main": "./dist/index.js",
34
+ "types": "./dist/index.d.ts",
35
+ "files": [
36
+ "dist"
37
+ ],
38
+ "dependencies": {
39
+ "@fluojs/core": "^1.0.0-beta.1",
40
+ "@fluojs/runtime": "^1.0.0-beta.1",
41
+ "@fluojs/di": "^1.0.0-beta.1",
42
+ "@fluojs/http": "^1.0.0-beta.1"
43
+ },
44
+ "peerDependencies": {
45
+ "ioredis": "^5.0.0",
46
+ "@fluojs/redis": "^1.0.0-beta.1"
47
+ },
48
+ "peerDependenciesMeta": {
49
+ "@fluojs/redis": {
50
+ "optional": true
51
+ },
52
+ "ioredis": {
53
+ "optional": true
54
+ }
55
+ },
56
+ "devDependencies": {
57
+ "ioredis": "^5.10.0",
58
+ "vitest": "^3.2.4"
59
+ },
60
+ "scripts": {
61
+ "prebuild": "node ../../tooling/scripts/clean-dist.mjs",
62
+ "build": "pnpm exec babel src --extensions .ts --ignore 'src/**/*.test.ts' --out-dir dist --config-file ../../tooling/babel/babel.config.cjs && pnpm exec tsc -p tsconfig.build.json",
63
+ "typecheck": "pnpm exec tsc -p tsconfig.json --noEmit",
64
+ "test": "pnpm exec vitest run -c vitest.config.ts",
65
+ "test:watch": "pnpm exec vitest -c vitest.config.ts"
66
+ }
67
+ }