@claude-flow/mcp 3.0.0-alpha.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/.agentic-flow/intelligence.json +16 -0
- package/README.md +428 -0
- package/__tests__/integration.test.ts +449 -0
- package/__tests__/mcp.test.ts +641 -0
- package/dist/connection-pool.d.ts +36 -0
- package/dist/connection-pool.d.ts.map +1 -0
- package/dist/connection-pool.js +273 -0
- package/dist/connection-pool.js.map +1 -0
- package/dist/index.d.ts +75 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +85 -0
- package/dist/index.js.map +1 -0
- package/dist/oauth.d.ts +146 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +318 -0
- package/dist/oauth.js.map +1 -0
- package/dist/prompt-registry.d.ts +90 -0
- package/dist/prompt-registry.d.ts.map +1 -0
- package/dist/prompt-registry.js +209 -0
- package/dist/prompt-registry.js.map +1 -0
- package/dist/rate-limiter.d.ts +86 -0
- package/dist/rate-limiter.d.ts.map +1 -0
- package/dist/rate-limiter.js +197 -0
- package/dist/rate-limiter.js.map +1 -0
- package/dist/resource-registry.d.ts +144 -0
- package/dist/resource-registry.d.ts.map +1 -0
- package/dist/resource-registry.js +405 -0
- package/dist/resource-registry.js.map +1 -0
- package/dist/sampling.d.ts +102 -0
- package/dist/sampling.d.ts.map +1 -0
- package/dist/sampling.js +268 -0
- package/dist/sampling.js.map +1 -0
- package/dist/schema-validator.d.ts +30 -0
- package/dist/schema-validator.d.ts.map +1 -0
- package/dist/schema-validator.js +182 -0
- package/dist/schema-validator.js.map +1 -0
- package/dist/server.d.ts +122 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +829 -0
- package/dist/server.js.map +1 -0
- package/dist/session-manager.d.ts +55 -0
- package/dist/session-manager.d.ts.map +1 -0
- package/dist/session-manager.js +252 -0
- package/dist/session-manager.js.map +1 -0
- package/dist/task-manager.d.ts +81 -0
- package/dist/task-manager.d.ts.map +1 -0
- package/dist/task-manager.js +337 -0
- package/dist/task-manager.js.map +1 -0
- package/dist/tool-registry.d.ts +88 -0
- package/dist/tool-registry.d.ts.map +1 -0
- package/dist/tool-registry.js +353 -0
- package/dist/tool-registry.js.map +1 -0
- package/dist/transport/http.d.ts +55 -0
- package/dist/transport/http.d.ts.map +1 -0
- package/dist/transport/http.js +446 -0
- package/dist/transport/http.js.map +1 -0
- package/dist/transport/index.d.ts +50 -0
- package/dist/transport/index.d.ts.map +1 -0
- package/dist/transport/index.js +181 -0
- package/dist/transport/index.js.map +1 -0
- package/dist/transport/stdio.d.ts +43 -0
- package/dist/transport/stdio.d.ts.map +1 -0
- package/dist/transport/stdio.js +194 -0
- package/dist/transport/stdio.js.map +1 -0
- package/dist/transport/websocket.d.ts +65 -0
- package/dist/transport/websocket.d.ts.map +1 -0
- package/dist/transport/websocket.js +314 -0
- package/dist/transport/websocket.js.map +1 -0
- package/dist/types.d.ts +473 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +40 -0
- package/dist/types.js.map +1 -0
- package/package.json +42 -0
- package/src/connection-pool.ts +344 -0
- package/src/index.ts +253 -0
- package/src/oauth.ts +447 -0
- package/src/prompt-registry.ts +296 -0
- package/src/rate-limiter.ts +266 -0
- package/src/resource-registry.ts +530 -0
- package/src/sampling.ts +363 -0
- package/src/schema-validator.ts +213 -0
- package/src/server.ts +1134 -0
- package/src/session-manager.ts +339 -0
- package/src/task-manager.ts +427 -0
- package/src/tool-registry.ts +475 -0
- package/src/transport/http.ts +532 -0
- package/src/transport/index.ts +233 -0
- package/src/transport/stdio.ts +252 -0
- package/src/transport/websocket.ts +396 -0
- package/src/types.ts +664 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @claude-flow/mcp - Prompt Registry
|
|
3
|
+
*
|
|
4
|
+
* MCP 2025-11-25 compliant prompt management
|
|
5
|
+
* Supports: list, get, arguments, templates, embedded resources
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { EventEmitter } from 'events';
|
|
9
|
+
import type {
|
|
10
|
+
MCPPrompt,
|
|
11
|
+
PromptArgument,
|
|
12
|
+
PromptMessage,
|
|
13
|
+
PromptListResult,
|
|
14
|
+
PromptGetResult,
|
|
15
|
+
PromptContent,
|
|
16
|
+
TextContent,
|
|
17
|
+
EmbeddedResource,
|
|
18
|
+
ResourceContent,
|
|
19
|
+
ILogger,
|
|
20
|
+
} from './types.js';
|
|
21
|
+
|
|
22
|
+
export type PromptHandler = (
|
|
23
|
+
args: Record<string, string>
|
|
24
|
+
) => Promise<PromptMessage[]>;
|
|
25
|
+
|
|
26
|
+
export interface PromptDefinition extends MCPPrompt {
|
|
27
|
+
handler: PromptHandler;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PromptRegistryOptions {
|
|
31
|
+
maxPrompts?: number;
|
|
32
|
+
validateArguments?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class PromptRegistry extends EventEmitter {
|
|
36
|
+
private prompts: Map<string, PromptDefinition> = new Map();
|
|
37
|
+
|
|
38
|
+
private readonly options: Required<PromptRegistryOptions>;
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
private readonly logger: ILogger,
|
|
42
|
+
options: PromptRegistryOptions = {}
|
|
43
|
+
) {
|
|
44
|
+
super();
|
|
45
|
+
this.options = {
|
|
46
|
+
maxPrompts: options.maxPrompts ?? 1000,
|
|
47
|
+
validateArguments: options.validateArguments ?? true,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Register a prompt
|
|
53
|
+
*/
|
|
54
|
+
register(prompt: PromptDefinition): boolean {
|
|
55
|
+
if (this.prompts.size >= this.options.maxPrompts) {
|
|
56
|
+
this.logger.error('Maximum prompts reached', { max: this.options.maxPrompts });
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (this.prompts.has(prompt.name)) {
|
|
61
|
+
this.logger.warn('Prompt already registered', { name: prompt.name });
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
this.prompts.set(prompt.name, prompt);
|
|
66
|
+
|
|
67
|
+
this.logger.debug('Prompt registered', { name: prompt.name });
|
|
68
|
+
this.emit('prompt:registered', { name: prompt.name });
|
|
69
|
+
this.emitListChanged();
|
|
70
|
+
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Unregister a prompt
|
|
76
|
+
*/
|
|
77
|
+
unregister(name: string): boolean {
|
|
78
|
+
if (!this.prompts.has(name)) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
this.prompts.delete(name);
|
|
83
|
+
|
|
84
|
+
this.logger.debug('Prompt unregistered', { name });
|
|
85
|
+
this.emit('prompt:unregistered', { name });
|
|
86
|
+
this.emitListChanged();
|
|
87
|
+
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* List prompts with pagination
|
|
93
|
+
*/
|
|
94
|
+
list(cursor?: string, pageSize: number = 50): PromptListResult {
|
|
95
|
+
const allPrompts = Array.from(this.prompts.values()).map((p) => ({
|
|
96
|
+
name: p.name,
|
|
97
|
+
title: p.title,
|
|
98
|
+
description: p.description,
|
|
99
|
+
arguments: p.arguments,
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
let startIndex = 0;
|
|
103
|
+
if (cursor) {
|
|
104
|
+
const decoded = this.decodeCursor(cursor);
|
|
105
|
+
startIndex = decoded.offset;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const endIndex = Math.min(startIndex + pageSize, allPrompts.length);
|
|
109
|
+
const prompts = allPrompts.slice(startIndex, endIndex);
|
|
110
|
+
|
|
111
|
+
const result: PromptListResult = { prompts };
|
|
112
|
+
|
|
113
|
+
if (endIndex < allPrompts.length) {
|
|
114
|
+
result.nextCursor = this.encodeCursor({ offset: endIndex });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get a prompt with arguments
|
|
122
|
+
*/
|
|
123
|
+
async get(
|
|
124
|
+
name: string,
|
|
125
|
+
args: Record<string, string> = {}
|
|
126
|
+
): Promise<PromptGetResult> {
|
|
127
|
+
const prompt = this.prompts.get(name);
|
|
128
|
+
if (!prompt) {
|
|
129
|
+
throw new Error(`Prompt not found: ${name}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Validate required arguments
|
|
133
|
+
if (this.options.validateArguments && prompt.arguments) {
|
|
134
|
+
for (const arg of prompt.arguments) {
|
|
135
|
+
if (arg.required && !(arg.name in args)) {
|
|
136
|
+
throw new Error(`Missing required argument: ${arg.name}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const messages = await prompt.handler(args);
|
|
142
|
+
|
|
143
|
+
this.emit('prompt:get', { name, argCount: Object.keys(args).length });
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
description: prompt.description,
|
|
147
|
+
messages,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Get prompt by name
|
|
153
|
+
*/
|
|
154
|
+
getPrompt(name: string): MCPPrompt | undefined {
|
|
155
|
+
const prompt = this.prompts.get(name);
|
|
156
|
+
if (!prompt) return undefined;
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
name: prompt.name,
|
|
160
|
+
title: prompt.title,
|
|
161
|
+
description: prompt.description,
|
|
162
|
+
arguments: prompt.arguments,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Check if prompt exists
|
|
168
|
+
*/
|
|
169
|
+
hasPrompt(name: string): boolean {
|
|
170
|
+
return this.prompts.has(name);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Get prompt count
|
|
175
|
+
*/
|
|
176
|
+
getPromptCount(): number {
|
|
177
|
+
return this.prompts.size;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Get stats
|
|
182
|
+
*/
|
|
183
|
+
getStats(): {
|
|
184
|
+
totalPrompts: number;
|
|
185
|
+
promptsWithArgs: number;
|
|
186
|
+
} {
|
|
187
|
+
let promptsWithArgs = 0;
|
|
188
|
+
for (const prompt of this.prompts.values()) {
|
|
189
|
+
if (prompt.arguments && prompt.arguments.length > 0) {
|
|
190
|
+
promptsWithArgs++;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
totalPrompts: this.prompts.size,
|
|
196
|
+
promptsWithArgs,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Encode cursor for pagination
|
|
202
|
+
*/
|
|
203
|
+
private encodeCursor(data: { offset: number }): string {
|
|
204
|
+
return Buffer.from(JSON.stringify(data)).toString('base64');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Decode cursor for pagination
|
|
209
|
+
*/
|
|
210
|
+
private decodeCursor(cursor: string): { offset: number } {
|
|
211
|
+
try {
|
|
212
|
+
return JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8'));
|
|
213
|
+
} catch {
|
|
214
|
+
return { offset: 0 };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Emit listChanged notification
|
|
220
|
+
*/
|
|
221
|
+
private emitListChanged(): void {
|
|
222
|
+
this.emit('prompts:listChanged');
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function createPromptRegistry(
|
|
227
|
+
logger: ILogger,
|
|
228
|
+
options?: PromptRegistryOptions
|
|
229
|
+
): PromptRegistry {
|
|
230
|
+
return new PromptRegistry(logger, options);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Helper to define a prompt
|
|
235
|
+
*/
|
|
236
|
+
export function definePrompt(
|
|
237
|
+
name: string,
|
|
238
|
+
description: string,
|
|
239
|
+
handler: PromptHandler,
|
|
240
|
+
options?: {
|
|
241
|
+
title?: string;
|
|
242
|
+
arguments?: PromptArgument[];
|
|
243
|
+
}
|
|
244
|
+
): PromptDefinition {
|
|
245
|
+
return {
|
|
246
|
+
name,
|
|
247
|
+
description,
|
|
248
|
+
title: options?.title,
|
|
249
|
+
arguments: options?.arguments,
|
|
250
|
+
handler,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Helper to create a text message
|
|
256
|
+
*/
|
|
257
|
+
export function textMessage(
|
|
258
|
+
role: 'user' | 'assistant',
|
|
259
|
+
text: string
|
|
260
|
+
): PromptMessage {
|
|
261
|
+
return {
|
|
262
|
+
role,
|
|
263
|
+
content: {
|
|
264
|
+
type: 'text',
|
|
265
|
+
text,
|
|
266
|
+
} as TextContent,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Helper to create a message with embedded resource
|
|
272
|
+
*/
|
|
273
|
+
export function resourceMessage(
|
|
274
|
+
role: 'user' | 'assistant',
|
|
275
|
+
resource: ResourceContent
|
|
276
|
+
): PromptMessage {
|
|
277
|
+
return {
|
|
278
|
+
role,
|
|
279
|
+
content: {
|
|
280
|
+
type: 'resource',
|
|
281
|
+
resource,
|
|
282
|
+
} as EmbeddedResource,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Template string interpolation for prompts
|
|
288
|
+
*/
|
|
289
|
+
export function interpolate(
|
|
290
|
+
template: string,
|
|
291
|
+
args: Record<string, string>
|
|
292
|
+
): string {
|
|
293
|
+
return template.replace(/\{(\w+)\}/g, (match, key) => {
|
|
294
|
+
return args[key] ?? match;
|
|
295
|
+
});
|
|
296
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @claude-flow/mcp - Rate Limiter
|
|
3
|
+
*
|
|
4
|
+
* Token bucket rate limiting for DoS protection
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { EventEmitter } from 'events';
|
|
8
|
+
import type { ILogger } from './types.js';
|
|
9
|
+
|
|
10
|
+
export interface RateLimitConfig {
|
|
11
|
+
/** Requests per second */
|
|
12
|
+
requestsPerSecond: number;
|
|
13
|
+
/** Burst size (max tokens) */
|
|
14
|
+
burstSize: number;
|
|
15
|
+
/** Per-session limits (if different from global) */
|
|
16
|
+
perSessionLimit?: number;
|
|
17
|
+
/** Cleanup interval for expired sessions */
|
|
18
|
+
cleanupInterval?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RateLimitResult {
|
|
22
|
+
allowed: boolean;
|
|
23
|
+
remaining: number;
|
|
24
|
+
resetIn: number; // ms until bucket refills
|
|
25
|
+
retryAfter?: number; // seconds to wait (for 429 response)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface TokenBucket {
|
|
29
|
+
tokens: number;
|
|
30
|
+
lastRefill: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const DEFAULT_CONFIG: RateLimitConfig = {
|
|
34
|
+
requestsPerSecond: 100,
|
|
35
|
+
burstSize: 200,
|
|
36
|
+
perSessionLimit: 50,
|
|
37
|
+
cleanupInterval: 60000, // 1 minute
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export class RateLimiter extends EventEmitter {
|
|
41
|
+
private readonly config: Required<RateLimitConfig>;
|
|
42
|
+
private globalBucket: TokenBucket;
|
|
43
|
+
private sessionBuckets: Map<string, TokenBucket> = new Map();
|
|
44
|
+
private cleanupTimer?: NodeJS.Timeout;
|
|
45
|
+
|
|
46
|
+
constructor(
|
|
47
|
+
private readonly logger: ILogger,
|
|
48
|
+
config: Partial<RateLimitConfig> = {}
|
|
49
|
+
) {
|
|
50
|
+
super();
|
|
51
|
+
this.config = { ...DEFAULT_CONFIG, ...config } as Required<RateLimitConfig>;
|
|
52
|
+
|
|
53
|
+
// Initialize global bucket
|
|
54
|
+
this.globalBucket = {
|
|
55
|
+
tokens: this.config.burstSize,
|
|
56
|
+
lastRefill: Date.now(),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
this.startCleanup();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Check if request is allowed (global limit)
|
|
64
|
+
*/
|
|
65
|
+
checkGlobal(): RateLimitResult {
|
|
66
|
+
return this.checkBucket(this.globalBucket, this.config.requestsPerSecond, this.config.burstSize);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Check if request is allowed (per-session limit)
|
|
71
|
+
*/
|
|
72
|
+
checkSession(sessionId: string): RateLimitResult {
|
|
73
|
+
let bucket = this.sessionBuckets.get(sessionId);
|
|
74
|
+
if (!bucket) {
|
|
75
|
+
bucket = {
|
|
76
|
+
tokens: this.config.perSessionLimit,
|
|
77
|
+
lastRefill: Date.now(),
|
|
78
|
+
};
|
|
79
|
+
this.sessionBuckets.set(sessionId, bucket);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return this.checkBucket(
|
|
83
|
+
bucket,
|
|
84
|
+
this.config.perSessionLimit / 10, // Refill rate (10 seconds to full)
|
|
85
|
+
this.config.perSessionLimit
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Check both global and session limits
|
|
91
|
+
*/
|
|
92
|
+
check(sessionId?: string): RateLimitResult {
|
|
93
|
+
const globalResult = this.checkGlobal();
|
|
94
|
+
if (!globalResult.allowed) {
|
|
95
|
+
this.emit('rate-limit:global', { remaining: globalResult.remaining });
|
|
96
|
+
return globalResult;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (sessionId) {
|
|
100
|
+
const sessionResult = this.checkSession(sessionId);
|
|
101
|
+
if (!sessionResult.allowed) {
|
|
102
|
+
this.emit('rate-limit:session', { sessionId, remaining: sessionResult.remaining });
|
|
103
|
+
return sessionResult;
|
|
104
|
+
}
|
|
105
|
+
return sessionResult;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return globalResult;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Consume a token (call after request is processed)
|
|
113
|
+
*/
|
|
114
|
+
consume(sessionId?: string): void {
|
|
115
|
+
this.consumeFromBucket(this.globalBucket);
|
|
116
|
+
if (sessionId) {
|
|
117
|
+
const bucket = this.sessionBuckets.get(sessionId);
|
|
118
|
+
if (bucket) {
|
|
119
|
+
this.consumeFromBucket(bucket);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Reset session bucket
|
|
126
|
+
*/
|
|
127
|
+
resetSession(sessionId: string): void {
|
|
128
|
+
this.sessionBuckets.delete(sessionId);
|
|
129
|
+
this.logger.debug('Rate limit session reset', { sessionId });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Get current stats
|
|
134
|
+
*/
|
|
135
|
+
getStats(): {
|
|
136
|
+
globalTokens: number;
|
|
137
|
+
globalBurstSize: number;
|
|
138
|
+
sessionCount: number;
|
|
139
|
+
config: RateLimitConfig;
|
|
140
|
+
} {
|
|
141
|
+
this.refillBucket(this.globalBucket, this.config.requestsPerSecond, this.config.burstSize);
|
|
142
|
+
return {
|
|
143
|
+
globalTokens: Math.floor(this.globalBucket.tokens),
|
|
144
|
+
globalBurstSize: this.config.burstSize,
|
|
145
|
+
sessionCount: this.sessionBuckets.size,
|
|
146
|
+
config: this.config,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Destroy the rate limiter
|
|
152
|
+
*/
|
|
153
|
+
destroy(): void {
|
|
154
|
+
if (this.cleanupTimer) {
|
|
155
|
+
clearInterval(this.cleanupTimer);
|
|
156
|
+
this.cleanupTimer = undefined;
|
|
157
|
+
}
|
|
158
|
+
this.sessionBuckets.clear();
|
|
159
|
+
this.removeAllListeners();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Check bucket and refill tokens
|
|
164
|
+
*/
|
|
165
|
+
private checkBucket(
|
|
166
|
+
bucket: TokenBucket,
|
|
167
|
+
refillRate: number,
|
|
168
|
+
maxTokens: number
|
|
169
|
+
): RateLimitResult {
|
|
170
|
+
this.refillBucket(bucket, refillRate, maxTokens);
|
|
171
|
+
|
|
172
|
+
if (bucket.tokens >= 1) {
|
|
173
|
+
return {
|
|
174
|
+
allowed: true,
|
|
175
|
+
remaining: Math.floor(bucket.tokens) - 1,
|
|
176
|
+
resetIn: Math.ceil((maxTokens - bucket.tokens) / refillRate * 1000),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Calculate when bucket will have 1 token
|
|
181
|
+
const tokensNeeded = 1 - bucket.tokens;
|
|
182
|
+
const retryAfter = Math.ceil(tokensNeeded / refillRate);
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
allowed: false,
|
|
186
|
+
remaining: 0,
|
|
187
|
+
resetIn: retryAfter * 1000,
|
|
188
|
+
retryAfter,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Refill tokens based on elapsed time
|
|
194
|
+
*/
|
|
195
|
+
private refillBucket(bucket: TokenBucket, refillRate: number, maxTokens: number): void {
|
|
196
|
+
const now = Date.now();
|
|
197
|
+
const elapsed = (now - bucket.lastRefill) / 1000; // seconds
|
|
198
|
+
const tokensToAdd = elapsed * refillRate;
|
|
199
|
+
|
|
200
|
+
bucket.tokens = Math.min(maxTokens, bucket.tokens + tokensToAdd);
|
|
201
|
+
bucket.lastRefill = now;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Consume a token from bucket
|
|
206
|
+
*/
|
|
207
|
+
private consumeFromBucket(bucket: TokenBucket): void {
|
|
208
|
+
if (bucket.tokens >= 1) {
|
|
209
|
+
bucket.tokens -= 1;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Start cleanup timer for expired sessions
|
|
215
|
+
*/
|
|
216
|
+
private startCleanup(): void {
|
|
217
|
+
this.cleanupTimer = setInterval(() => {
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
const expireTime = this.config.cleanupInterval * 2;
|
|
220
|
+
|
|
221
|
+
for (const [sessionId, bucket] of this.sessionBuckets) {
|
|
222
|
+
if (now - bucket.lastRefill > expireTime) {
|
|
223
|
+
this.sessionBuckets.delete(sessionId);
|
|
224
|
+
this.logger.debug('Rate limit session expired', { sessionId });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}, this.config.cleanupInterval);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function createRateLimiter(
|
|
232
|
+
logger: ILogger,
|
|
233
|
+
config?: Partial<RateLimitConfig>
|
|
234
|
+
): RateLimiter {
|
|
235
|
+
return new RateLimiter(logger, config);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Express/Connect middleware for rate limiting
|
|
240
|
+
*/
|
|
241
|
+
export function rateLimitMiddleware(rateLimiter: RateLimiter) {
|
|
242
|
+
return (req: any, res: any, next: () => void) => {
|
|
243
|
+
const sessionId = req.headers['x-session-id'] || req.ip;
|
|
244
|
+
const result = rateLimiter.check(sessionId);
|
|
245
|
+
|
|
246
|
+
res.setHeader('X-RateLimit-Remaining', result.remaining);
|
|
247
|
+
res.setHeader('X-RateLimit-Reset', Math.ceil(Date.now() / 1000) + Math.ceil(result.resetIn / 1000));
|
|
248
|
+
|
|
249
|
+
if (!result.allowed) {
|
|
250
|
+
res.setHeader('Retry-After', result.retryAfter);
|
|
251
|
+
res.status(429).json({
|
|
252
|
+
jsonrpc: '2.0',
|
|
253
|
+
id: null,
|
|
254
|
+
error: {
|
|
255
|
+
code: -32000,
|
|
256
|
+
message: 'Rate limit exceeded',
|
|
257
|
+
data: { retryAfter: result.retryAfter },
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
rateLimiter.consume(sessionId);
|
|
264
|
+
next();
|
|
265
|
+
};
|
|
266
|
+
}
|