/** * Cache and rate limiting utility functions */ import type { RecommendRequest, Env } from '../types'; import { LIMITS } from '../config'; /** * Simple hash function for strings */ export function hashString(str: string): string { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32-bit integer } // Use >>> 0 to convert to unsigned 32-bit integer return (hash >>> 0).toString(36); } /** * Sanitize special characters for cache key */ export function sanitizeCacheValue(value: string): string { // Use URL-safe base64 encoding to avoid collisions try { return btoa(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); } catch { // Fallback for non-ASCII characters return encodeURIComponent(value).replace(/[%]/g, '_'); } } /** * Generate cache key from request parameters */ export function generateCacheKey(req: RecommendRequest): string { // Don't mutate original arrays - create sorted copies const sortedStack = [...req.tech_stack].sort(); const sanitizedStack = sortedStack.map(sanitizeCacheValue).join(','); // Hash use_case to avoid special characters and length issues const useCaseHash = hashString(req.use_case); const parts = [ `stack:${sanitizedStack}`, `users:${req.expected_users}`, `case:${useCaseHash}`, ]; if (req.traffic_pattern) { parts.push(`traffic:${req.traffic_pattern}`); } if (req.budget_limit) { parts.push(`budget:${req.budget_limit}`); } // Include region preference in cache key if (req.region_preference && req.region_preference.length > 0) { const sortedRegions = [...req.region_preference].sort(); const sanitizedRegions = sortedRegions.map(sanitizeCacheValue).join(','); parts.push(`region:${sanitizedRegions}`); } // Include language in cache key if (req.lang) { parts.push(`lang:${req.lang}`); } // Include CDN options in cache key if (req.cdn_enabled !== undefined) { parts.push(`cdn:${req.cdn_enabled}`); } if (req.cdn_cache_hit_rate !== undefined) { parts.push(`cdnrate:${req.cdn_cache_hit_rate}`); } return `recommend:${parts.join('|')}`; } // In-memory fallback for rate limiting when CACHE KV is unavailable const inMemoryRateLimit = new Map(); const MAX_IN_MEMORY_ENTRIES = 10000; /** * Clean up expired entries from in-memory rate limit map */ function cleanupExpiredEntries(now: number): void { for (const [key, record] of inMemoryRateLimit) { if (record.resetTime < now) { inMemoryRateLimit.delete(key); } } } /** * Rate limiting check using KV storage with in-memory fallback */ export async function checkRateLimit(clientIP: string, env: Env): Promise<{ allowed: boolean; requestId: string }> { const requestId = crypto.randomUUID(); const now = Date.now(); const maxRequests = LIMITS.RATE_LIMIT_MAX_REQUESTS; const windowMs = LIMITS.RATE_LIMIT_WINDOW_MS; // Use in-memory fallback if CACHE unavailable if (!env.CACHE) { // Cleanup expired entries if map is getting too large if (inMemoryRateLimit.size >= MAX_IN_MEMORY_ENTRIES) { cleanupExpiredEntries(now); // If still too large after cleanup, remove oldest 10% by resetTime if (inMemoryRateLimit.size >= MAX_IN_MEMORY_ENTRIES) { const entries = Array.from(inMemoryRateLimit.entries()) .sort((a, b) => a[1].resetTime - b[1].resetTime) .slice(0, Math.floor(MAX_IN_MEMORY_ENTRIES * 0.1)); entries.forEach(([key]) => inMemoryRateLimit.delete(key)); } } const record = inMemoryRateLimit.get(clientIP); if (!record || record.resetTime < now) { // New window or expired inMemoryRateLimit.set(clientIP, { count: 1, resetTime: now + windowMs }); return { allowed: true, requestId }; } if (record.count >= maxRequests) { return { allowed: false, requestId }; } // Increment count record.count++; return { allowed: true, requestId }; } // KV-based rate limiting const kvKey = `ratelimit:${clientIP}`; try { const recordJson = await env.CACHE.get(kvKey); const record = recordJson ? JSON.parse(recordJson) as { count: number; resetTime: number } : null; if (!record || record.resetTime < now) { // New window await env.CACHE.put( kvKey, JSON.stringify({ count: 1, resetTime: now + windowMs }), { expirationTtl: 60 } ); return { allowed: true, requestId }; } if (record.count >= maxRequests) { return { allowed: false, requestId }; } // Increment count record.count++; await env.CACHE.put( kvKey, JSON.stringify(record), { expirationTtl: 60 } ); return { allowed: true, requestId }; } catch (error) { console.error('[RateLimit] KV error:', error); // On error, deny the request (fail closed) for security return { allowed: false, requestId }; } }