codecrucible-synth
Version:
Production-Ready AI Development Platform with Multi-Voice Synthesis, Smithery MCP Integration, Enterprise Security, and Zero-Timeout Reliability
496 lines • 17.4 kB
JavaScript
/**
* Enterprise Rate Limiting System
* Implements sliding window, fixed window, and token bucket algorithms with Redis backend
*/
import { EventEmitter } from 'events';
import { logger } from '../logger.js';
/**
* In-memory rate limit store (for development)
*/
class MemoryStore {
store = new Map();
cleanupInterval;
constructor() {
// Clean up expired entries every minute
this.cleanupInterval = setInterval(() => this.cleanup(), 60000);
// TODO: Store interval ID and call clearInterval in cleanup
}
async get(key) {
const entry = this.store.get(key);
if (!entry || entry.expiry < Date.now()) {
return null;
}
return entry.info;
}
async set(key, info, ttl) {
this.store.set(key, {
info,
expiry: Date.now() + ttl,
});
}
async increment(key) {
const existing = await this.get(key);
if (existing) {
existing.totalHits++;
existing.remainingHits = Math.max(0, existing.totalHitsLimit - existing.totalHits);
await this.set(key, existing, existing.resetTime.getTime() - Date.now());
return existing;
}
const newInfo = {
totalHits: 1,
totalHitsLimit: 100, // Default, should be overridden
remainingHits: 99,
msBeforeNext: 0,
resetTime: new Date(Date.now() + 60000), // Default 1 minute
};
await this.set(key, newInfo, 60000);
return newInfo;
}
async reset(key) {
this.store.delete(key);
}
cleanup() {
const now = Date.now();
for (const [key, entry] of this.store.entries()) {
if (entry.expiry < now) {
this.store.delete(key);
}
}
}
stop() {
clearInterval(this.cleanupInterval);
this.store.clear();
}
}
/**
* Token bucket implementation for rate limiting
*/
class TokenBucket {
tokens;
lastRefill;
config;
constructor(config) {
this.config = config;
this.tokens = config.capacity;
this.lastRefill = Date.now();
}
/**
* Try to consume tokens
*/
consume(tokens = 1) {
this.refill();
if (this.tokens >= tokens) {
this.tokens -= tokens;
return true;
}
return false;
}
/**
* Get available tokens
*/
getAvailableTokens() {
this.refill();
return this.tokens;
}
/**
* Refill tokens based on time elapsed
*/
refill() {
const now = Date.now();
const timePassed = now - this.lastRefill;
const intervalsElapsed = Math.floor(timePassed / this.config.refillInterval);
if (intervalsElapsed > 0) {
const tokensToAdd = intervalsElapsed * this.config.refillRate;
this.tokens = Math.min(this.config.capacity, this.tokens + tokensToAdd);
this.lastRefill = now;
}
}
}
/**
* Sliding window rate limiter
*/
class SlidingWindow {
windows = new Map();
config;
constructor(config) {
this.config = config;
}
/**
* Check if request is allowed
*/
isAllowed(key, limit) {
const now = Date.now();
const windowStart = now - this.config.windowSize;
// Get or create window for this key
let timestamps = this.windows.get(key) || [];
// Remove old timestamps
timestamps = timestamps.filter(timestamp => timestamp > windowStart);
// Check if under limit
if (timestamps.length < limit) {
timestamps.push(now);
this.windows.set(key, timestamps);
return true;
}
return false;
}
/**
* Get current count for key
*/
getCurrentCount(key) {
const now = Date.now();
const windowStart = now - this.config.windowSize;
const timestamps = this.windows.get(key) || [];
return timestamps.filter(timestamp => timestamp > windowStart).length;
}
/**
* Clean up old entries
*/
cleanup() {
const now = Date.now();
const cutoff = now - this.config.windowSize;
for (const [key, timestamps] of this.windows.entries()) {
const filtered = timestamps.filter(timestamp => timestamp > cutoff);
if (filtered.length === 0) {
this.windows.delete(key);
}
else {
this.windows.set(key, filtered);
}
}
}
}
/**
* Main rate limiter class
*/
export class RateLimiter extends EventEmitter {
config;
store;
tokenBuckets = new Map();
slidingWindows = new Map();
cleanupInterval;
constructor(config) {
super();
this.config = {
keyGenerator: this.defaultKeyGenerator,
skipSuccessfulRequests: false,
skipFailedRequests: false,
message: 'Too many requests, please try again later',
standardHeaders: true,
legacyHeaders: false,
...config,
};
this.store = config.store || new MemoryStore();
// Start cleanup for sliding windows
if (this.config.algorithm === 'sliding-window') {
this.cleanupInterval = setInterval(() => this.cleanupSlidingWindows(), 60000);
// TODO: Store interval ID and call clearInterval in cleanup
}
}
/**
* Create Express middleware
*/
middleware() {
return async (req, res, next) => {
try {
const key = this.config.keyGenerator(req);
const allowed = await this.checkLimit(key, req);
if (!allowed.allowed) {
// Set rate limit headers
if (this.config.standardHeaders) {
res.setHeader('X-RateLimit-Limit', this.config.maxRequests);
res.setHeader('X-RateLimit-Remaining', allowed.info.remainingHits);
res.setHeader('X-RateLimit-Reset', Math.ceil(allowed.info.resetTime.getTime() / 1000));
}
if (this.config.legacyHeaders) {
res.setHeader('X-Rate-Limit-Limit', this.config.maxRequests);
res.setHeader('X-Rate-Limit-Remaining', allowed.info.remainingHits);
res.setHeader('X-Rate-Limit-Reset', Math.ceil(allowed.info.resetTime.getTime() / 1000));
}
// Call custom handler if provided
if (this.config.onLimitReached) {
this.config.onLimitReached(req, res);
return;
}
// Emit event
this.emit('limit-reached', {
key,
ip: req.ip,
info: allowed.info,
});
// Log rate limit hit
logger.warn('Rate limit exceeded', {
key,
ip: req.ip,
userAgent: req.get('User-Agent'),
path: req.path,
method: req.method,
limit: this.config.maxRequests,
window: this.config.windowMs,
});
return res.status(429).json({
error: this.config.message,
retryAfter: Math.ceil(allowed.info.msBeforeNext / 1000),
limit: this.config.maxRequests,
remaining: allowed.info.remainingHits,
resetTime: allowed.info.resetTime.toISOString(),
});
}
// Set success headers
if (this.config.standardHeaders) {
res.setHeader('X-RateLimit-Limit', this.config.maxRequests);
res.setHeader('X-RateLimit-Remaining', allowed.info.remainingHits);
res.setHeader('X-RateLimit-Reset', Math.ceil(allowed.info.resetTime.getTime() / 1000));
}
// Track the request for potential cleanup
req.rateLimitInfo = allowed.info;
next();
}
catch (error) {
logger.error('Rate limiter error', error);
// Fail open - allow request if rate limiter fails
next();
}
};
}
/**
* Check if request is within rate limit
*/
async checkLimit(key, req) {
// Check skip conditions
if (req && this.config.skipIf && this.config.skipIf(req)) {
return {
allowed: true,
info: {
totalHits: 0,
totalHitsLimit: this.config.maxRequests,
remainingHits: this.config.maxRequests,
msBeforeNext: 0,
resetTime: new Date(Date.now() + this.config.windowMs),
},
};
}
switch (this.config.algorithm) {
case 'token-bucket':
return this.checkTokenBucket(key);
case 'sliding-window':
return this.checkSlidingWindow(key);
case 'fixed-window':
default:
return this.checkFixedWindow(key);
}
}
/**
* Check token bucket rate limit
*/
async checkTokenBucket(key) {
let bucket = this.tokenBuckets.get(key);
if (!bucket) {
bucket = new TokenBucket({
capacity: this.config.maxRequests,
refillRate: Math.ceil(this.config.maxRequests / (this.config.windowMs / 1000)),
refillInterval: 1000, // Refill every second
});
this.tokenBuckets.set(key, bucket);
}
const allowed = bucket.consume(1);
const availableTokens = bucket.getAvailableTokens();
const info = {
totalHits: this.config.maxRequests - availableTokens,
totalHitsLimit: this.config.maxRequests,
remainingHits: availableTokens,
msBeforeNext: allowed ? 0 : 1000,
resetTime: new Date(Date.now() + this.config.windowMs),
};
return { allowed, info };
}
/**
* Check sliding window rate limit
*/
async checkSlidingWindow(key) {
let window = this.slidingWindows.get(key);
if (!window) {
window = new SlidingWindow({
windowSize: this.config.windowMs,
subWindows: 10,
});
this.slidingWindows.set(key, window);
}
const allowed = window.isAllowed(key, this.config.maxRequests);
const currentCount = window.getCurrentCount(key);
const info = {
totalHits: currentCount,
totalHitsLimit: this.config.maxRequests,
remainingHits: Math.max(0, this.config.maxRequests - currentCount),
msBeforeNext: allowed ? 0 : this.config.windowMs,
resetTime: new Date(Date.now() + this.config.windowMs),
};
return { allowed, info };
}
/**
* Check fixed window rate limit
*/
async checkFixedWindow(key) {
const now = Date.now();
const windowStart = Math.floor(now / this.config.windowMs) * this.config.windowMs;
const windowKey = `${key}:${windowStart}`;
let info = await this.store.get(windowKey);
if (!info) {
info = {
totalHits: 0,
totalHitsLimit: this.config.maxRequests,
remainingHits: this.config.maxRequests,
msBeforeNext: this.config.windowMs,
resetTime: new Date(windowStart + this.config.windowMs),
};
}
const allowed = info.totalHits < this.config.maxRequests;
if (allowed) {
info = await this.store.increment(windowKey);
info.totalHitsLimit = this.config.maxRequests;
info.remainingHits = Math.max(0, this.config.maxRequests - info.totalHits);
info.resetTime = new Date(windowStart + this.config.windowMs);
info.msBeforeNext = windowStart + this.config.windowMs - now;
await this.store.set(windowKey, info, this.config.windowMs);
}
return { allowed, info };
}
/**
* Default key generator (IP-based)
*/
defaultKeyGenerator(req) {
return `rate_limit:${req.ip || 'unknown'}`;
}
/**
* Get rate limit status for a key
*/
async getStatus(key) {
switch (this.config.algorithm) {
case 'token-bucket': {
const bucket = this.tokenBuckets.get(key);
if (!bucket)
return null;
return {
totalHits: this.config.maxRequests - bucket.getAvailableTokens(),
totalHitsLimit: this.config.maxRequests,
remainingHits: bucket.getAvailableTokens(),
msBeforeNext: 0,
resetTime: new Date(Date.now() + this.config.windowMs),
};
}
case 'sliding-window': {
const window = this.slidingWindows.get(key);
if (!window)
return null;
const currentCount = window.getCurrentCount(key);
return {
totalHits: currentCount,
totalHitsLimit: this.config.maxRequests,
remainingHits: Math.max(0, this.config.maxRequests - currentCount),
msBeforeNext: 0,
resetTime: new Date(Date.now() + this.config.windowMs),
};
}
case 'fixed-window':
default:
return await this.store.get(key);
}
}
/**
* Reset rate limit for a key
*/
async reset(key) {
switch (this.config.algorithm) {
case 'token-bucket':
this.tokenBuckets.delete(key);
break;
case 'sliding-window':
this.slidingWindows.delete(key);
break;
case 'fixed-window':
default:
await this.store.reset(key);
break;
}
this.emit('rate-limit-reset', { key });
logger.info('Rate limit reset', { key });
}
/**
* Create key generator for specific field
*/
static createKeyGenerator(field) {
return (req) => {
switch (field) {
case 'ip':
return `rate_limit:ip:${req.ip || 'unknown'}`;
case 'userId':
return `rate_limit:user:${req.user?.userId || 'anonymous'}`;
case 'sessionId':
return `rate_limit:session:${req.sessionId || 'no-session'}`;
default:
return `rate_limit:${field}:${req[field] || 'unknown'}`;
}
};
}
/**
* Create different rate limiters for different endpoints
*/
static createTieredLimiters() {
return {
// Strict limits for auth endpoints
auth: new RateLimiter({
algorithm: 'fixed-window',
windowMs: 15 * 60 * 1000, // 15 minutes
maxRequests: 5,
keyGenerator: RateLimiter.createKeyGenerator('ip'),
message: 'Too many authentication attempts',
}),
// Moderate limits for API endpoints
api: new RateLimiter({
algorithm: 'sliding-window',
windowMs: 60 * 1000, // 1 minute
maxRequests: 100,
keyGenerator: RateLimiter.createKeyGenerator('userId'),
message: 'API rate limit exceeded',
}),
// Generous limits for general requests
general: new RateLimiter({
algorithm: 'token-bucket',
windowMs: 60 * 1000, // 1 minute
maxRequests: 1000,
keyGenerator: RateLimiter.createKeyGenerator('ip'),
message: 'Rate limit exceeded',
}),
// Very strict for admin operations
admin: new RateLimiter({
algorithm: 'fixed-window',
windowMs: 60 * 60 * 1000, // 1 hour
maxRequests: 10,
keyGenerator: RateLimiter.createKeyGenerator('userId'),
message: 'Admin operation rate limit exceeded',
}),
};
}
/**
* Clean up sliding windows
*/
cleanupSlidingWindows() {
for (const window of this.slidingWindows.values()) {
window.cleanup();
}
}
/**
* Stop rate limiter and clean up resources
*/
stop() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
this.tokenBuckets.clear();
this.slidingWindows.clear();
if (this.store instanceof MemoryStore) {
this.store.stop();
}
logger.info('Rate limiter stopped');
}
}
//# sourceMappingURL=rate-limiter.js.map