Split oversized frontend API client, email service, and admin/booking pages into focused modules while preserving import surfaces, and add Redis-backed queues, stale booking cleanup, stronger auth, and scale deployment configs. Co-authored-by: Cursor <cursoragent@cursor.com>
99 lines
3.0 KiB
TypeScript
99 lines
3.0 KiB
TypeScript
// Rate limiter abstraction with two implementations:
|
|
// - memory: per-process fixed window (the original behavior)
|
|
// - redis: shared fixed window across all instances (INCR + PEXPIRE)
|
|
//
|
|
// Selection happens once based on REDIS_URL. On any Redis error the limiter
|
|
// fails open (allows the request) so a Redis blip never takes the API down.
|
|
|
|
import { getRedis, isRedisEnabled } from '../redis.js';
|
|
|
|
export interface RateLimitResult {
|
|
allowed: boolean;
|
|
retryAfter?: number;
|
|
}
|
|
|
|
export interface RateLimiter {
|
|
readonly backend: 'memory' | 'redis';
|
|
consume(key: string, max: number, windowMs: number): Promise<RateLimitResult>;
|
|
}
|
|
|
|
// ==================== Memory implementation ====================
|
|
|
|
interface Bucket {
|
|
count: number;
|
|
resetAt: number;
|
|
}
|
|
|
|
class MemoryRateLimiter implements RateLimiter {
|
|
readonly backend = 'memory' as const;
|
|
private buckets = new Map<string, Bucket>();
|
|
|
|
constructor() {
|
|
// Periodically drop expired buckets so the Map does not grow unbounded.
|
|
const cleanup = setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [key, bucket] of this.buckets) {
|
|
if (now > bucket.resetAt) this.buckets.delete(key);
|
|
}
|
|
}, 60_000);
|
|
(cleanup as any).unref?.();
|
|
}
|
|
|
|
async consume(key: string, max: number, windowMs: number): Promise<RateLimitResult> {
|
|
const now = Date.now();
|
|
const bucket = this.buckets.get(key);
|
|
|
|
if (!bucket || now > bucket.resetAt) {
|
|
this.buckets.set(key, { count: 1, resetAt: now + windowMs });
|
|
return { allowed: true };
|
|
}
|
|
|
|
bucket.count++;
|
|
if (bucket.count > max) {
|
|
return { allowed: false, retryAfter: Math.ceil((bucket.resetAt - now) / 1000) };
|
|
}
|
|
return { allowed: true };
|
|
}
|
|
}
|
|
|
|
// ==================== Redis implementation ====================
|
|
|
|
class RedisRateLimiter implements RateLimiter {
|
|
readonly backend = 'redis' as const;
|
|
|
|
async consume(key: string, max: number, windowMs: number): Promise<RateLimitResult> {
|
|
const redis = getRedis();
|
|
if (!redis) return { allowed: true };
|
|
|
|
const redisKey = `rl:${key}`;
|
|
try {
|
|
const count = await redis.incr(redisKey);
|
|
if (count === 1) {
|
|
// First hit in this window: set the expiry that defines the window.
|
|
await redis.pexpire(redisKey, windowMs);
|
|
}
|
|
if (count > max) {
|
|
const ttl = await redis.pttl(redisKey);
|
|
const retryAfter = ttl > 0 ? Math.ceil(ttl / 1000) : Math.ceil(windowMs / 1000);
|
|
return { allowed: false, retryAfter };
|
|
}
|
|
return { allowed: true };
|
|
} catch (err: any) {
|
|
// Fail open: never block traffic because Redis is unavailable.
|
|
console.error('[rateLimiter] redis error, allowing request:', err?.message || err);
|
|
return { allowed: true };
|
|
}
|
|
}
|
|
}
|
|
|
|
// ==================== Selection ====================
|
|
|
|
let instance: RateLimiter | null = null;
|
|
|
|
export function getRateLimiter(): RateLimiter {
|
|
if (!instance) {
|
|
instance = isRedisEnabled() ? new RedisRateLimiter() : new MemoryRateLimiter();
|
|
}
|
|
return instance;
|
|
}
|