Files
cloud-orchestrator/src/index.ts
kappa dcc8be6f5b refactor: 서버 추천 핵심 로직 개선
## vCPU 계산 로직 개선
- 카테고리 합산 → 병목 분석(Max)으로 변경
- nginx+nodejs+postgresql 조합: 16 vCPU → 10 vCPU
- 요청 흐름(web→app→db)에서 가장 느린 컴포넌트가 병목

## 메모리 계산 로직 개선
- memory_intensive 서비스: Max → 합산으로 변경
- java+elasticsearch+redis: 8GB → 11GB (실제 동시 실행 반영)

## 대역폭 추정 개선
- 사용자 활동률(activeUserRatio) 추가
- video: 30%, gaming: 50%, e-commerce: 40%
- 비디오 1000명: 257TB → ~80TB/월 (현실적)

## DAU 변환 비율 개선
- 용도별 차등 적용 (getDauMultiplier)
- gaming: 10-20배, blog: 30-50배, saas: 5-10배

## aliases 대소문자 수정
- LOWER(aliases) LIKE로 case-insensitive 매칭

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 15:11:24 +09:00

2069 lines
71 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Cloudflare Worker - Server Recommendation System
*
* AI-powered server recommendation service using Workers AI, D1, and KV.
*/
interface Env {
AI: Ai; // Legacy - kept for fallback
DB: D1Database;
CACHE: KVNamespace;
OPENAI_API_KEY: string;
}
interface ValidationError {
error: string;
missing_fields?: string[];
invalid_fields?: { field: string; reason: string }[];
schema: Record<string, string>;
example: Record<string, any>;
}
interface RecommendRequest {
tech_stack: string[];
expected_users: number;
use_case: string;
traffic_pattern?: 'steady' | 'spiky' | 'growing';
region_preference?: string[];
budget_limit?: number;
provider_filter?: string[]; // Filter by specific providers (e.g., ["Linode", "Vultr"])
lang?: 'en' | 'zh' | 'ja' | 'ko'; // Response language
}
interface Server {
id: number;
provider_name: string;
instance_id: string;
instance_name: string;
vcpu: number;
memory_mb: number;
memory_gb: number;
storage_gb: number;
network_speed_gbps: number | null;
instance_family: string | null;
gpu_count: number;
gpu_type: string | null;
monthly_price: number;
currency: 'USD' | 'KRW';
region_name: string;
region_code: string;
}
interface RecommendationResult {
server: Server;
score: number;
analysis: {
tech_fit: string;
capacity: string;
cost_efficiency: string;
scalability: string;
};
estimated_capacity: {
max_daily_users?: number;
max_concurrent_users: number;
requests_per_second: number;
};
benchmark_reference?: BenchmarkReference;
vps_benchmark_reference?: {
plan_name: string;
geekbench_single: number;
geekbench_multi: number;
monthly_price_usd: number;
performance_per_dollar: number;
};
}
interface BenchmarkReference {
processor_name: string;
benchmarks: {
name: string;
category: string;
score: number;
percentile: number;
}[];
}
interface BenchmarkData {
id: number;
processor_name: string;
benchmark_name: string;
category: string;
score: number;
percentile: number;
cores: number | null;
}
interface VPSBenchmark {
id: number;
provider_name: string;
plan_name: string;
cpu_type: string;
vcpu: number;
memory_gb: number;
country_code: string;
geekbench_single: number;
geekbench_multi: number;
geekbench_total: number;
monthly_price_usd: number;
performance_per_dollar: number;
geekbench_version: string;
gb6_single_normalized: number;
gb6_multi_normalized: number;
}
interface TechSpec {
id: number;
name: string;
category: string;
vcpu_per_users: number;
vcpu_per_users_max: number | null;
min_memory_mb: number;
max_memory_mb: number | null;
description: string | null;
aliases: string | null;
is_memory_intensive: boolean;
is_cpu_intensive: boolean;
}
interface BandwidthEstimate {
monthly_gb: number;
monthly_tb: number;
daily_gb: number;
category: 'light' | 'moderate' | 'heavy' | 'very_heavy';
description: string;
estimated_dau_min: number; // Daily Active Users estimate (min)
estimated_dau_max: number; // Daily Active Users estimate (max)
}
/**
* Get DAU multiplier based on use case (how many daily active users per concurrent user)
*/
function getDauMultiplier(useCase: string): { min: number; max: number } {
const useCaseLower = useCase.toLowerCase();
if (/game|gaming|minecraft|게임/.test(useCaseLower)) {
// Gaming: users stay online longer, higher concurrent ratio
return { min: 10, max: 20 };
} else if (/blog|news|static|블로그|뉴스|포트폴리오/.test(useCaseLower)) {
// Blog/Static: short visits, lower concurrent ratio
return { min: 30, max: 50 };
} else if (/api|saas|backend|서비스|백엔드/.test(useCaseLower)) {
// SaaS/API: business hours concentration
return { min: 5, max: 10 };
} else if (/e-?commerce|shop|store|쇼핑|커머스|온라인몰/.test(useCaseLower)) {
// E-commerce: moderate session lengths
return { min: 20, max: 30 };
} else if (/forum|community|board|게시판|커뮤니티|포럼/.test(useCaseLower)) {
// Forum/Community: moderate engagement
return { min: 15, max: 25 };
} else if (/video|stream|media|youtube|netflix|vod|동영상|스트리밍|미디어/.test(useCaseLower)) {
// Video/Streaming: medium-long sessions
return { min: 8, max: 12 };
} else {
// Default: general web app
return { min: 10, max: 14 };
}
}
/**
* Get active user ratio (what percentage of DAU actually performs the bandwidth-heavy action)
*/
function getActiveUserRatio(useCase: string): number {
const useCaseLower = useCase.toLowerCase();
if (/video|stream|media|youtube|netflix|vod|동영상|스트리밍|미디어/.test(useCaseLower)) {
// Video/Streaming: only 30% of DAU actually stream
return 0.3;
} else if (/game|gaming|minecraft|게임/.test(useCaseLower)) {
// Gaming: 50% of DAU are active players
return 0.5;
} else if (/e-?commerce|shop|store|쇼핑|커머스|온라인몰/.test(useCaseLower)) {
// E-commerce: 40% browse products
return 0.4;
} else if (/api|saas|backend|서비스|백엔드/.test(useCaseLower)) {
// API/SaaS: 60% active usage
return 0.6;
} else if (/forum|community|board|게시판|커뮤니티|포럼/.test(useCaseLower)) {
// Forum/Community: 50% active posting/reading
return 0.5;
} else if (/blog|static|portfolio|블로그|포트폴리오/.test(useCaseLower)) {
// Static/Blog: 30% active readers
return 0.3;
} else {
// Default: 50% active
return 0.5;
}
}
/**
* Estimate monthly bandwidth based on concurrent users and use case
*
* Formula: concurrent_users × dau_multiplier × active_ratio × avg_page_size_mb × requests_per_session × active_hours × 30
*
* Multipliers by use case:
* - Static site/blog: 0.5 MB/request, 5 requests/session
* - API/SaaS: 0.1 MB/request, 50 requests/session
* - E-commerce: 1 MB/request, 20 requests/session
* - Media/Video: 50 MB/request, 10 requests/session
* - Gaming: 0.05 MB/request, 1000 requests/session
* - Forum/Community: 0.3 MB/request, 30 requests/session
*/
function estimateBandwidth(concurrentUsers: number, useCase: string, trafficPattern?: string): BandwidthEstimate {
const useCaseLower = useCase.toLowerCase();
// Determine use case category and bandwidth multiplier
let avgPageSizeMB: number;
let requestsPerSession: number;
let categoryMultiplier: number;
if (/video|stream|media|youtube|netflix|vod|동영상|스트리밍|미디어/.test(useCaseLower)) {
// Video/Streaming: very heavy bandwidth
avgPageSizeMB = 50; // 50MB per video segment
requestsPerSession = 10;
categoryMultiplier = 1.5;
} else if (/download|file|storage|cdn|파일|다운로드|저장소/.test(useCaseLower)) {
// File downloads: heavy bandwidth
avgPageSizeMB = 20;
requestsPerSession = 5;
categoryMultiplier = 1.3;
} else if (/game|gaming|minecraft|게임/.test(useCaseLower)) {
// Gaming: many small packets
avgPageSizeMB = 0.05;
requestsPerSession = 1000;
categoryMultiplier = 1.2;
} else if (/e-?commerce|shop|store|쇼핑|커머스|온라인몰/.test(useCaseLower)) {
// E-commerce: images heavy
avgPageSizeMB = 1;
requestsPerSession = 20;
categoryMultiplier = 1.0;
} else if (/api|saas|backend|서비스|백엔드/.test(useCaseLower)) {
// API/SaaS: many small requests
avgPageSizeMB = 0.1;
requestsPerSession = 50;
categoryMultiplier = 0.8;
} else if (/forum|community|board|게시판|커뮤니티|포럼/.test(useCaseLower)) {
// Forum/Community: moderate
avgPageSizeMB = 0.3;
requestsPerSession = 30;
categoryMultiplier = 0.9;
} else if (/blog|static|portfolio|블로그|포트폴리오/.test(useCaseLower)) {
// Static/Blog: light
avgPageSizeMB = 0.5;
requestsPerSession = 5;
categoryMultiplier = 0.5;
} else {
// Default: moderate web app
avgPageSizeMB = 0.5;
requestsPerSession = 15;
categoryMultiplier = 1.0;
}
// Traffic pattern adjustment
let patternMultiplier = 1.0;
if (trafficPattern === 'spiky') {
patternMultiplier = 1.5; // Account for peak loads
} else if (trafficPattern === 'growing') {
patternMultiplier = 1.3; // Headroom for growth
}
// Assume 8 active hours per day average (varies by use case)
const activeHoursPerDay = 8;
// Calculate DAU estimate from concurrent users with use-case-specific multipliers
const dauMultiplier = getDauMultiplier(useCase);
const estimatedDauMin = Math.round(concurrentUsers * dauMultiplier.min);
const estimatedDauMax = Math.round(concurrentUsers * dauMultiplier.max);
// Calculate daily bandwidth with active user ratio
const dailyUniqueVisitors = Math.round((estimatedDauMin + estimatedDauMax) / 2);
const activeUserRatio = getActiveUserRatio(useCase);
const activeDau = Math.round(dailyUniqueVisitors * activeUserRatio);
console.log(`[Bandwidth] DAU: ${dailyUniqueVisitors} (${dauMultiplier.min}-${dauMultiplier.max}x), Active DAU: ${activeDau} (${(activeUserRatio * 100).toFixed(0)}%)`);
const dailyBandwidthMB = activeDau * avgPageSizeMB * requestsPerSession * categoryMultiplier * patternMultiplier;
const dailyBandwidthGB = dailyBandwidthMB / 1024;
// Monthly bandwidth
const monthlyGB = dailyBandwidthGB * 30;
const monthlyTB = monthlyGB / 1024;
// Categorize
let category: 'light' | 'moderate' | 'heavy' | 'very_heavy';
let description: string;
if (monthlyTB < 0.5) {
category = 'light';
description = `~${Math.round(monthlyGB)} GB/month - Most VPS plans include sufficient bandwidth`;
} else if (monthlyTB < 2) {
category = 'moderate';
description = `~${monthlyTB.toFixed(1)} TB/month - Check provider bandwidth limits`;
} else if (monthlyTB < 6) {
category = 'heavy';
description = `~${monthlyTB.toFixed(1)} TB/month - Prefer providers with generous bandwidth (Linode: 1-6TB included)`;
} else {
category = 'very_heavy';
description = `~${monthlyTB.toFixed(1)} TB/month - HIGH BANDWIDTH: Linode strongly recommended for cost savings`;
}
return {
monthly_gb: Math.round(monthlyGB),
monthly_tb: Math.round(monthlyTB * 10) / 10,
daily_gb: Math.round(dailyBandwidthGB * 10) / 10,
category,
description,
estimated_dau_min: estimatedDauMin,
estimated_dau_max: estimatedDauMax
};
}
interface AIRecommendationResponse {
recommendations: Array<{
server_id: number;
score: number;
analysis: {
tech_fit: string;
capacity: string;
cost_efficiency: string;
scalability: string;
};
estimated_capacity: {
max_daily_users?: number;
max_concurrent_users: number;
requests_per_second: number;
};
}>;
infrastructure_tips?: string[];
}
/**
* i18n Messages for multi-language support
*/
const i18n: Record<string, {
missingFields: string;
invalidFields: string;
schema: Record<string, string>;
example: Record<string, any>;
aiLanguageInstruction: string;
}> = {
en: {
missingFields: 'Missing required fields',
invalidFields: 'Invalid field values',
schema: {
tech_stack: "(required) string[] - e.g. ['nginx', 'nodejs']",
expected_users: "(required) number - expected concurrent users, e.g. 1000",
use_case: "(required) string - e.g. 'e-commerce website'",
traffic_pattern: "(optional) 'steady' | 'spiky' | 'growing'",
region_preference: "(optional) string[] - e.g. ['korea', 'japan']",
budget_limit: "(optional) number - max monthly USD",
provider_filter: "(optional) string[] - e.g. ['linode', 'vultr']",
lang: "(optional) 'en' | 'zh' | 'ja' | 'ko' - response language"
},
example: {
tech_stack: ["nginx", "nodejs", "postgresql"],
expected_users: 5000,
use_case: "SaaS application"
},
aiLanguageInstruction: 'Respond in English.'
},
zh: {
missingFields: '缺少必填字段',
invalidFields: '字段值无效',
schema: {
tech_stack: "(必填) string[] - 例如 ['nginx', 'nodejs']",
expected_users: "(必填) number - 预计同时在线用户数,例如 1000",
use_case: "(必填) string - 例如 '电商网站'",
traffic_pattern: "(可选) 'steady' | 'spiky' | 'growing'",
region_preference: "(可选) string[] - 例如 ['korea', 'japan']",
budget_limit: "(可选) number - 每月最高预算(美元)",
provider_filter: "(可选) string[] - 例如 ['linode', 'vultr']",
lang: "(可选) 'en' | 'zh' | 'ja' | 'ko' - 响应语言"
},
example: {
tech_stack: ["nginx", "nodejs", "postgresql"],
expected_users: 5000,
use_case: "SaaS应用程序"
},
aiLanguageInstruction: 'Respond in Chinese (Simplified). All analysis text must be in Chinese.'
},
ja: {
missingFields: '必須フィールドがありません',
invalidFields: 'フィールド値が無効です',
schema: {
tech_stack: "(必須) string[] - 例: ['nginx', 'nodejs']",
expected_users: "(必須) number - 予想同時接続ユーザー数、例: 1000",
use_case: "(必須) string - 例: 'ECサイト'",
traffic_pattern: "(任意) 'steady' | 'spiky' | 'growing'",
region_preference: "(任意) string[] - 例: ['korea', 'japan']",
budget_limit: "(任意) number - 月額予算上限(USD)",
provider_filter: "(任意) string[] - 例: ['linode', 'vultr']",
lang: "(任意) 'en' | 'zh' | 'ja' | 'ko' - 応答言語"
},
example: {
tech_stack: ["nginx", "nodejs", "postgresql"],
expected_users: 5000,
use_case: "SaaSアプリケーション"
},
aiLanguageInstruction: 'Respond in Japanese. All analysis text must be in Japanese.'
},
ko: {
missingFields: '필수 필드가 누락되었습니다',
invalidFields: '필드 값이 잘못되었습니다',
schema: {
tech_stack: "(필수) string[] - 예: ['nginx', 'nodejs']",
expected_users: "(필수) number - 예상 동시 접속자 수, 예: 1000",
use_case: "(필수) string - 예: '이커머스 웹사이트'",
traffic_pattern: "(선택) 'steady' | 'spiky' | 'growing'",
region_preference: "(선택) string[] - 예: ['korea', 'japan']",
budget_limit: "(선택) number - 월 예산 한도(원화, KRW)",
provider_filter: "(선택) string[] - 예: ['linode', 'vultr']",
lang: "(선택) 'en' | 'zh' | 'ja' | 'ko' - 응답 언어"
},
example: {
tech_stack: ["nginx", "nodejs", "postgresql"],
expected_users: 5000,
use_case: "SaaS 애플리케이션"
},
aiLanguageInstruction: 'Respond in Korean. All analysis text must be in Korean.'
}
};
/**
* Helper function to get allowed CORS origin
*/
function getAllowedOrigin(request: Request): string {
const allowedOrigins = [
'https://server-recommend.kappa-d8e.workers.dev',
];
const origin = request.headers.get('Origin');
if (origin && allowedOrigins.includes(origin)) {
return origin;
}
// Allow requests without Origin header (non-browser, curl, etc.)
if (!origin) {
return '*';
}
return allowedOrigins[0];
}
/**
* Rate limiting check using KV storage
*/
async function checkRateLimit(clientIP: string, env: Env): Promise<{ allowed: boolean; requestId: string }> {
const requestId = crypto.randomUUID();
// If CACHE is not configured, allow the request
if (!env.CACHE) {
return { allowed: true, requestId };
}
const now = Date.now();
const maxRequests = 60;
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 + 60000 }),
{ 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, allow the request (fail open)
return { allowed: true, requestId };
}
}
/**
* Main request handler
*/
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const requestId = crypto.randomUUID();
try {
const url = new URL(request.url);
const path = url.pathname;
// Rate limiting (except for health checks)
if (path !== '/api/health') {
const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown';
const rateCheck = await checkRateLimit(clientIP, env);
if (!rateCheck.allowed) {
const origin = getAllowedOrigin(request);
return jsonResponse(
{ error: 'Too many requests', request_id: rateCheck.requestId },
429,
{
'Access-Control-Allow-Origin': origin,
'Vary': 'Origin',
}
);
}
}
// CORS headers for all responses
const origin = getAllowedOrigin(request);
const corsHeaders = {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Vary': 'Origin',
};
// Handle preflight requests
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
// Route handling
if (path === '/api/health') {
return handleHealth(corsHeaders);
}
if (path === '/api/servers' && request.method === 'GET') {
return handleGetServers(request, env, corsHeaders);
}
if (path === '/api/recommend' && request.method === 'POST') {
return handleRecommend(request, env, corsHeaders);
}
return jsonResponse(
{ error: 'Not found', request_id: requestId },
404,
corsHeaders
);
} catch (error) {
console.error('[Worker] Unhandled error:', error);
const origin = getAllowedOrigin(request);
return jsonResponse(
{
error: 'Internal server error',
request_id: requestId,
},
500,
{
'Access-Control-Allow-Origin': origin,
'Vary': 'Origin',
}
);
}
},
};
/**
* Health check endpoint
*/
function handleHealth(corsHeaders: Record<string, string>): Response {
return jsonResponse(
{
status: 'ok',
timestamp: new Date().toISOString(),
service: 'server-recommend',
},
200,
corsHeaders
);
}
/**
* GET /api/servers - Server list with filtering
*/
async function handleGetServers(
request: Request,
env: Env,
corsHeaders: Record<string, string>
): Promise<Response> {
try {
const url = new URL(request.url);
const provider = url.searchParams.get('provider');
const minCpu = url.searchParams.get('minCpu');
const minMemory = url.searchParams.get('minMemory');
const region = url.searchParams.get('region');
console.log('[GetServers] Query params:', {
provider,
minCpu,
minMemory,
region,
});
// Build SQL query dynamically
let query = `
SELECT
it.id,
p.display_name as provider_name,
it.instance_id,
it.instance_name,
it.vcpu,
it.memory_mb,
ROUND(it.memory_mb / 1024.0, 1) as memory_gb,
it.storage_gb,
it.network_speed_gbps,
it.instance_family,
it.gpu_count,
it.gpu_type,
MIN(pr.monthly_price) as monthly_price,
MIN(r.region_name) as region_name,
MIN(r.region_code) as region_code
FROM instance_types it
JOIN providers p ON it.provider_id = p.id
JOIN pricing pr ON pr.instance_type_id = it.id
JOIN regions r ON pr.region_id = r.id
WHERE p.id IN (1, 2) -- Linode, Vultr only
AND (
-- Korea (Seoul)
r.region_code IN ('icn', 'ap-northeast-2') OR
LOWER(r.region_name) LIKE '%seoul%' OR
-- Japan (Tokyo, Osaka)
r.region_code IN ('nrt', 'itm', 'ap-northeast-1', 'ap-northeast-3') OR
LOWER(r.region_code) LIKE '%tyo%' OR
LOWER(r.region_code) LIKE '%osa%' OR
LOWER(r.region_name) LIKE '%tokyo%' OR
LOWER(r.region_name) LIKE '%osaka%' OR
-- Singapore
r.region_code IN ('sgp', 'ap-southeast-1') OR
LOWER(r.region_code) LIKE '%sin%' OR
LOWER(r.region_code) LIKE '%sgp%' OR
LOWER(r.region_name) LIKE '%singapore%'
)
`;
const params: (string | number)[] = [];
if (provider) {
query += ` AND p.name = ?`;
params.push(provider);
}
if (minCpu) {
const parsedCpu = parseInt(minCpu, 10);
if (isNaN(parsedCpu)) {
return jsonResponse({ error: 'Invalid minCpu parameter' }, 400, corsHeaders);
}
query += ` AND it.vcpu >= ?`;
params.push(parsedCpu);
}
if (minMemory) {
const parsedMemory = parseInt(minMemory, 10);
if (isNaN(parsedMemory)) {
return jsonResponse({ error: 'Invalid minMemory parameter' }, 400, corsHeaders);
}
query += ` AND it.memory_mb >= ?`;
params.push(parsedMemory * 1024);
}
if (region) {
query += ` AND r.region_code = ?`;
params.push(region);
}
query += ` GROUP BY it.id ORDER BY MIN(pr.monthly_price) ASC LIMIT 100`;
const result = await env.DB.prepare(query).bind(...params).all();
if (!result.success) {
throw new Error('Database query failed');
}
// Validate each result with type guard
const servers = (result.results as unknown[]).filter(isValidServer);
const invalidCount = result.results.length - servers.length;
if (invalidCount > 0) {
console.warn(`[GetServers] Filtered out ${invalidCount} invalid server records`);
}
console.log('[GetServers] Found servers:', servers.length);
return jsonResponse(
{
servers,
count: servers.length,
filters: { provider, minCpu, minMemory, region },
},
200,
corsHeaders
);
} catch (error) {
console.error('[GetServers] Error:', error);
const requestId = crypto.randomUUID();
return jsonResponse(
{
error: 'Failed to retrieve servers',
request_id: requestId,
},
500,
corsHeaders
);
}
}
/**
* POST /api/recommend - AI-powered server recommendation
*/
async function handleRecommend(
request: Request,
env: Env,
corsHeaders: Record<string, string>
): Promise<Response> {
const requestId = crypto.randomUUID();
try {
// Parse and validate request
const body = await request.json() as RecommendRequest;
const lang = body.lang || 'en';
const validationError = validateRecommendRequest(body, lang);
if (validationError) {
return jsonResponse(validationError, 400, corsHeaders);
}
console.log('[Recommend] Request summary:', {
tech_stack_count: body.tech_stack.length,
expected_users: body.expected_users,
use_case_length: body.use_case.length,
traffic_pattern: body.traffic_pattern,
has_region_pref: !!body.region_preference,
has_budget: !!body.budget_limit,
has_provider_filter: !!body.provider_filter,
lang: lang,
});
// Generate cache key
const cacheKey = generateCacheKey(body);
console.log('[Recommend] Cache key:', cacheKey);
// Check cache (optional - may not be configured)
if (env.CACHE) {
const cached = await env.CACHE.get(cacheKey);
if (cached) {
console.log('[Recommend] Cache hit');
return jsonResponse(
{ ...JSON.parse(cached), cached: true },
200,
corsHeaders
);
}
}
console.log('[Recommend] Cache miss or disabled');
// Phase 1: Execute independent queries in parallel
const [techSpecs, benchmarkDataAll] = await Promise.all([
queryTechSpecs(env.DB, body.tech_stack),
queryBenchmarkData(env.DB, body.tech_stack).catch(err => {
console.warn('[Recommend] Benchmark data unavailable:', err.message);
return [] as BenchmarkData[];
}),
]);
console.log('[Recommend] Tech specs matched:', techSpecs.length);
console.log('[Recommend] Benchmark data points (initial):', benchmarkDataAll.length);
// Calculate minimum memory with proper aggregation
// Memory-intensive services (Java, Elasticsearch, Redis): sum their memory requirements
// Non-memory-intensive services: 256MB overhead each
const memoryIntensiveSpecs = techSpecs.filter(s => s.is_memory_intensive);
const otherSpecs = techSpecs.filter(s => !s.is_memory_intensive);
let minMemoryMb: number | undefined;
if (memoryIntensiveSpecs.length > 0 || otherSpecs.length > 0) {
// Sum memory-intensive requirements
const memoryIntensiveSum = memoryIntensiveSpecs.reduce((sum, s) => sum + s.min_memory_mb, 0);
// Add 256MB overhead per non-memory-intensive service
const otherOverhead = otherSpecs.length * 256;
minMemoryMb = memoryIntensiveSum + otherOverhead;
console.log(`[Recommend] Memory calculation: ${memoryIntensiveSpecs.length} memory-intensive (${(memoryIntensiveSum/1024).toFixed(1)}GB) + ${otherSpecs.length} other services (${(otherOverhead/1024).toFixed(1)}GB) = ${(minMemoryMb/1024).toFixed(1)}GB total`);
}
// Calculate minimum vCPU with category-based weighting
// Different tech categories have different bottleneck characteristics
let minVcpu: number | undefined;
if (techSpecs.length > 0) {
// Group specs by category
const categoryWeights: Record<string, number> = {
'web_server': 0.1, // nginx, apache: reverse proxy uses minimal resources
'runtime': 1.0, // nodejs, php, python: actual computation
'database': 1.0, // mysql, postgresql, mongodb: major bottleneck
'cache': 0.5, // redis, memcached: supporting role
'search': 0.8, // elasticsearch: CPU-intensive but not always primary
'container': 0.3, // docker: orchestration overhead
'messaging': 0.5, // rabbitmq, kafka: I/O bound
'default': 0.7 // unknown categories
};
// Calculate weighted vCPU requirements per category
const categoryRequirements = new Map<string, number>();
for (const spec of techSpecs) {
const category = spec.category || 'default';
const weight = categoryWeights[category] || categoryWeights['default'];
const vcpuNeeded = Math.ceil(body.expected_users / spec.vcpu_per_users);
const weightedVcpu = vcpuNeeded * weight;
const existing = categoryRequirements.get(category) || 0;
// Take max within same category (not additive)
categoryRequirements.set(category, Math.max(existing, weightedVcpu));
console.log(`[Recommend] ${spec.name} (${category}): ${vcpuNeeded} vCPU × ${weight} weight = ${weightedVcpu.toFixed(1)} weighted vCPU`);
}
// Find bottleneck: use MAX across categories, not SUM
// Request flow (web_server → runtime → database) means the slowest component is the bottleneck
// SUM would over-provision since components process the SAME requests sequentially
let maxWeightedVcpu = 0;
let bottleneckCategory = '';
for (const [category, vcpu] of categoryRequirements) {
console.log(`[Recommend] Category '${category}': ${vcpu.toFixed(1)} weighted vCPU`);
if (vcpu > maxWeightedVcpu) {
maxWeightedVcpu = vcpu;
bottleneckCategory = category;
}
}
minVcpu = Math.max(Math.ceil(maxWeightedVcpu), 1); // At least 1 vCPU
console.log(`[Recommend] Bottleneck: '${bottleneckCategory}' with ${maxWeightedVcpu.toFixed(1)} weighted vCPU → ${minVcpu} vCPU (for ${body.expected_users} users)`);
}
// Calculate bandwidth estimate for provider filtering
const bandwidthEstimate = estimateBandwidth(body.expected_users, body.use_case, body.traffic_pattern);
console.log(`[Recommend] Bandwidth estimate: ${bandwidthEstimate.monthly_tb >= 1 ? bandwidthEstimate.monthly_tb + ' TB' : bandwidthEstimate.monthly_gb + ' GB'}/month (${bandwidthEstimate.category})`);
// Phase 2: Query candidate servers (depends on minMemoryMb, minVcpu, bandwidth)
const candidates = await queryCandidateServers(env.DB, body, minMemoryMb, minVcpu, bandwidthEstimate, lang);
console.log('[Recommend] Candidate servers:', candidates.length);
if (candidates.length === 0) {
return jsonResponse(
{
error: 'No servers found matching your requirements',
recommendations: [],
request_id: requestId,
},
200,
corsHeaders
);
}
// Calculate average specs from candidates for VPS benchmark queries
const avgCores = Math.round(
candidates.reduce((sum, s) => sum + s.vcpu, 0) / candidates.length
);
const avgMemory = Math.round(
candidates.reduce((sum, s) => sum + s.memory_gb, 0) / candidates.length
);
// Use initially fetched benchmark data (already filtered by tech stack)
const benchmarkData = benchmarkDataAll;
// Get unique providers from candidates
const providers = [...new Set(candidates.map((c) => c.provider_name))];
console.log('[Recommend] Providers:', providers);
// Query VPS benchmarks using consolidated single query (includes provider-specific and spec-based matching)
const vpsBenchmarks = await queryVPSBenchmarksBatch(env.DB, avgCores, avgMemory, providers).catch(err => {
console.warn('[Recommend] VPS benchmark data unavailable:', err.message);
return [] as VPSBenchmark[];
});
console.log('[Recommend] VPS benchmark data points:', vpsBenchmarks.length);
// Use OpenAI GPT-4o-mini to analyze and recommend (techSpecs already queried above)
const aiResult = await getAIRecommendations(
env.OPENAI_API_KEY,
body,
candidates,
benchmarkData,
vpsBenchmarks,
techSpecs,
lang
);
console.log('[Recommend] Generated recommendations:', aiResult.recommendations.length);
const response = {
recommendations: aiResult.recommendations,
infrastructure_tips: aiResult.infrastructure_tips || [],
total_candidates: candidates.length,
cached: false,
};
// Cache result for 1 hour (if cache is configured)
if (env.CACHE) {
await env.CACHE.put(cacheKey, JSON.stringify(response), {
expirationTtl: 3600,
});
}
return jsonResponse(response, 200, corsHeaders);
} catch (error) {
console.error('[Recommend] Error:', error);
console.error('[Recommend] Error stack:', error instanceof Error ? error.stack : 'No stack');
return jsonResponse(
{
error: 'Failed to generate recommendations',
details: error instanceof Error ? error.message : 'Unknown error',
request_id: requestId,
},
500,
corsHeaders
);
}
}
/**
* Type guard to validate Server object structure
*/
function isValidServer(obj: unknown): obj is Server {
if (!obj || typeof obj !== 'object') return false;
const s = obj as Record<string, unknown>;
return (
typeof s.id === 'number' &&
typeof s.provider_name === 'string' &&
typeof s.instance_id === 'string' &&
typeof s.vcpu === 'number' &&
typeof s.memory_mb === 'number' &&
typeof s.monthly_price === 'number'
);
}
/**
* Type guard to validate VPSBenchmark object structure
*/
function isValidVPSBenchmark(obj: unknown): obj is VPSBenchmark {
if (!obj || typeof obj !== 'object') return false;
const v = obj as Record<string, unknown>;
return (
typeof v.id === 'number' &&
typeof v.provider_name === 'string' &&
typeof v.vcpu === 'number' &&
typeof v.geekbench_single === 'number'
);
}
/**
* Type guard to validate TechSpec object structure
*/
function isValidTechSpec(obj: unknown): obj is TechSpec {
if (!obj || typeof obj !== 'object') return false;
const t = obj as Record<string, unknown>;
return (
typeof t.id === 'number' &&
typeof t.name === 'string' &&
typeof t.vcpu_per_users === 'number' &&
typeof t.min_memory_mb === 'number'
);
}
/**
* Type guard to validate BenchmarkData object structure
*/
function isValidBenchmarkData(obj: unknown): obj is BenchmarkData {
if (!obj || typeof obj !== 'object') return false;
const b = obj as Record<string, unknown>;
return (
typeof b.id === 'number' &&
typeof b.processor_name === 'string' &&
typeof b.benchmark_name === 'string' &&
typeof b.score === 'number'
);
}
/**
* Type guard to validate AI recommendation structure
*/
function isValidAIRecommendation(obj: unknown): obj is AIRecommendationResponse['recommendations'][0] {
if (!obj || typeof obj !== 'object') return false;
const r = obj as Record<string, unknown>;
return (
(typeof r.server_id === 'number' || typeof r.server_id === 'string') &&
typeof r.score === 'number' &&
r.analysis !== null &&
typeof r.analysis === 'object' &&
r.estimated_capacity !== null &&
typeof r.estimated_capacity === 'object'
);
}
/**
* Validate recommendation request
*/
function validateRecommendRequest(body: any, lang: string = 'en'): ValidationError | null {
// Ensure lang is valid
const validLang = ['en', 'zh', 'ja', 'ko'].includes(lang) ? lang : 'en';
const messages = i18n[validLang];
if (!body || typeof body !== 'object') {
return {
error: 'Request body must be a JSON object',
missing_fields: ['tech_stack', 'expected_users', 'use_case'],
schema: messages.schema,
example: messages.example
};
}
const missingFields: string[] = [];
const invalidFields: { field: string; reason: string }[] = [];
// Check required fields
if (!body.tech_stack) {
missingFields.push('tech_stack');
} else if (!Array.isArray(body.tech_stack) || body.tech_stack.length === 0) {
invalidFields.push({ field: 'tech_stack', reason: 'must be a non-empty array of strings' });
} else if (body.tech_stack.length > 20) {
invalidFields.push({ field: 'tech_stack', reason: 'must not exceed 20 items' });
} else if (!body.tech_stack.every((item: any) => typeof item === 'string')) {
invalidFields.push({ field: 'tech_stack', reason: 'all items must be strings' });
}
if (body.expected_users === undefined) {
missingFields.push('expected_users');
} else if (typeof body.expected_users !== 'number' || body.expected_users < 1) {
invalidFields.push({ field: 'expected_users', reason: 'must be a positive number' });
} else if (body.expected_users > 10000000) {
invalidFields.push({ field: 'expected_users', reason: 'must not exceed 10,000,000' });
}
if (!body.use_case) {
missingFields.push('use_case');
} else if (typeof body.use_case !== 'string' || body.use_case.trim().length === 0) {
invalidFields.push({ field: 'use_case', reason: 'must be a non-empty string' });
} else if (body.use_case.length > 500) {
invalidFields.push({ field: 'use_case', reason: 'must not exceed 500 characters' });
}
// Check optional fields if provided
if (body.traffic_pattern !== undefined && !['steady', 'spiky', 'growing'].includes(body.traffic_pattern)) {
invalidFields.push({ field: 'traffic_pattern', reason: "must be one of: 'steady', 'spiky', 'growing'" });
}
if (body.region_preference !== undefined) {
if (!Array.isArray(body.region_preference)) {
invalidFields.push({ field: 'region_preference', reason: 'must be an array' });
} else if (body.region_preference.length > 10) {
invalidFields.push({ field: 'region_preference', reason: 'must not exceed 10 items' });
} else if (!body.region_preference.every((item: any) => typeof item === 'string')) {
invalidFields.push({ field: 'region_preference', reason: 'all items must be strings' });
}
}
if (body.budget_limit !== undefined && (typeof body.budget_limit !== 'number' || body.budget_limit < 0)) {
invalidFields.push({ field: 'budget_limit', reason: 'must be a non-negative number' });
}
if (body.provider_filter !== undefined) {
if (!Array.isArray(body.provider_filter)) {
invalidFields.push({ field: 'provider_filter', reason: 'must be an array' });
} else if (body.provider_filter.length > 10) {
invalidFields.push({ field: 'provider_filter', reason: 'must not exceed 10 items' });
} else if (!body.provider_filter.every((item: any) => typeof item === 'string')) {
invalidFields.push({ field: 'provider_filter', reason: 'all items must be strings' });
}
}
// Validate lang field if provided
if (body.lang !== undefined && !['en', 'zh', 'ja', 'ko'].includes(body.lang)) {
invalidFields.push({ field: 'lang', reason: "must be one of: 'en', 'zh', 'ja', 'ko'" });
}
// Return error if any issues found
if (missingFields.length > 0 || invalidFields.length > 0) {
return {
error: missingFields.length > 0 ? messages.missingFields : messages.invalidFields,
...(missingFields.length > 0 && { missing_fields: missingFields }),
...(invalidFields.length > 0 && { invalid_fields: invalidFields }),
schema: messages.schema,
example: messages.example
};
}
return null;
}
/**
* Escape LIKE pattern special characters
*/
function escapeLikePattern(pattern: string): string {
return pattern.replace(/[%_\\]/g, '\\$&');
}
/**
* Query candidate servers from database
* @param minMemoryMb - Minimum memory requirement from tech specs (optional)
* @param minVcpu - Minimum vCPU requirement based on expected users (optional)
* @param bandwidthEstimate - Bandwidth estimate for provider prioritization (optional)
* @param lang - Language for currency selection: 'ko' → KRW, others → retail USD
*/
async function queryCandidateServers(
db: D1Database,
req: RecommendRequest,
minMemoryMb?: number,
minVcpu?: number,
bandwidthEstimate?: BandwidthEstimate,
lang: string = 'en'
): Promise<Server[]> {
// Select price column based on language
// Korean → monthly_price_krw (KRW), Others → monthly_price_retail (1.21x USD)
const priceColumn = lang === 'ko' ? 'pr.monthly_price_krw' : 'pr.monthly_price_retail';
const currency = lang === 'ko' ? 'KRW' : 'USD';
// Check if region preference is specified
const hasRegionPref = req.region_preference && req.region_preference.length > 0;
let query = `
SELECT
it.id,
p.display_name as provider_name,
it.instance_id,
it.instance_name,
it.vcpu,
it.memory_mb,
ROUND(it.memory_mb / 1024.0, 1) as memory_gb,
it.storage_gb,
it.network_speed_gbps,
it.instance_family,
it.gpu_count,
it.gpu_type,
MIN(${priceColumn}) as monthly_price,
'${currency}' as currency,
r.region_name as region_name,
r.region_code as region_code,
r.country_code as country_code
FROM instance_types it
JOIN providers p ON it.provider_id = p.id
JOIN pricing pr ON pr.instance_type_id = it.id
JOIN regions r ON pr.region_id = r.id
WHERE p.id IN (1, 2) -- Linode, Vultr only
`;
const params: (string | number)[] = [];
if (req.budget_limit) {
// Use same price column as display for budget filtering
query += ` AND ${priceColumn} <= ?`;
params.push(req.budget_limit);
}
// Filter by minimum memory requirement (from tech specs)
if (minMemoryMb && minMemoryMb > 0) {
query += ` AND it.memory_mb >= ?`;
params.push(minMemoryMb);
console.log(`[Candidates] Filtering by minimum memory: ${minMemoryMb}MB (${(minMemoryMb/1024).toFixed(1)}GB)`);
}
// Filter by minimum vCPU requirement (from expected users + tech specs)
if (minVcpu && minVcpu > 0) {
query += ` AND it.vcpu >= ?`;
params.push(minVcpu);
console.log(`[Candidates] Filtering by minimum vCPU: ${minVcpu}`);
}
// Provider filtering based on bandwidth requirements
// Heavy bandwidth (>2TB/month) → Linode only (better bandwidth allowance)
// Very heavy bandwidth (>6TB/month) → Linode only with warning
if (bandwidthEstimate) {
if (bandwidthEstimate.category === 'very_heavy') {
// >6TB/month: Linode only (includes up to 20TB depending on plan)
query += ` AND p.id = 1`; // Linode only
console.log(`[Candidates] Very heavy bandwidth (${bandwidthEstimate.monthly_tb}TB/month): Linode only`);
} else if (bandwidthEstimate.category === 'heavy') {
// 2-6TB/month: Prefer Linode, but allow Vultr
// Order by Linode first (handled in ORDER BY)
console.log(`[Candidates] Heavy bandwidth (${bandwidthEstimate.monthly_tb}TB/month): Linode preferred`);
}
}
// Country name to code mapping for common names
// Note: Use specific city names to avoid LIKE pattern collisions (e.g., 'de' matches 'Delhi')
const countryNameToCode: Record<string, string[]> = {
'korea': ['seoul', 'ap-northeast-2'],
'south korea': ['seoul', 'ap-northeast-2'],
'japan': ['tokyo', 'osaka', 'ap-northeast-1', 'ap-northeast-3'],
'singapore': ['singapore', 'ap-southeast-1'],
'indonesia': ['jakarta', 'ap-southeast-3'],
'india': ['mumbai', 'delhi', 'bangalore', 'hyderabad', 'ap-south-1'],
'australia': ['sydney', 'melbourne', 'ap-southeast-2'],
'germany': ['frankfurt', 'nuremberg', 'falkenstein', 'eu-central-1'],
'usa': ['us-east', 'us-west', 'virginia', 'oregon', 'ohio'],
'united states': ['us-east', 'us-west', 'virginia', 'oregon', 'ohio'],
'uk': ['london', 'manchester', 'eu-west-2'],
'united kingdom': ['london', 'manchester', 'eu-west-2'],
'netherlands': ['amsterdam', 'eu-west-1'],
'france': ['paris', 'eu-west-3'],
'hong kong': ['hong kong', 'ap-east-1'],
'taiwan': ['taipei', 'ap-northeast-1'],
'brazil': ['sao paulo', 'sa-east-1'],
'canada': ['montreal', 'toronto', 'ca-central-1'],
};
// Flexible region matching: region_code, region_name, or country_code
if (req.region_preference && req.region_preference.length > 0) {
// User specified region → filter to that region only
const regionConditions: string[] = [];
for (const region of req.region_preference) {
const lowerRegion = region.toLowerCase();
// Expand country names to their codes/cities
const expandedRegions = countryNameToCode[lowerRegion] || [lowerRegion];
const allRegions = [lowerRegion, ...expandedRegions];
for (const r of allRegions) {
const escapedRegion = escapeLikePattern(r);
regionConditions.push(`(
LOWER(r.region_code) = ? OR
LOWER(r.region_code) LIKE ? ESCAPE '\\' OR
LOWER(r.region_name) LIKE ? ESCAPE '\\' OR
LOWER(r.country_code) = ?
)`);
params.push(r, `%${escapedRegion}%`, `%${escapedRegion}%`, r);
}
}
query += ` AND (${regionConditions.join(' OR ')})`;
} else {
// No region specified → default to Seoul/Tokyo/Osaka/Singapore
query += ` AND (
-- Korea (Seoul)
r.region_code IN ('icn', 'ap-northeast-2') OR
LOWER(r.region_name) LIKE '%seoul%' OR
-- Japan (Tokyo, Osaka)
r.region_code IN ('nrt', 'itm', 'ap-northeast-1', 'ap-northeast-3') OR
LOWER(r.region_code) LIKE '%tyo%' OR
LOWER(r.region_code) LIKE '%osa%' OR
LOWER(r.region_name) LIKE '%tokyo%' OR
LOWER(r.region_name) LIKE '%osaka%' OR
-- Singapore
r.region_code IN ('sgp', 'ap-southeast-1') OR
LOWER(r.region_code) LIKE '%sin%' OR
LOWER(r.region_code) LIKE '%sgp%' OR
LOWER(r.region_name) LIKE '%singapore%'
)`;
}
// Filter by provider if specified
if (req.provider_filter && req.provider_filter.length > 0) {
const placeholders = req.provider_filter.map(() => '?').join(',');
query += ` AND (p.name IN (${placeholders}) OR p.display_name IN (${placeholders}))`;
params.push(...req.provider_filter, ...req.provider_filter);
}
// Group by instance + region to show each server per region
// For heavy bandwidth, prioritize Linode (p.id=1) over Vultr (p.id=2)
const orderByClause = bandwidthEstimate?.category === 'heavy'
? `ORDER BY CASE WHEN p.id = 1 THEN 0 ELSE 1 END, monthly_price ASC`
: `ORDER BY monthly_price ASC`;
query += ` GROUP BY it.id, r.id ${orderByClause} LIMIT 50`;
const result = await db.prepare(query).bind(...params).all();
if (!result.success) {
throw new Error('Failed to query candidate servers');
}
// Validate each result with type guard
const validServers = (result.results as unknown[]).filter(isValidServer);
const invalidCount = result.results.length - validServers.length;
if (invalidCount > 0) {
console.warn(`[Candidates] Filtered out ${invalidCount} invalid server records`);
}
return validServers;
}
/**
* Query relevant benchmark data for tech stack
*/
async function queryBenchmarkData(
db: D1Database,
techStack: string[],
coreCount?: number
): Promise<BenchmarkData[]> {
// Map tech stack to relevant benchmark types
const techToBenchmark: Record<string, string[]> = {
'node.js': ['pts-node-octane', 'pts-node-express-loadtest'],
'nodejs': ['pts-node-octane', 'pts-node-express-loadtest'],
'express': ['pts-node-express-loadtest'],
'nginx': ['pts-nginx'],
'apache': ['pts-apache'],
'php': ['pts-phpbench'],
'redis': ['pts-redis'],
'mysql': ['pts-mysqlslap'],
'postgresql': ['pts-mysqlslap'], // Use MySQL benchmark as proxy
'docker': ['pts-compress-7zip', 'pts-postmark'], // CPU + I/O for containers
'mongodb': ['pts-postmark'], // I/O intensive
'python': ['pts-coremark', 'pts-compress-7zip'],
'java': ['pts-coremark', 'pts-compress-7zip'],
'go': ['pts-coremark', 'pts-compress-7zip'],
'rust': ['pts-coremark', 'pts-compress-7zip'],
};
// Find relevant benchmark types
const relevantBenchmarks = new Set<string>();
for (const tech of techStack) {
const benchmarks = techToBenchmark[tech.toLowerCase()] || [];
benchmarks.forEach(b => relevantBenchmarks.add(b));
}
// Always include general CPU benchmark
relevantBenchmarks.add('pts-compress-7zip');
if (relevantBenchmarks.size === 0) {
return [];
}
const benchmarkNames = Array.from(relevantBenchmarks);
const placeholders = benchmarkNames.map(() => '?').join(',');
// Query benchmark data, optionally filtering by core count
let query = `
SELECT
p.id,
p.name as processor_name,
bt.name as benchmark_name,
bt.category,
br.score,
br.percentile,
p.cores
FROM benchmark_results br
JOIN processors p ON br.processor_id = p.id
JOIN benchmark_types bt ON br.benchmark_type_id = bt.id
WHERE bt.name IN (${placeholders})
`;
const params: (string | number)[] = [...benchmarkNames];
// If we know core count, filter to similar processors
if (coreCount && coreCount > 0) {
query += ` AND (p.cores IS NULL OR (p.cores >= ? AND p.cores <= ?))`;
params.push(Math.max(1, coreCount - 2), coreCount + 4);
}
query += ` ORDER BY br.percentile DESC, br.score DESC LIMIT 50`;
const result = await db.prepare(query).bind(...params).all();
if (!result.success) {
console.warn('[Benchmark] Query failed');
return [];
}
// Validate each result with type guard
return (result.results as unknown[]).filter(isValidBenchmarkData);
}
/**
* Get benchmark reference for a server
*/
function getBenchmarkReference(
benchmarks: BenchmarkData[],
vcpu: number
): BenchmarkReference | undefined {
// Find benchmarks from processors with similar core count
const similarBenchmarks = benchmarks.filter(b =>
b.cores === null || (b.cores >= vcpu - 2 && b.cores <= vcpu + 4)
);
if (similarBenchmarks.length === 0) {
return undefined;
}
// Group by processor and get the best match
const byProcessor = new Map<string, BenchmarkData[]>();
for (const b of similarBenchmarks) {
const existing = byProcessor.get(b.processor_name) || [];
existing.push(b);
byProcessor.set(b.processor_name, existing);
}
// Find processor with most benchmark data
let bestProcessor = '';
let maxBenchmarks = 0;
for (const [name, data] of byProcessor) {
if (data.length > maxBenchmarks) {
maxBenchmarks = data.length;
bestProcessor = name;
}
}
if (!bestProcessor) {
return undefined;
}
const processorBenchmarks = byProcessor.get(bestProcessor)!;
return {
processor_name: bestProcessor,
benchmarks: processorBenchmarks.map(b => ({
name: b.benchmark_name,
category: b.category,
score: b.score,
percentile: b.percentile,
})),
};
}
/**
* Query VPS benchmarks - prioritize matching provider
*/
async function queryVPSBenchmarks(
db: D1Database,
vcpu: number,
memoryGb: number,
providerHint?: string
): Promise<VPSBenchmark[]> {
const vcpuMin = Math.max(1, vcpu - 1);
const vcpuMax = vcpu + 2;
const memMin = Math.max(1, memoryGb - 2);
const memMax = memoryGb + 4;
// First try to find benchmarks from the same provider
if (providerHint) {
const providerQuery = `
SELECT *
FROM vps_benchmarks
WHERE (LOWER(provider_name) LIKE ? ESCAPE '\\' OR LOWER(plan_name) LIKE ? ESCAPE '\\')
ORDER BY gb6_single_normalized DESC
LIMIT 20
`;
const escapedHint = escapeLikePattern(providerHint.toLowerCase());
const providerPattern = `%${escapedHint}%`;
const providerResult = await db.prepare(providerQuery).bind(providerPattern, providerPattern).all();
if (providerResult.success && providerResult.results.length > 0) {
// Validate each result with type guard
return (providerResult.results as unknown[]).filter(isValidVPSBenchmark);
}
}
// Fallback: Find VPS with similar specs
const query = `
SELECT *
FROM vps_benchmarks
WHERE vcpu >= ? AND vcpu <= ?
AND memory_gb >= ? AND memory_gb <= ?
ORDER BY gb6_single_normalized DESC
LIMIT 10
`;
const result = await db.prepare(query).bind(vcpuMin, vcpuMax, memMin, memMax).all();
if (!result.success) {
return [];
}
// Validate each result with type guard
return (result.results as unknown[]).filter(isValidVPSBenchmark);
}
/**
* Query VPS benchmarks in a single batched query
* Consolidates multiple provider-specific queries into one for better performance
*/
async function queryVPSBenchmarksBatch(
db: D1Database,
vcpu: number,
memoryGb: number,
providers: string[]
): Promise<VPSBenchmark[]> {
const vcpuMin = Math.max(1, vcpu - 1);
const vcpuMax = vcpu + 2;
const memMin = Math.max(1, memoryGb - 2);
const memMax = memoryGb + 4;
// Build provider conditions for up to 3 providers
const providerConditions: string[] = [];
const params: (string | number)[] = [];
const limitedProviders = providers.slice(0, 3);
for (const provider of limitedProviders) {
const pattern = `%${escapeLikePattern(provider.toLowerCase())}%`;
providerConditions.push(`(LOWER(provider_name) LIKE ? ESCAPE '\\' OR LOWER(plan_name) LIKE ? ESCAPE '\\')`);
params.push(pattern, pattern);
}
// Build query with provider matching OR spec matching
const query = `
SELECT * FROM vps_benchmarks
WHERE ${providerConditions.length > 0 ? `(${providerConditions.join(' OR ')})` : '1=0'}
OR (vcpu >= ? AND vcpu <= ? AND memory_gb >= ? AND memory_gb <= ?)
ORDER BY gb6_single_normalized DESC
LIMIT 30
`;
params.push(vcpuMin, vcpuMax, memMin, memMax);
const result = await db.prepare(query).bind(...params).all();
if (!result.success) {
console.warn('[VPSBenchmarksBatch] Query failed');
return [];
}
// Validate each result with type guard
return (result.results as unknown[]).filter(isValidVPSBenchmark);
}
/**
* Format VPS benchmark data for AI prompt
* Uses GB6-normalized scores (GB5 scores converted with ×1.45 factor)
*/
function formatVPSBenchmarkSummary(benchmarks: VPSBenchmark[]): string {
if (benchmarks.length === 0) {
return '';
}
const lines = ['Real VPS performance data (Geekbench 6 normalized):'];
for (const b of benchmarks.slice(0, 5)) {
const versionNote = b.geekbench_version?.startsWith('5.') ? ' [GB5→6]' : '';
lines.push(
`- ${b.plan_name} (${b.country_code}): Single=${b.gb6_single_normalized}, Multi=${b.gb6_multi_normalized}${versionNote}, $${b.monthly_price_usd}/mo, Perf/$=${b.performance_per_dollar.toFixed(1)}`
);
}
return lines.join('\n');
}
/**
* Format benchmark data for AI prompt
*/
function formatBenchmarkSummary(benchmarks: BenchmarkData[]): string {
if (benchmarks.length === 0) {
return '';
}
// Group by benchmark type
const byType = new Map<string, BenchmarkData[]>();
for (const b of benchmarks) {
const existing = byType.get(b.benchmark_name) || [];
existing.push(b);
byType.set(b.benchmark_name, existing);
}
const lines: string[] = [];
for (const [type, data] of byType) {
// Get top 3 performers for this benchmark
const top3 = data.slice(0, 3);
const scores = top3.map(d =>
`${d.processor_name}${d.cores ? ` (${d.cores} cores)` : ''}: ${d.score} (${d.percentile}th percentile)`
);
lines.push(`### ${type} (${data[0].category})`);
lines.push(scores.join('\n'));
lines.push('');
}
return lines.join('\n');
}
/**
* Query tech stack specifications from database
* Matches user's tech_stack against canonical names and aliases
*/
async function queryTechSpecs(
db: D1Database,
techStack: string[]
): Promise<TechSpec[]> {
if (!techStack || techStack.length === 0) {
return [];
}
// Normalize user input
const normalizedStack = techStack.map(t => t.toLowerCase().trim());
// Build query that matches both name and aliases (case-insensitive)
// Using LOWER() for alias matching since aliases are stored as JSON array strings
const conditions: string[] = [];
const params: string[] = [];
for (const tech of normalizedStack) {
conditions.push(`(LOWER(name) = ? OR LOWER(aliases) LIKE ?)`);
params.push(tech, `%"${tech}"%`);
}
const query = `
SELECT
id, name, category,
vcpu_per_users, vcpu_per_users_max,
min_memory_mb, max_memory_mb,
description, aliases,
is_memory_intensive, is_cpu_intensive
FROM tech_specs
WHERE ${conditions.join(' OR ')}
ORDER BY category, name
`;
try {
const result = await db.prepare(query).bind(...params).all();
if (!result.success) {
console.warn('[TechSpecs] Query failed');
return [];
}
// Validate each result with type guard
const validSpecs = (result.results as unknown[]).filter(isValidTechSpec);
console.log(`[TechSpecs] Found ${validSpecs.length} specs for: ${normalizedStack.join(', ')}`);
return validSpecs;
} catch (error) {
console.error('[TechSpecs] Error:', error);
return [];
}
}
/**
* Format tech specs for AI prompt
*/
function formatTechSpecsForPrompt(techSpecs: TechSpec[]): string {
if (!techSpecs || techSpecs.length === 0) {
return `Tech stack resource guidelines:
- Default: 1 vCPU per 100-300 users, 1-2GB RAM`;
}
const lines = ['Tech stack resource guidelines (MUST follow minimum RAM requirements):'];
for (const spec of techSpecs) {
const vcpuRange = spec.vcpu_per_users_max
? `${spec.vcpu_per_users}-${spec.vcpu_per_users_max}`
: `${spec.vcpu_per_users}`;
// Convert MB to GB for readability
const minMemoryGB = (spec.min_memory_mb / 1024).toFixed(1).replace('.0', '');
const maxMemoryGB = spec.max_memory_mb ? (spec.max_memory_mb / 1024).toFixed(1).replace('.0', '') : null;
const memoryRange = maxMemoryGB ? `${minMemoryGB}-${maxMemoryGB}GB` : `${minMemoryGB}GB+`;
let line = `- ${spec.name}: 1 vCPU per ${vcpuRange} users, MINIMUM ${minMemoryGB}GB RAM`;
// Add warnings for special requirements
const warnings: string[] = [];
if (spec.is_memory_intensive) warnings.push('⚠️ MEMORY-INTENSIVE: must have at least ' + minMemoryGB + 'GB RAM');
if (spec.is_cpu_intensive) warnings.push('⚠️ CPU-INTENSIVE');
if (warnings.length > 0) {
line += ` [${warnings.join(', ')}]`;
}
lines.push(line);
}
// Add explicit warning for memory-intensive apps
const memoryIntensive = techSpecs.filter(s => s.is_memory_intensive);
if (memoryIntensive.length > 0) {
const maxMinMemory = Math.max(...memoryIntensive.map(s => s.min_memory_mb));
lines.push('');
lines.push(`⚠️ CRITICAL: This tech stack includes memory-intensive apps. Servers with less than ${(maxMinMemory / 1024).toFixed(0)}GB RAM will NOT work properly!`);
}
return lines.join('\n');
}
/**
* Get AI-powered recommendations using OpenAI GPT-4o-mini
*/
async function getAIRecommendations(
apiKey: string,
req: RecommendRequest,
candidates: Server[],
benchmarkData: BenchmarkData[],
vpsBenchmarks: VPSBenchmark[],
techSpecs: TechSpec[],
lang: string = 'en'
): Promise<{ recommendations: RecommendationResult[]; infrastructure_tips?: string[] }> {
// Build dynamic tech specs prompt from database
const techSpecsPrompt = formatTechSpecsForPrompt(techSpecs);
// Ensure lang is valid
const validLang = ['en', 'zh', 'ja', 'ko'].includes(lang) ? lang : 'en';
const languageInstruction = i18n[validLang].aiLanguageInstruction;
// Build system prompt with benchmark awareness
const systemPrompt = `You are a cloud infrastructure expert focused on COST-EFFECTIVE solutions. Your goal is to recommend the SMALLEST and CHEAPEST server that can handle the user's requirements.
CRITICAL RULES:
1. NEVER over-provision. Recommend the minimum specs needed.
2. Cost efficiency is the PRIMARY factor - cheaper is better if it meets requirements.
3. A 1-2 vCPU server can handle 100-500 concurrent users for most web workloads.
4. Nginx/reverse proxy needs very little resources - 1 vCPU can handle 1000+ req/sec.
5. Provide 3 options: Budget (cheapest viable), Balanced (some headroom), Premium (growth ready).
BANDWIDTH CONSIDERATIONS (VERY IMPORTANT):
- Estimated monthly bandwidth is provided based on concurrent users and use case.
- TOTAL COST = Base server price + Bandwidth overage charges
- Provider bandwidth allowances:
* Linode: 1TB (1GB plan) to 20TB (192GB plan) included free, $0.005/GB overage
* Vultr: 1TB-10TB depending on plan, $0.01/GB overage (2x Linode rate)
* DigitalOcean: 1TB-12TB depending on plan, $0.01/GB overage
- For bandwidth >1TB/month: Linode is often cheaper despite higher base price
- For bandwidth >3TB/month: Linode is STRONGLY preferred (overage savings significant)
- Always mention bandwidth implications in cost_efficiency analysis
${techSpecsPrompt}
Use REAL BENCHMARK DATA to validate capacity estimates.
${languageInstruction}`;
// Build user prompt with requirements and candidates
// Estimate bandwidth based on concurrent users and use case
const bandwidthEstimate = estimateBandwidth(req.expected_users, req.use_case, req.traffic_pattern);
console.log('[AI] Bandwidth estimate:', bandwidthEstimate);
// Detect high-traffic based on bandwidth estimate (more accurate than keyword matching)
const isHighTraffic = bandwidthEstimate.category === 'heavy' || bandwidthEstimate.category === 'very_heavy';
// Format benchmark data for the prompt
const benchmarkSummary = formatBenchmarkSummary(benchmarkData);
const vpsBenchmarkSummary = formatVPSBenchmarkSummary(vpsBenchmarks);
const userPrompt = `Analyze these server options and recommend the top 3 best matches.
## User Requirements
- Tech Stack: ${req.tech_stack.join(', ')}
- Expected Concurrent Users: ${req.expected_users} ${req.traffic_pattern === 'spiky' ? '(with traffic spikes)' : req.traffic_pattern === 'growing' ? '(growing user base)' : '(steady traffic)'}
- **Estimated DAU (Daily Active Users)**: ${bandwidthEstimate.estimated_dau_min.toLocaleString()}-${bandwidthEstimate.estimated_dau_max.toLocaleString()}명 (동시 접속 ${req.expected_users}명 기준)
- Use Case: ${req.use_case}
- Traffic Pattern: ${req.traffic_pattern || 'steady'}
- **Estimated Monthly Bandwidth**: ${bandwidthEstimate.monthly_tb >= 1 ? `${bandwidthEstimate.monthly_tb} TB` : `${bandwidthEstimate.monthly_gb} GB`} (${bandwidthEstimate.category})
${isHighTraffic ? `- ⚠️ HIGH BANDWIDTH WORKLOAD (${bandwidthEstimate.monthly_tb} TB/month): MUST recommend Linode over Vultr. Linode includes 1-6TB/month transfer vs Vultr overage charges ($0.01/GB). Bandwidth cost savings > base price difference.` : ''}
${req.region_preference ? `- Region Preference: ${req.region_preference.join(', ')}` : ''}
${req.budget_limit ? `- Budget Limit: $${req.budget_limit}/month` : ''}
## Real VPS Benchmark Data (Geekbench 6 normalized - actual VPS tests)
${vpsBenchmarkSummary || 'No similar VPS benchmark data available.'}
## CPU Benchmark Reference (from Phoronix Test Suite)
${benchmarkSummary || 'No relevant CPU benchmark data available.'}
## Available Servers
${candidates.map((s, idx) => `
${idx + 1}. ${s.provider_name} - ${s.instance_name}${s.instance_family ? ` (${s.instance_family})` : ''}
ID: ${s.id}
Instance: ${s.instance_id}
vCPU: ${s.vcpu} | Memory: ${s.memory_gb} GB | Storage: ${s.storage_gb} GB
Network: ${s.network_speed_gbps ? `${s.network_speed_gbps} Gbps` : 'N/A'}${s.gpu_count > 0 ? ` | GPU: ${s.gpu_count}x ${s.gpu_type || 'Unknown'}` : ' | GPU: None'}
Price: ${s.currency === 'KRW' ? '₩' : '$'}${s.currency === 'KRW' ? Math.round(s.monthly_price).toLocaleString() : s.monthly_price.toFixed(2)}/month (${s.currency}) | Region: ${s.region_name} (${s.region_code})
`).join('\n')}
Return ONLY a valid JSON object (no markdown, no code blocks) with this exact structure:
{
"recommendations": [
{
"server_id": 123,
"score": 95,
"analysis": {
"tech_fit": "Why this server fits the tech stack",
"capacity": "MUST mention: '동시 접속 X명 요청 (DAU A-B명), 최대 동시 Y명까지 처리 가능' format",
"cost_efficiency": "MUST include: base price + bandwidth cost estimate. Example: '$5/month + ~$X bandwidth = ~$Y total'",
"scalability": "Scalability potential including bandwidth headroom"
},
"estimated_capacity": {
"max_concurrent_users": 7500,
"requests_per_second": 1000
}
}
],
"infrastructure_tips": [
"Practical tip 1",
"Practical tip 2"
]
}
Provide exactly 3 recommendations:
1. BUDGET option: Cheapest TOTAL cost (base + bandwidth) that can handle the load (highest score if viable)
2. BALANCED option: Some headroom for traffic spikes
3. PREMIUM option: Ready for 2-3x growth
SCORING (100 points total):
- Total Cost Efficiency (40%): Base price + estimated bandwidth overage. Lower total = higher score.
- Capacity Fit (30%): Can it handle the concurrent users and bandwidth?
- Scalability (30%): Room for growth in CPU, memory, AND bandwidth allowance.
The option with the LOWEST TOTAL MONTHLY COST (including bandwidth) should have the HIGHEST score.`;
console.log('[AI] Sending request to OpenAI GPT-4o-mini');
// Create AbortController with 30 second timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
try {
const openaiResponse = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
max_tokens: 2000,
temperature: 0.3,
}),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!openaiResponse.ok) {
const errorText = await openaiResponse.text();
const sanitized = errorText.slice(0, 100).replace(/sk-[a-zA-Z0-9-_]+/g, 'sk-***');
console.error('[AI] OpenAI API error:', openaiResponse.status, sanitized);
throw new Error(`OpenAI API error: ${openaiResponse.status}`);
}
const openaiResult = await openaiResponse.json() as {
choices: Array<{ message: { content: string } }>;
};
const response = openaiResult.choices[0]?.message?.content || '';
console.log('[AI] Response received from OpenAI, length:', response.length);
console.log('[AI] Raw response preview:', response.substring(0, 500));
// Parse AI response
const aiResult = parseAIResponse(response);
console.log('[AI] Parsed recommendations count:', aiResult.recommendations.length);
// Pre-index VPS benchmarks by provider for O(1) lookups
const vpsByProvider = new Map<string, VPSBenchmark[]>();
for (const vps of vpsBenchmarks) {
const providerKey = vps.provider_name.toLowerCase();
const existing = vpsByProvider.get(providerKey) || [];
existing.push(vps);
vpsByProvider.set(providerKey, existing);
}
// Map AI recommendations to full results
const results: RecommendationResult[] = [];
for (const aiRec of aiResult.recommendations) {
// Handle both string and number server_id from AI
const serverId = Number(aiRec.server_id);
const server = candidates.find((s) => s.id === serverId);
if (!server) {
console.warn('[AI] Server not found:', aiRec.server_id);
continue;
}
// Get benchmark reference for this server's CPU count
const benchmarkRef = getBenchmarkReference(benchmarkData, server.vcpu);
// Find matching VPS benchmark using pre-indexed data
const providerName = server.provider_name.toLowerCase();
let matchingVPS: VPSBenchmark | undefined;
// Try to find from indexed provider benchmarks
for (const [providerKey, benchmarks] of vpsByProvider.entries()) {
if (providerKey.includes(providerName) || providerName.includes(providerKey)) {
// First try exact or close vCPU match
matchingVPS = benchmarks.find(
(v) => v.vcpu === server.vcpu || (v.vcpu >= server.vcpu - 1 && v.vcpu <= server.vcpu + 1)
);
// Fallback to any from this provider
if (!matchingVPS && benchmarks.length > 0) {
matchingVPS = benchmarks[0];
}
if (matchingVPS) break;
}
}
// Final fallback: similar specs from any provider
if (!matchingVPS) {
matchingVPS = vpsBenchmarks.find(
(v) => v.vcpu === server.vcpu || (v.vcpu >= server.vcpu - 1 && v.vcpu <= server.vcpu + 1)
);
}
results.push({
server: server,
score: aiRec.score,
analysis: aiRec.analysis,
estimated_capacity: aiRec.estimated_capacity,
benchmark_reference: benchmarkRef,
vps_benchmark_reference: matchingVPS
? {
plan_name: matchingVPS.plan_name,
geekbench_single: matchingVPS.geekbench_single,
geekbench_multi: matchingVPS.geekbench_multi,
monthly_price_usd: matchingVPS.monthly_price_usd,
performance_per_dollar: matchingVPS.performance_per_dollar,
}
: undefined,
});
if (results.length >= 3) break;
}
return {
recommendations: results,
infrastructure_tips: aiResult.infrastructure_tips,
};
} catch (error) {
clearTimeout(timeoutId);
// Handle timeout specifically
if (error instanceof Error && error.name === 'AbortError') {
console.error('[AI] Request timed out after 30 seconds');
throw new Error('AI request timed out - please try again');
}
console.error('[AI] Error:', error);
throw new Error(`AI processing failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Parse AI response and extract JSON
*/
function parseAIResponse(response: any): AIRecommendationResponse {
try {
// Handle different response formats
let content: string;
if (typeof response === 'string') {
content = response;
} else if (response.response) {
content = response.response;
} else if (response.result && response.result.response) {
content = response.result.response;
} else if (response.choices && response.choices[0]?.message?.content) {
content = response.choices[0].message.content;
} else {
console.error('[AI] Unexpected response format:', response);
throw new Error('Unexpected AI response format');
}
// Remove markdown code blocks if present
content = content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
// Find JSON object in response
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error('No JSON found in AI response');
}
const parsed = JSON.parse(jsonMatch[0]);
if (!parsed.recommendations || !Array.isArray(parsed.recommendations)) {
throw new Error('Invalid recommendations structure');
}
// Validate each recommendation with type guard
const validRecommendations = parsed.recommendations.filter(isValidAIRecommendation);
if (validRecommendations.length === 0 && parsed.recommendations.length > 0) {
console.warn('[AI] All recommendations failed validation, raw:', JSON.stringify(parsed.recommendations[0]).slice(0, 200));
throw new Error('AI recommendations failed validation');
}
return {
recommendations: validRecommendations,
infrastructure_tips: Array.isArray(parsed.infrastructure_tips) ? parsed.infrastructure_tips : [],
} as AIRecommendationResponse;
} catch (error) {
console.error('[AI] Parse error:', error);
console.error('[AI] Response was:', response);
throw new Error(`Failed to parse AI response: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Sanitize special characters for cache key
*/
function sanitizeCacheValue(value: string): string {
return value.replace(/[|:,]/g, '_');
}
/**
* Generate cache key from request parameters
*/
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.region_preference) {
const sortedRegions = [...req.region_preference].sort();
const sanitizedRegions = sortedRegions.map(sanitizeCacheValue).join(',');
parts.push(`reg:${sanitizedRegions}`);
}
if (req.budget_limit) {
parts.push(`budget:${req.budget_limit}`);
}
if (req.provider_filter && req.provider_filter.length > 0) {
const sortedProviders = [...req.provider_filter].sort();
const sanitizedProviders = sortedProviders.map(sanitizeCacheValue).join(',');
parts.push(`prov:${sanitizedProviders}`);
}
// Include language in cache key
if (req.lang) {
parts.push(`lang:${req.lang}`);
}
return `recommend:${parts.join('|')}`;
}
/**
* Simple hash function for strings
*/
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
}
return Math.abs(hash).toString(36);
}
/**
* JSON response helper
*/
function jsonResponse(
data: any,
status: number,
headers: Record<string, string> = {}
): Response {
return new Response(JSON.stringify(data), {
status,
headers: {
'Content-Type': 'application/json',
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Cache-Control': 'no-store',
...headers,
},
});
}