From b682abc45df19fa7b11c432333991121ed4f97c2 Mon Sep 17 00:00:00 2001 From: kappa Date: Sun, 25 Jan 2026 17:46:16 +0900 Subject: [PATCH] refactor: modularize codebase and add DB workload multiplier - Split monolithic index.ts (2370 lines) into modular structure: - src/handlers/ for route handlers - src/utils.ts for shared utilities - src/config.ts for configuration - src/types.ts for TypeScript definitions - Add DB workload multiplier for smarter database resource calculation: - Heavy (analytics, logs): 0.3x multiplier - Medium-heavy (e-commerce, transactional): 0.5x - Medium (API, SaaS): 0.7x - Light (blog, portfolio): 1.0x - Fix tech_specs with realistic vcpu_per_users values (150+ technologies) - Fix "blog" matching "log" regex bug - Update documentation to reflect new architecture Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 115 +- fix-tech-specs.sql | 292 +++++ src/config.ts | 148 +++ src/handlers/health.ts | 20 + src/handlers/recommend.ts | 1240 ++++++++++++++++++++ src/handlers/servers.ts | 139 +++ src/index.ts | 2291 +------------------------------------ src/types.ts | 245 ++-- src/utils.ts | 642 +++++++++++ 9 files changed, 2729 insertions(+), 2403 deletions(-) create mode 100644 fix-tech-specs.sql create mode 100644 src/config.ts create mode 100644 src/handlers/health.ts create mode 100644 src/handlers/recommend.ts create mode 100644 src/handlers/servers.ts create mode 100644 src/utils.ts diff --git a/CLAUDE.md b/CLAUDE.md index b596a50..462157d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Cloudflare Worker-based AI server recommendation service. Uses Workers AI (Llama 3.1 8B), D1 database, and VPS benchmark data to recommend cost-effective servers based on natural language requirements. +Cloudflare Worker-based AI server recommendation service. Uses OpenAI GPT-4o-mini (via AI Gateway), D1 database, KV cache, and VPS benchmark data to recommend cost-effective servers based on natural language requirements. **Production URL**: `https://server-recommend.kappa-d8e.workers.dev` @@ -19,6 +19,7 @@ npm run typecheck # TypeScript type checking # Database operations (D1) npx wrangler d1 execute cloud-instances-db --file=schema.sql # Apply schema npx wrangler d1 execute cloud-instances-db --file=seed.sql # Seed data +npx wrangler d1 execute cloud-instances-db --file=fix-tech-specs.sql # Update tech specs npx wrangler d1 execute cloud-instances-db --command="SELECT ..." # Ad-hoc queries # View logs @@ -28,23 +29,25 @@ npx wrangler tail ## Architecture ``` -src/index.ts (single file Worker) -├── handleHealth() GET /api/health -├── handleGetServers() GET /api/servers - List servers with filtering -└── handleRecommend() POST /api/recommend - AI-powered recommendations - ├── validateRecommendRequest() - ├── queryCandidateServers() → D1: instance_types + providers + pricing + regions - ├── queryBenchmarkData() → D1: benchmark_results + benchmark_types - ├── queryVPSBenchmarks() → D1: vps_benchmarks (Geekbench 6) - └── getAIRecommendations() → Workers AI (Llama 3.1 8B) +src/ +├── index.ts # Main router, CORS, request handling +├── config.ts # Configuration constants +├── types.ts # TypeScript type definitions +├── utils.ts # Utilities (bandwidth, response, AI, benchmarks, candidates, techSpecs) +└── handlers/ + ├── health.ts # GET /api/health + ├── servers.ts # GET /api/servers - List servers with filtering + └── recommend.ts # POST /api/recommend - AI-powered recommendations ``` ### Key Data Flow -1. User sends natural language request (`tech_stack`, `expected_users`, `use_case`, `region_preference`) -2. `queryCandidateServers()` finds matching servers with **flexible region matching** (supports country codes, names, city names) -3. `queryVPSBenchmarks()` retrieves Geekbench 6 scores, **prioritizing same provider match** -4. AI analyzes and returns 3 tiers: Budget, Balanced, Premium +1. User sends request (`tech_stack`, `expected_users`, `use_case`, `region_preference`) +2. Tech specs calculation with **DB workload multiplier** based on use_case +3. Candidate filtering with **flexible region matching** +4. VPS benchmarks retrieval (Geekbench 6), **prioritizing same provider** +5. AI analysis returns 3 tiers: Budget, Balanced, Premium +6. Results cached in KV (5 min TTL, empty results not cached) ### D1 Database Tables (cloud-instances-db) @@ -52,12 +55,42 @@ src/index.ts (single file Worker) - `instance_types` - Server specifications - `pricing` - Regional pricing - `regions` - Geographic regions -- `vps_benchmarks` - Geekbench 6 benchmark data (269 records, manually seeded) +- `tech_specs` - Resource requirements per technology (vcpu_per_users, min_memory_mb) +- `vps_benchmarks` - Geekbench 6 benchmark data (269 records) - `benchmark_results` / `benchmark_types` / `processors` - Phoronix benchmark data ## Key Implementation Details -### Flexible Region Matching (`queryCandidateServers`) +### DB Workload Multiplier (`recommend.ts`) + +Database resource requirements vary by workload type, not just user count: + +| Workload Type | Multiplier | Example Use Cases | +|---------------|------------|-------------------| +| Heavy | 0.3x | analytics, log server, reporting, dashboard | +| Medium-Heavy | 0.5x | e-commerce, ERP, CRM, community forum | +| Medium | 0.7x | API, SaaS, app backend | +| Light | 1.0x | blog, portfolio, documentation, wiki | + +**Example**: PostgreSQL (vcpu_per_users: 200) with 1000 users +- Analytics dashboard: 1000 / (200 × 0.3) = 17 vCPU +- E-commerce: 1000 / (200 × 0.5) = 10 vCPU +- Personal blog: 1000 / (200 × 1.0) = 5 vCPU + +### Bandwidth Estimation (`bandwidth.ts`) + +Estimates monthly bandwidth based on use_case patterns: + +| Pattern | Page Size | Pages/Day | Active Ratio | +|---------|-----------|-----------|--------------| +| E-commerce | 2.5MB | 20 | 40% | +| Streaming | 50MB | 5 | 20% | +| Analytics | 0.7MB | 30 | 50% | +| Blog/Content | 1.5MB | 4 | 30% | + +Heavy bandwidth (>1TB/month) prefers Linode for included bandwidth. + +### Flexible Region Matching (`candidates.ts`) Region matching supports multiple input formats: ```sql @@ -67,34 +100,29 @@ LOWER(r.region_name) LIKE ? OR LOWER(r.country_code) = ? ``` -Valid inputs: `"korea"`, `"KR"`, `"seoul"`, `"ap-northeast-2"` +Valid inputs: `"korea"`, `"KR"`, `"seoul"`, `"ap-northeast-2"`, `"icn"` -### Provider-Priority Benchmark Matching (`queryVPSBenchmarks`) +### AI Prompt Strategy (`ai.ts`) -1. First tries exact provider match -2. Falls back to similar spec match from any provider -3. Used to attach real benchmark data to recommendations - -### AI Prompt Strategy - -- System prompt emphasizes cost-efficiency and minimum viable specs -- Tech stack → resource guidelines (e.g., "Nginx: 1 vCPU per 1000 users") +- Uses OpenAI GPT-4o-mini via Cloudflare AI Gateway (bypasses regional restrictions) +- Server list format: `[server_id=XXXX] Provider Name...` for accurate ID extraction - Scoring: Cost efficiency (40%) + Capacity fit (30%) + Scalability (30%) -- Budget option should score highest if viable +- Capacity response in Korean for Korean users ## Bindings (wrangler.toml) ```toml -[ai] -binding = "AI" +[[kv_namespaces]] +binding = "CACHE" +id = "c68cdb477022424cbe4594f491390c8a" [[d1_databases]] binding = "DB" database_name = "cloud-instances-db" database_id = "bbcb472d-b25e-4e48-b6ea-112f9fffb4a8" -# KV Cache (optional, not configured) -# binding = "CACHE" +[vars] +OPENAI_API_KEY = "sk-..." # Set via wrangler secret ``` ## Testing @@ -103,19 +131,32 @@ database_id = "bbcb472d-b25e-4e48-b6ea-112f9fffb4a8" # Health check curl https://server-recommend.kappa-d8e.workers.dev/api/health -# Recommendation +# Recommendation (e-commerce) curl -X POST https://server-recommend.kappa-d8e.workers.dev/api/recommend \ -H "Content-Type: application/json" \ -d '{ "tech_stack": ["php", "mysql"], "expected_users": 1000, - "use_case": "community forum", + "use_case": "e-commerce shopping mall", "region_preference": ["korea"] }' + +# Recommendation (analytics - heavier DB workload) +curl -X POST https://server-recommend.kappa-d8e.workers.dev/api/recommend \ + -H "Content-Type: application/json" \ + -d '{ + "tech_stack": ["postgresql"], + "expected_users": 500, + "use_case": "analytics dashboard", + "region_preference": ["japan"] + }' ``` -## Known Limitations +## Recent Changes -- AI recommendations may be inaccurate for specialized workloads (game servers, Minecraft) -- KV cache is not currently configured (CACHE binding commented out in wrangler.toml) -- `src/types.ts` contains legacy type definitions (not actively used, actual types inline in index.ts) +- **Modular architecture**: Split from single 2370-line file into 7 modules +- **DB workload multiplier**: Database resource calculation based on use_case +- **KV caching**: 5-minute cache with smart invalidation (empty results not cached) +- **OpenAI integration**: GPT-4o-mini via AI Gateway for better recommendations +- **Bandwidth estimation**: Automatic bandwidth category detection for provider filtering +- **Tech specs update**: Realistic vcpu_per_users values for 150+ technologies diff --git a/fix-tech-specs.sql b/fix-tech-specs.sql new file mode 100644 index 0000000..0f0543c --- /dev/null +++ b/fix-tech-specs.sql @@ -0,0 +1,292 @@ +-- Fix tech_specs vcpu_per_users to realistic values +-- vcpu_per_users = how many users 1 vCPU can handle (higher = lighter workload) + +-- ============================================ +-- WEB SERVERS (very light - reverse proxy, static) +-- ============================================ +UPDATE tech_specs SET vcpu_per_users = 2000 WHERE name = 'nginx'; +UPDATE tech_specs SET vcpu_per_users = 2000 WHERE name = 'caddy'; +UPDATE tech_specs SET vcpu_per_users = 1500 WHERE name = 'traefik'; +UPDATE tech_specs SET vcpu_per_users = 800 WHERE name = 'apache'; +UPDATE tech_specs SET vcpu_per_users = 2000 WHERE name = 'haproxy'; + +-- ============================================ +-- CACHES (very light - in-memory) +-- ============================================ +UPDATE tech_specs SET vcpu_per_users = 2000 WHERE name = 'redis'; +UPDATE tech_specs SET vcpu_per_users = 3000 WHERE name = 'memcached'; + +-- ============================================ +-- RUNTIMES / LANGUAGES +-- ============================================ +-- Light runtimes (Go, Rust - very efficient) +UPDATE tech_specs SET vcpu_per_users = 800 WHERE name = 'go'; +UPDATE tech_specs SET vcpu_per_users = 800 WHERE name = 'rust'; + +-- Medium runtimes (Node.js, Deno, Bun) +UPDATE tech_specs SET vcpu_per_users = 400 WHERE name = 'nodejs'; +UPDATE tech_specs SET vcpu_per_users = 400 WHERE name = 'deno'; +UPDATE tech_specs SET vcpu_per_users = 500 WHERE name = 'bun'; + +-- Scripting languages (PHP, Python, Ruby) +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'php'; +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'python'; +UPDATE tech_specs SET vcpu_per_users = 150 WHERE name = 'ruby'; + +-- JVM-based (heavier due to JVM overhead) +UPDATE tech_specs SET vcpu_per_users = 100 WHERE name = 'java'; +UPDATE tech_specs SET vcpu_per_users = 100 WHERE name = 'kotlin'; +UPDATE tech_specs SET vcpu_per_users = 80 WHERE name = 'scala'; + +-- .NET +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'dotnet'; + +-- ============================================ +-- DATABASES +-- ============================================ +-- Light databases +UPDATE tech_specs SET vcpu_per_users = 1500 WHERE name = 'sqlite'; + +-- Standard RDBMS +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'mysql'; +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'postgresql'; +UPDATE tech_specs SET vcpu_per_users = 150 WHERE name = 'mariadb'; +UPDATE tech_specs SET vcpu_per_users = 100 WHERE name = 'mssql'; + +-- NoSQL (document-based - lighter queries) +UPDATE tech_specs SET vcpu_per_users = 300 WHERE name = 'mongodb'; +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'couchdb'; + +-- Time-series / Analytics (heavy) +UPDATE tech_specs SET vcpu_per_users = 50 WHERE name = 'clickhouse'; +UPDATE tech_specs SET vcpu_per_users = 80 WHERE name = 'timescaledb'; +UPDATE tech_specs SET vcpu_per_users = 80 WHERE name = 'questdb'; +UPDATE tech_specs SET vcpu_per_users = 100 WHERE name = 'influxdb'; + +-- Search engines (heavy) +UPDATE tech_specs SET vcpu_per_users = 50 WHERE name = 'elasticsearch'; + +-- Distributed databases (heavy) +UPDATE tech_specs SET vcpu_per_users = 80 WHERE name = 'cassandra'; +UPDATE tech_specs SET vcpu_per_users = 50 WHERE name = 'cockroachdb'; +UPDATE tech_specs SET vcpu_per_users = 50 WHERE name = 'vitess'; +UPDATE tech_specs SET vcpu_per_users = 60 WHERE name = 'neo4j'; + +-- ============================================ +-- MESSAGE QUEUES +-- ============================================ +UPDATE tech_specs SET vcpu_per_users = 300 WHERE name = 'rabbitmq'; +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'kafka'; +UPDATE tech_specs SET vcpu_per_users = 500 WHERE name = 'nats'; + +-- ============================================ +-- WEB FRAMEWORKS +-- ============================================ +-- Node.js frameworks +UPDATE tech_specs SET vcpu_per_users = 300 WHERE name = 'express'; +UPDATE tech_specs SET vcpu_per_users = 350 WHERE name = 'fastify'; +UPDATE tech_specs SET vcpu_per_users = 250 WHERE name = 'nestjs'; +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'nextjs'; +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'nuxt'; +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'remix'; +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'adonis'; + +-- Python frameworks +UPDATE tech_specs SET vcpu_per_users = 150 WHERE name = 'django'; +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'flask'; +UPDATE tech_specs SET vcpu_per_users = 300 WHERE name = 'fastapi'; + +-- Ruby frameworks +UPDATE tech_specs SET vcpu_per_users = 100 WHERE name = 'rails'; + +-- PHP frameworks +UPDATE tech_specs SET vcpu_per_users = 150 WHERE name = 'laravel'; +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'symfony'; + +-- Java frameworks +UPDATE tech_specs SET vcpu_per_users = 80 WHERE name = 'spring-boot'; + +-- Go frameworks +UPDATE tech_specs SET vcpu_per_users = 600 WHERE name = 'gin'; +UPDATE tech_specs SET vcpu_per_users = 600 WHERE name = 'fiber'; +UPDATE tech_specs SET vcpu_per_users = 600 WHERE name = 'echo'; + +-- ============================================ +-- CMS / ECOMMERCE +-- ============================================ +UPDATE tech_specs SET vcpu_per_users = 150 WHERE name = 'wordpress'; +UPDATE tech_specs SET vcpu_per_users = 100 WHERE name = 'drupal'; +UPDATE tech_specs SET vcpu_per_users = 100 WHERE name = 'joomla'; +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'ghost'; +UPDATE tech_specs SET vcpu_per_users = 150 WHERE name = 'strapi'; +UPDATE tech_specs SET vcpu_per_users = 150 WHERE name = 'directus'; +UPDATE tech_specs SET vcpu_per_users = 100 WHERE name = 'payload'; +UPDATE tech_specs SET vcpu_per_users = 100 WHERE name = 'keystone'; +UPDATE tech_specs SET vcpu_per_users = 100 WHERE name = 'wagtail'; + +-- Heavy CMS/ERP +UPDATE tech_specs SET vcpu_per_users = 40 WHERE name = 'magento'; +UPDATE tech_specs SET vcpu_per_users = 50 WHERE name = 'woocommerce'; +UPDATE tech_specs SET vcpu_per_users = 40 WHERE name = 'odoo'; +UPDATE tech_specs SET vcpu_per_users = 40 WHERE name = 'erpnext'; +UPDATE tech_specs SET vcpu_per_users = 80 WHERE name = 'shopify'; +UPDATE tech_specs SET vcpu_per_users = 80 WHERE name = 'medusa'; +UPDATE tech_specs SET vcpu_per_users = 80 WHERE name = 'saleor'; + +-- ============================================ +-- COMMUNICATION / CHAT +-- ============================================ +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'mumble'; +UPDATE tech_specs SET vcpu_per_users = 150 WHERE name = 'teamspeak'; +UPDATE tech_specs SET vcpu_per_users = 100 WHERE name = 'matrix'; +UPDATE tech_specs SET vcpu_per_users = 100 WHERE name = 'mattermost'; +UPDATE tech_specs SET vcpu_per_users = 80 WHERE name = 'rocketchat'; +UPDATE tech_specs SET vcpu_per_users = 80 WHERE name = 'zulip'; +UPDATE tech_specs SET vcpu_per_users = 80 WHERE name = 'revolt'; +UPDATE tech_specs SET vcpu_per_users = 500 WHERE name = 'discord-bot'; + +-- Video conferencing (CPU intensive) +UPDATE tech_specs SET vcpu_per_users = 5 WHERE name = 'jitsi'; + +-- ============================================ +-- DEVOPS / CI/CD +-- ============================================ +UPDATE tech_specs SET vcpu_per_users = 30 WHERE name = 'gitlab'; +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'gitea'; +UPDATE tech_specs SET vcpu_per_users = 150 WHERE name = 'gitness'; +UPDATE tech_specs SET vcpu_per_users = 50 WHERE name = 'jenkins'; +UPDATE tech_specs SET vcpu_per_users = 100 WHERE name = 'drone'; +UPDATE tech_specs SET vcpu_per_users = 100 WHERE name = 'woodpecker'; +UPDATE tech_specs SET vcpu_per_users = 60 WHERE name = 'concourse'; +UPDATE tech_specs SET vcpu_per_users = 100 WHERE name = 'argocd'; +UPDATE tech_specs SET vcpu_per_users = 60 WHERE name = 'sonarqube'; +UPDATE tech_specs SET vcpu_per_users = 80 WHERE name = 'harbor'; + +-- ============================================ +-- MONITORING / OBSERVABILITY +-- ============================================ +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'grafana'; +UPDATE tech_specs SET vcpu_per_users = 100 WHERE name = 'prometheus'; +UPDATE tech_specs SET vcpu_per_users = 80 WHERE name = 'loki'; +UPDATE tech_specs SET vcpu_per_users = 100 WHERE name = 'jaeger'; +UPDATE tech_specs SET vcpu_per_users = 150 WHERE name = 'zipkin'; +UPDATE tech_specs SET vcpu_per_users = 100 WHERE name = 'zabbix'; +UPDATE tech_specs SET vcpu_per_users = 150 WHERE name = 'nagios'; +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'netdata'; +UPDATE tech_specs SET vcpu_per_users = 300 WHERE name = 'uptime-kuma'; + +-- ============================================ +-- AUTOMATION / LOW-CODE +-- ============================================ +UPDATE tech_specs SET vcpu_per_users = 100 WHERE name = 'n8n'; +UPDATE tech_specs SET vcpu_per_users = 150 WHERE name = 'nocodb'; +UPDATE tech_specs SET vcpu_per_users = 100 WHERE name = 'appwrite'; +UPDATE tech_specs SET vcpu_per_users = 100 WHERE name = 'supabase'; + +-- ============================================ +-- DOCUMENTATION / WIKI +-- ============================================ +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'bookstack'; +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'wiki-js'; +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'hedgedoc'; +UPDATE tech_specs SET vcpu_per_users = 150 WHERE name = 'outline'; + +-- ============================================ +-- MEDIA / FILES +-- ============================================ +UPDATE tech_specs SET vcpu_per_users = 100 WHERE name = 'nextcloud'; +UPDATE tech_specs SET vcpu_per_users = 100 WHERE name = 'seafile'; +UPDATE tech_specs SET vcpu_per_users = 50 WHERE name = 'photoprism'; +UPDATE tech_specs SET vcpu_per_users = 50 WHERE name = 'immich'; + +-- Media streaming (CPU intensive for transcoding) +UPDATE tech_specs SET vcpu_per_users = 5 WHERE name = 'plex'; +UPDATE tech_specs SET vcpu_per_users = 5 WHERE name = 'jellyfin'; +UPDATE tech_specs SET vcpu_per_users = 10 WHERE name = 'audiobookshelf'; +UPDATE tech_specs SET vcpu_per_users = 100 WHERE name = 'calibre-web'; + +-- ============================================ +-- AI / ML (very CPU/GPU intensive) +-- ============================================ +UPDATE tech_specs SET vcpu_per_users = 1 WHERE name = 'ollama'; +UPDATE tech_specs SET vcpu_per_users = 1 WHERE name = 'vllm'; +UPDATE tech_specs SET vcpu_per_users = 1 WHERE name = 'text-generation-webui'; +UPDATE tech_specs SET vcpu_per_users = 1 WHERE name = 'localai'; +UPDATE tech_specs SET vcpu_per_users = 1 WHERE name = 'stable-diffusion'; +UPDATE tech_specs SET vcpu_per_users = 1 WHERE name = 'comfyui'; +UPDATE tech_specs SET vcpu_per_users = 2 WHERE name = 'whisper'; +UPDATE tech_specs SET vcpu_per_users = 10 WHERE name = 'langchain'; +UPDATE tech_specs SET vcpu_per_users = 10 WHERE name = 'tensorflow'; +UPDATE tech_specs SET vcpu_per_users = 10 WHERE name = 'pytorch'; + +-- ============================================ +-- GAME SERVERS (CPU intensive, low player count) +-- ============================================ +UPDATE tech_specs SET vcpu_per_users = 10 WHERE name = 'minecraft'; +UPDATE tech_specs SET vcpu_per_users = 15 WHERE name = 'minecraft-bedrock'; +UPDATE tech_specs SET vcpu_per_users = 10 WHERE name = 'valheim'; +UPDATE tech_specs SET vcpu_per_users = 15 WHERE name = 'terraria'; +UPDATE tech_specs SET vcpu_per_users = 8 WHERE name = 'factorio'; +UPDATE tech_specs SET vcpu_per_users = 5 WHERE name = 'ark'; +UPDATE tech_specs SET vcpu_per_users = 5 WHERE name = 'rust-game'; +UPDATE tech_specs SET vcpu_per_users = 5 WHERE name = 'palworld'; +UPDATE tech_specs SET vcpu_per_users = 5 WHERE name = '7daystodie'; +UPDATE tech_specs SET vcpu_per_users = 5 WHERE name = 'satisfactory'; +UPDATE tech_specs SET vcpu_per_users = 5 WHERE name = 'enshrouded'; +UPDATE tech_specs SET vcpu_per_users = 8 WHERE name = 'projectzomboid'; +UPDATE tech_specs SET vcpu_per_users = 5 WHERE name = 'conanexiles'; +UPDATE tech_specs SET vcpu_per_users = 5 WHERE name = 'dayz'; +UPDATE tech_specs SET vcpu_per_users = 8 WHERE name = 'vrising'; +UPDATE tech_specs SET vcpu_per_users = 15 WHERE name = 'csgo'; +UPDATE tech_specs SET vcpu_per_users = 20 WHERE name = 'l4d2'; +UPDATE tech_specs SET vcpu_per_users = 15 WHERE name = 'gmod'; +UPDATE tech_specs SET vcpu_per_users = 20 WHERE name = 'unturned'; + +-- ============================================ +-- VIDEO / ENCODING (CPU intensive) +-- ============================================ +UPDATE tech_specs SET vcpu_per_users = 3 WHERE name = 'ffmpeg'; +UPDATE tech_specs SET vcpu_per_users = 5 WHERE name = 'obs'; + +-- ============================================ +-- SECURITY / AUTH +-- ============================================ +UPDATE tech_specs SET vcpu_per_users = 300 WHERE name = 'vaultwarden'; +UPDATE tech_specs SET vcpu_per_users = 100 WHERE name = 'vault'; +UPDATE tech_specs SET vcpu_per_users = 150 WHERE name = 'keycloak'; +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'authelia'; +UPDATE tech_specs SET vcpu_per_users = 150 WHERE name = 'authentik'; +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'crowdsec'; + +-- ============================================ +-- NETWORKING / PROXY / VPN +-- ============================================ +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'openvpn'; +UPDATE tech_specs SET vcpu_per_users = 300 WHERE name = 'wireguard'; +UPDATE tech_specs SET vcpu_per_users = 150 WHERE name = 'squid'; + +-- ============================================ +-- BACKUP (depends on compression) +-- ============================================ +UPDATE tech_specs SET vcpu_per_users = 50 WHERE name = 'borgbackup'; +UPDATE tech_specs SET vcpu_per_users = 50 WHERE name = 'restic'; +UPDATE tech_specs SET vcpu_per_users = 50 WHERE name = 'duplicati'; + +-- ============================================ +-- CONTAINER / ORCHESTRATION +-- ============================================ +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'docker'; +UPDATE tech_specs SET vcpu_per_users = 100 WHERE name = 'kubernetes'; +UPDATE tech_specs SET vcpu_per_users = 150 WHERE name = 'nomad'; +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'consul'; + +-- ============================================ +-- MISC / UTILITIES +-- ============================================ +UPDATE tech_specs SET vcpu_per_users = 300 WHERE name = 'homeassistant'; +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'paperless'; +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'freshrss'; +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'wallabag'; +UPDATE tech_specs SET vcpu_per_users = 300 WHERE name = 'mealie'; +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'excalidraw'; +UPDATE tech_specs SET vcpu_per_users = 200 WHERE name = 'graphql'; diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..b3d6dc5 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,148 @@ +/** + * Configuration constants and use case mappings + */ + +import type { UseCaseConfig } from './types'; + +export const USE_CASE_CONFIGS: UseCaseConfig[] = [ + { + category: 'video', + patterns: /video|stream|media|youtube|netflix|vod|동영상|스트리밍|미디어/i, + dauMultiplier: { min: 8, max: 12 }, + activeRatio: 0.3 + }, + { + category: 'file', + patterns: /download|file|storage|cdn|파일|다운로드|저장소/i, + dauMultiplier: { min: 10, max: 14 }, + activeRatio: 0.5 + }, + { + category: 'gaming', + patterns: /game|gaming|minecraft|게임/i, + dauMultiplier: { min: 10, max: 20 }, + activeRatio: 0.5 + }, + { + category: 'api', + patterns: /api|saas|backend|서비스|백엔드/i, + dauMultiplier: { min: 5, max: 10 }, + activeRatio: 0.6 + }, + { + category: 'ecommerce', + patterns: /e-?commerce|shop|store|쇼핑|커머스|온라인몰/i, + dauMultiplier: { min: 20, max: 30 }, + activeRatio: 0.4 + }, + { + category: 'forum', + patterns: /forum|community|board|게시판|커뮤니티|포럼/i, + dauMultiplier: { min: 15, max: 25 }, + activeRatio: 0.5 + }, + { + category: 'blog', + patterns: /blog|news|static|portfolio|블로그|뉴스|포트폴리오|landing/i, + dauMultiplier: { min: 30, max: 50 }, + activeRatio: 0.3 + }, + { + category: 'chat', + patterns: /chat|messaging|slack|discord|채팅|메신저/i, + dauMultiplier: { min: 10, max: 14 }, + activeRatio: 0.7 + } +]; + +/** + * i18n Messages for multi-language support + */ +export const i18n: Record; + example: Record; + 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.' + } +}; diff --git a/src/handlers/health.ts b/src/handlers/health.ts new file mode 100644 index 0000000..ee7cdb3 --- /dev/null +++ b/src/handlers/health.ts @@ -0,0 +1,20 @@ +/** + * Health check endpoint handler + */ + +import { jsonResponse } from '../utils'; + +/** + * Health check endpoint + */ +export function handleHealth(corsHeaders: Record): Response { + return jsonResponse( + { + status: 'ok', + timestamp: new Date().toISOString(), + service: 'server-recommend', + }, + 200, + corsHeaders + ); +} diff --git a/src/handlers/recommend.ts b/src/handlers/recommend.ts new file mode 100644 index 0000000..7d613e7 --- /dev/null +++ b/src/handlers/recommend.ts @@ -0,0 +1,1240 @@ +/** + * POST /api/recommend - AI-powered server recommendation handler + */ + +import type { + Env, + RecommendRequest, + Server, + BenchmarkData, + VPSBenchmark, + TechSpec, + BandwidthEstimate, + RecommendationResult, + BenchmarkReference, + AIRecommendationResponse +} from '../types'; +import { i18n } from '../config'; +import { + jsonResponse, + validateRecommendRequest, + generateCacheKey, + estimateBandwidth, + calculateBandwidthInfo, + escapeLikePattern, + isValidServer, + isValidBenchmarkData, + isValidVPSBenchmark, + isValidTechSpec, + isValidAIRecommendation +} from '../utils'; + +export async function handleRecommend( + request: Request, + env: Env, + corsHeaders: Record +): Promise { + const requestId = crypto.randomUUID(); + + try { + // Check request body size to prevent large payload attacks + const contentLength = request.headers.get('Content-Length'); + if (contentLength && parseInt(contentLength, 10) > 10240) { // 10KB limit + return jsonResponse( + { error: 'Request body too large', max_size: '10KB' }, + 413, + corsHeaders + ); + } + + // Parse and validate request with actual body size check + const bodyText = await request.text(); + const actualBodySize = new TextEncoder().encode(bodyText).length; + + if (actualBodySize > 10240) { // 10KB limit + return jsonResponse( + { error: 'Request body too large', max_size: '10KB', actual_size: actualBodySize }, + 413, + corsHeaders + ); + } + + const body = JSON.parse(bodyText) 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; + + // DB workload multiplier based on use_case (databases need different resources based on workload type) + // Lower multiplier = heavier workload = more resources needed + const getDbWorkloadMultiplier = (useCase: string): { multiplier: number; type: string } => { + const lowerUseCase = useCase.toLowerCase(); + + // Heavy DB workloads (analytics, big data, reporting) - multiplier 0.3x + // Note: use \blog(s|ging)?\b to match "log", "logs", "logging" but NOT "blog" + if (/analytics|warehouse|reporting|dashboard|\bbi\b|olap|\blog(s|ging)?\b|metric|monitoring|time.?series|대시보드|분석|리포트|로그/.test(lowerUseCase)) { + return { multiplier: 0.3, type: 'heavy (analytics/reporting)' }; + } + + // Medium-heavy DB workloads (e-commerce, ERP, CRM, social) - multiplier 0.5x + if (/e.?commerce|shop|store|cart|order|payment|erp|crm|inventory|social|community|forum|게시판|쇼핑몰|주문|결제|커뮤니티/.test(lowerUseCase)) { + return { multiplier: 0.5, type: 'medium-heavy (transactional)' }; + } + + // Medium DB workloads (API, SaaS, app backend) - multiplier 0.7x + if (/api|saas|backend|service|app|mobile|플랫폼|서비스|앱/.test(lowerUseCase)) { + return { multiplier: 0.7, type: 'medium (API/SaaS)' }; + } + + // Light DB workloads (blog, landing, portfolio, docs) - multiplier 1.0x (use default) + if (/blog|landing|portfolio|doc|wiki|static|personal|홈페이지|블로그|포트폴리오|문서/.test(lowerUseCase)) { + return { multiplier: 1.0, type: 'light (content/read-heavy)' }; + } + + // Default: medium workload + return { multiplier: 0.7, type: 'default (medium)' }; + }; + + const dbWorkload = getDbWorkloadMultiplier(body.use_case); + console.log(`[Recommend] DB workload inferred from use_case: ${dbWorkload.type} (multiplier: ${dbWorkload.multiplier})`); + + if (techSpecs.length > 0) { + // Group specs by category + const categoryWeights: Record = { + '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(); + + for (const spec of techSpecs) { + const category = spec.category || 'default'; + const weight = categoryWeights[category] || categoryWeights['default']; + + // Apply DB workload multiplier for database category + // Lower multiplier = heavier workload = higher resource needs (lower vcpu_per_users) + let effectiveVcpuPerUsers = spec.vcpu_per_users; + if (category === 'database') { + effectiveVcpuPerUsers = Math.max(1, Math.floor(spec.vcpu_per_users * dbWorkload.multiplier)); + } + + const vcpuNeeded = Math.ceil(body.expected_users / effectiveVcpuPerUsers); + const weightedVcpu = vcpuNeeded * weight; + + const existing = categoryRequirements.get(category) || 0; + // Take max within same category (not additive) + categoryRequirements.set(category, Math.max(existing, weightedVcpu)); + + const dbNote = category === 'database' ? ` (adjusted for ${dbWorkload.type})` : ''; + console.log(`[Recommend] ${spec.name} (${category}): ${vcpuNeeded} vCPU × ${weight} weight = ${weightedVcpu.toFixed(1)} weighted vCPU${dbNote}`); + } + + // 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})`); + + // Estimate specs for VPS benchmark query (doesn't need exact candidates) + const estimatedCores = minVcpu || 2; + const estimatedMemory = minMemoryMb ? Math.ceil(minMemoryMb / 1024) : 4; + const defaultProviders = bandwidthEstimate?.category === 'very_heavy' ? ['Linode'] : ['Linode', 'Vultr']; + + // Phase 2: Query candidate servers and VPS benchmarks in parallel + const [candidates, vpsBenchmarks] = await Promise.all([ + queryCandidateServers(env.DB, body, minMemoryMb, minVcpu, bandwidthEstimate, lang), + queryVPSBenchmarksBatch(env.DB, estimatedCores, estimatedMemory, defaultProviders).catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + console.warn('[Recommend] VPS benchmarks unavailable:', message); + return [] as VPSBenchmark[]; + }), + ]); + + console.log('[Recommend] Candidate servers:', candidates.length); + console.log('[Recommend] VPS benchmark data points:', vpsBenchmarks.length); + + if (candidates.length === 0) { + return jsonResponse( + { + error: 'No servers found matching your requirements', + recommendations: [], + request_id: requestId, + }, + 200, + corsHeaders + ); + } + + // Use initially fetched benchmark data (already filtered by tech stack) + const benchmarkData = benchmarkDataAll; + + // Use OpenAI GPT-4o-mini to analyze and recommend (techSpecs already queried above) + const aiResult = await getAIRecommendations( + env, + env.OPENAI_API_KEY, + body, + candidates, + benchmarkData, + vpsBenchmarks, + techSpecs, + bandwidthEstimate, + lang + ); + + console.log('[Recommend] Generated recommendations:', aiResult.recommendations.length); + + const response = { + recommendations: aiResult.recommendations, + infrastructure_tips: aiResult.infrastructure_tips || [], + bandwidth_estimate: { + monthly_tb: bandwidthEstimate.monthly_tb, + monthly_gb: bandwidthEstimate.monthly_gb, + daily_gb: bandwidthEstimate.daily_gb, + category: bandwidthEstimate.category, + description: bandwidthEstimate.description, + active_ratio: bandwidthEstimate.active_ratio, + calculation_note: `Based on ${body.expected_users} concurrent users with ${Math.round(bandwidthEstimate.active_ratio * 100)}% active ratio`, + }, + total_candidates: candidates.length, + cached: false, + }; + + // Cache result only if we have recommendations (don't cache empty/failed results) + if (env.CACHE && response.recommendations && response.recommendations.length > 0) { + await env.CACHE.put(cacheKey, JSON.stringify(response), { + expirationTtl: 300, // 5 minutes (reduced from 1 hour for faster iteration) + }); + } + + return jsonResponse(response, 200, corsHeaders); + } catch (error) { + console.error('[Recommend] Error:', error); + console.error('[Recommend] Error stack:', error instanceof Error ? error.stack : 'No stack'); + console.error('[Recommend] Error details:', error instanceof Error ? error.message : 'Unknown error'); + return jsonResponse( + { + error: 'Failed to generate recommendations', + request_id: requestId, + }, + 500, + corsHeaders + ); + } +} +async function queryCandidateServers( + db: D1Database, + req: RecommendRequest, + minMemoryMb?: number, + minVcpu?: number, + bandwidthEstimate?: BandwidthEstimate, + lang: string = 'en' +): Promise { + // 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, + 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 preference based on bandwidth requirements (no hard filtering to avoid empty results) + // Heavy/Very heavy bandwidth → Prefer Linode (better bandwidth allowance), but allow all providers + // AI prompt will warn about bandwidth costs for non-Linode providers + if (bandwidthEstimate) { + if (bandwidthEstimate.category === 'very_heavy') { + // >6TB/month: Strongly prefer Linode, but don't exclude others (Linode may not be available in all regions) + console.log(`[Candidates] Very heavy bandwidth (${bandwidthEstimate.monthly_tb}TB/month): Linode strongly preferred, all providers included`); + } else if (bandwidthEstimate.category === 'heavy') { + // 2-6TB/month: Prefer Linode + 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 = { + 'korea': ['seoul', 'koreacentral', 'kr', 'ap-northeast-2'], + 'south korea': ['seoul', 'koreacentral', 'kr', '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/very_heavy bandwidth, prioritize Linode (p.id=1) due to generous bandwidth allowance + const isHighBandwidth = bandwidthEstimate?.category === 'heavy' || bandwidthEstimate?.category === 'very_heavy'; + const orderByClause = isHighBandwidth + ? `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'); + } + + // Add currency to each result and validate with type guard + const serversWithCurrency = (result.results as unknown[]).map(server => { + if (typeof server === 'object' && server !== null) { + return { ...server, currency }; + } + return server; + }); + const validServers = serversWithCurrency.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 { + // Map tech stack to relevant benchmark types + const techToBenchmark: Record = { + '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(); + 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(); + 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 { + 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 { + 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(); + 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 { + 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( + env: Env, + apiKey: string, + req: RecommendRequest, + candidates: Server[], + benchmarkData: BenchmarkData[], + vpsBenchmarks: VPSBenchmark[], + techSpecs: TechSpec[], + bandwidthEstimate: BandwidthEstimate, + lang: string = 'en' +): Promise<{ recommendations: RecommendationResult[]; infrastructure_tips?: string[] }> { + // Validate API key before making any API calls + if (!apiKey || !apiKey.trim()) { + console.error('[AI] OPENAI_API_KEY is not configured or empty'); + throw new Error('OPENAI_API_KEY not configured. Please set the secret via: wrangler secret put OPENAI_API_KEY'); + } + if (!apiKey.startsWith('sk-')) { + console.error('[AI] OPENAI_API_KEY has invalid format (should start with sk-)'); + throw new Error('Invalid OPENAI_API_KEY format'); + } + console.log('[AI] API key validated (format: sk-***)'); + + // 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 + 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 (IMPORTANT: Use the server_id value, NOT the list number!) +${candidates.map((s) => ` +[server_id=${s.id}] ${s.provider_name} - ${s.instance_name}${s.instance_family ? ` (${s.instance_family})` : ''} + 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": 2045, // Use the actual server_id from [server_id=XXXX] above, NOT list position! + "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.`; + + // Use AI Gateway if configured (bypasses regional restrictions like HKG) + // AI Gateway URL format: https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_name}/openai + const useAIGateway = !!env.AI_GATEWAY_URL; + const apiEndpoint = useAIGateway + ? `${env.AI_GATEWAY_URL}/chat/completions` + : 'https://api.openai.com/v1/chat/completions'; + + console.log(`[AI] Sending request to ${useAIGateway ? 'AI Gateway → ' : ''}OpenAI GPT-4o-mini`); + if (useAIGateway) { + console.log('[AI] Using Cloudflare AI Gateway to bypass regional restrictions'); + } + + // Create AbortController with 30 second timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); + + try { + const openaiResponse = await fetch(apiEndpoint, { + 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(); + + // Parse error details for better debugging + let errorDetails = ''; + try { + const errorObj = JSON.parse(errorText); + errorDetails = errorObj?.error?.message || errorObj?.error?.type || ''; + } catch { + errorDetails = errorText.slice(0, 200); + } + + // Sanitize API keys from error messages + const sanitized = errorDetails.replace(/sk-[a-zA-Z0-9-_]+/g, 'sk-***'); + + // Enhanced logging for specific error codes + if (openaiResponse.status === 403) { + const isRegionalBlock = errorDetails.includes('Country') || errorDetails.includes('region') || errorDetails.includes('territory'); + if (isRegionalBlock && !useAIGateway) { + console.error('[AI] ❌ REGIONAL BLOCK (403) - OpenAI blocked this region'); + console.error('[AI] Worker is running in a blocked region (e.g., HKG)'); + console.error('[AI] FIX: Set AI_GATEWAY_URL secret to use Cloudflare AI Gateway'); + console.error('[AI] 1. Create AI Gateway: https://dash.cloudflare.com → AI → AI Gateway'); + console.error('[AI] 2. Run: wrangler secret put AI_GATEWAY_URL'); + console.error('[AI] 3. Enter: https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_name}/openai'); + } else { + console.error('[AI] ❌ AUTH FAILED (403) - Possible causes:'); + console.error('[AI] 1. Invalid or expired OPENAI_API_KEY'); + console.error('[AI] 2. API key not properly set in Cloudflare secrets'); + console.error('[AI] 3. Account billing issue or quota exceeded'); + } + console.error('[AI] Error details:', sanitized); + } else if (openaiResponse.status === 429) { + console.error('[AI] ⚠️ RATE LIMITED (429) - Too many requests'); + console.error('[AI] Error details:', sanitized); + } else if (openaiResponse.status === 401) { + console.error('[AI] ❌ UNAUTHORIZED (401) - API key invalid'); + console.error('[AI] Error details:', sanitized); + } else { + 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(); + 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) + ); + } + + // Calculate bandwidth info for this server + const bandwidthInfo = calculateBandwidthInfo(server, bandwidthEstimate); + + results.push({ + server: server, + score: aiRec.score, + analysis: aiRec.analysis, + estimated_capacity: aiRec.estimated_capacity, + bandwidth_info: bandwidthInfo, + 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'}`); + } +} diff --git a/src/handlers/servers.ts b/src/handlers/servers.ts new file mode 100644 index 0000000..8cb7785 --- /dev/null +++ b/src/handlers/servers.ts @@ -0,0 +1,139 @@ +/** + * GET /api/servers - Server list with filtering handler + */ + +import type { Env } from '../types'; +import { jsonResponse, isValidServer } from '../utils'; + +/** + * GET /api/servers - Server list with filtering + */ +export async function handleGetServers( + request: Request, + env: Env, + corsHeaders: Record +): Promise { + 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 + ); + } +} diff --git a/src/index.ts b/src/index.ts index 69b948a..43d8213 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,709 +1,14 @@ /** - * Cloudflare Worker - Server Recommendation System + * Cloudflare Worker - Server Recommendation System Entry Point * * 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; - AI_GATEWAY_URL?: string; // Cloudflare AI Gateway URL to bypass regional restrictions -} - -interface ValidationError { - error: string; - missing_fields?: string[]; - invalid_fields?: { field: string; reason: string }[]; - schema: Record; - example: Record; -} - -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 BandwidthInfo { - included_transfer_tb: number; // 기본 포함 트래픽 (TB/월) - overage_cost_per_gb: number; // 초과 비용 ($/GB) - overage_cost_per_tb: number; // 초과 비용 ($/TB) - estimated_monthly_tb: number; // 예상 월간 사용량 (TB) - estimated_overage_tb: number; // 예상 초과량 (TB) - estimated_overage_cost: number; // 예상 초과 비용 ($) - total_estimated_cost: number; // 총 예상 비용 (서버 + 트래픽) - warning?: 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; - }; - bandwidth_info?: BandwidthInfo; - 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) - active_ratio: number; // Active user ratio (0.0-1.0) -} - -// Use case configuration for bandwidth estimation and user metrics -interface UseCaseConfig { - category: 'video' | 'file' | 'gaming' | 'api' | 'ecommerce' | 'forum' | 'blog' | 'chat' | 'default'; - patterns: RegExp; - dauMultiplier: { min: number; max: number }; - activeRatio: number; -} - -const USE_CASE_CONFIGS: UseCaseConfig[] = [ - { - category: 'video', - patterns: /video|stream|media|youtube|netflix|vod|동영상|스트리밍|미디어/i, - dauMultiplier: { min: 8, max: 12 }, - activeRatio: 0.3 - }, - { - category: 'file', - patterns: /download|file|storage|cdn|파일|다운로드|저장소/i, - dauMultiplier: { min: 10, max: 14 }, - activeRatio: 0.5 - }, - { - category: 'gaming', - patterns: /game|gaming|minecraft|게임/i, - dauMultiplier: { min: 10, max: 20 }, - activeRatio: 0.5 - }, - { - category: 'api', - patterns: /api|saas|backend|서비스|백엔드/i, - dauMultiplier: { min: 5, max: 10 }, - activeRatio: 0.6 - }, - { - category: 'ecommerce', - patterns: /e-?commerce|shop|store|쇼핑|커머스|온라인몰/i, - dauMultiplier: { min: 20, max: 30 }, - activeRatio: 0.4 - }, - { - category: 'forum', - patterns: /forum|community|board|게시판|커뮤니티|포럼/i, - dauMultiplier: { min: 15, max: 25 }, - activeRatio: 0.5 - }, - { - category: 'blog', - patterns: /blog|news|static|portfolio|블로그|뉴스|포트폴리오|landing/i, - dauMultiplier: { min: 30, max: 50 }, - activeRatio: 0.3 - }, - { - category: 'chat', - patterns: /chat|messaging|slack|discord|채팅|메신저/i, - dauMultiplier: { min: 10, max: 14 }, - activeRatio: 0.7 - } -]; - -/** - * Find use case configuration by matching patterns - */ -function findUseCaseConfig(useCase: string): UseCaseConfig { - const useCaseLower = useCase.toLowerCase(); - - for (const config of USE_CASE_CONFIGS) { - if (config.patterns.test(useCaseLower)) { - return config; - } - } - - // Default configuration - return { - category: 'default', - patterns: /.*/, - dauMultiplier: { min: 10, max: 14 }, - activeRatio: 0.5 - }; -} - -/** - * Get DAU multiplier based on use case (how many daily active users per concurrent user) - */ -function getDauMultiplier(useCase: string): { min: number; max: number } { - return findUseCaseConfig(useCase).dauMultiplier; -} - -/** - * Get active user ratio (what percentage of DAU actually performs the bandwidth-heavy action) - */ -function getActiveUserRatio(useCase: string): number { - return findUseCaseConfig(useCase).activeRatio; -} - -/** - * 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(); - - // Get use case configuration - const config = findUseCaseConfig(useCase); - const useCaseCategory = config.category; - - // Calculate DAU estimate from concurrent users with use-case-specific multipliers - const dauMultiplier = config.dauMultiplier; - const estimatedDauMin = Math.round(concurrentUsers * dauMultiplier.min); - const estimatedDauMax = Math.round(concurrentUsers * dauMultiplier.max); - const dailyUniqueVisitors = Math.round((estimatedDauMin + estimatedDauMax) / 2); - const activeUserRatio = config.activeRatio; - const activeDau = Math.round(dailyUniqueVisitors * activeUserRatio); - - // 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 - } - - let dailyBandwidthGB: number; - let bandwidthModel: string; - - // ========== IMPROVED BANDWIDTH MODELS ========== - // Each use case uses the most appropriate calculation method - - switch (useCaseCategory) { - case 'video': { - // VIDEO/STREAMING: Bitrate-based model - // - HD streaming: ~5 Mbps = 2.25 GB/hour - // - Average watch time: 1.5 hours per session - // - 4K streaming: ~25 Mbps = 11.25 GB/hour (detect if mentioned) - const is4K = /4k|uhd|ultra/i.test(useCaseLower); - const bitrateGBperHour = is4K ? 11.25 : 2.25; // 4K vs HD - const avgWatchTimeHours = is4K ? 1.0 : 1.5; // 4K users watch less - const gbPerActiveUser = bitrateGBperHour * avgWatchTimeHours; - dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; - bandwidthModel = `bitrate-based: ${activeDau} active × ${bitrateGBperHour} GB/hr × ${avgWatchTimeHours}hr`; - break; - } - - case 'file': { - // FILE DOWNLOAD: File-size based model - // - Average file size: 100-500 MB depending on type - // - Downloads per active user: 2-5 per day - const isLargeFiles = /iso|video|backup|대용량/.test(useCaseLower); - const avgFileSizeGB = isLargeFiles ? 2.0 : 0.2; // 2GB for large, 200MB for normal - const downloadsPerUser = isLargeFiles ? 1 : 3; - const gbPerActiveUser = avgFileSizeGB * downloadsPerUser; - dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; - bandwidthModel = `file-based: ${activeDau} active × ${avgFileSizeGB} GB × ${downloadsPerUser} downloads`; - break; - } - - case 'gaming': { - // GAMING: Session-duration based model - // - Multiplayer games: 50-150 MB/hour (small packets, frequent) - // - Average session: 2-3 hours - // - Minecraft specifically uses more due to chunk loading - const isMinecraft = /minecraft|마인크래프트/.test(useCaseLower); - const mbPerHour = isMinecraft ? 150 : 80; // Minecraft uses more - const avgSessionHours = isMinecraft ? 3 : 2.5; - const gbPerActiveUser = (mbPerHour * avgSessionHours) / 1024; - dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; - bandwidthModel = `session-based: ${activeDau} active × ${mbPerHour} MB/hr × ${avgSessionHours}hr`; - break; - } - - case 'api': { - // API/SAAS: Request-based model - // - Average request+response: 10-50 KB - // - Requests per active user per day: 500-2000 - const avgRequestKB = 20; - const requestsPerUserPerDay = 1000; - const gbPerActiveUser = (avgRequestKB * requestsPerUserPerDay) / (1024 * 1024); - dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; - bandwidthModel = `request-based: ${activeDau} active × ${avgRequestKB}KB × ${requestsPerUserPerDay} req`; - break; - } - - case 'ecommerce': { - // E-COMMERCE: Page-based model (images heavy) - // - Average page with images: 2-3 MB - // - Pages per session: 15-25 (product browsing) - const avgPageSizeMB = 2.5; - const pagesPerSession = 20; - const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024; - dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; - bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`; - break; - } - - case 'forum': { - // FORUM/COMMUNITY: Page-based model (text + some images) - // - Average page: 0.5-1 MB - // - Pages per session: 20-40 (thread reading) - const avgPageSizeMB = 0.7; - const pagesPerSession = 30; - const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024; - dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; - bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`; - break; - } - - case 'blog': { - // STATIC/BLOG: Lightweight page-based model - // - Average page: 1-2 MB (optimized images) - // - Pages per session: 3-5 (bounce rate high) - const avgPageSizeMB = 1.5; - const pagesPerSession = 4; - const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024; - dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; - bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`; - break; - } - - case 'chat': { - // CHAT/MESSAGING: Message-based model - // - Average message: 1-5 KB (text + small attachments) - // - Messages per active user: 100-500 per day - // - Some image/file sharing: adds 10-50 MB/user - const textBandwidthMB = (3 * 200) / 1024; // 3KB × 200 messages - const attachmentBandwidthMB = 20; // occasional images/files - const gbPerActiveUser = (textBandwidthMB + attachmentBandwidthMB) / 1024; - dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; - bandwidthModel = `message-based: ${activeDau} active × ~20MB/user (text+attachments)`; - break; - } - - default: { - // DEFAULT: General web app (page-based) - const avgPageSizeMB = 1.0; - const pagesPerSession = 10; - const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024; - dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; - bandwidthModel = `page-based (default): ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`; - break; - } - } - - console.log(`[Bandwidth] Model: ${bandwidthModel}`); - console.log(`[Bandwidth] DAU: ${dailyUniqueVisitors} (${dauMultiplier.min}-${dauMultiplier.max}x), Active: ${activeDau} (${(activeUserRatio * 100).toFixed(0)}%), Daily: ${dailyBandwidthGB.toFixed(1)} GB`); - - // 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, - active_ratio: activeUserRatio - }; -} - -/** - * Get provider bandwidth allocation based on memory size - * Returns included transfer in TB/month - */ -function getProviderBandwidthAllocation(providerName: string, memoryGb: number): { - included_tb: number; - overage_per_gb: number; - overage_per_tb: number; -} { - const provider = providerName.toLowerCase(); - - if (provider.includes('linode')) { - // Linode: roughly 1TB per 1GB RAM (Nanode 1GB = 1TB, 2GB = 2TB, etc.) - // Max around 20TB for largest plans - const includedTb = Math.min(Math.max(memoryGb, 1), 20); - return { - included_tb: includedTb, - overage_per_gb: 0.005, // $0.005/GB = $5/TB - overage_per_tb: 5 - }; - } else if (provider.includes('vultr')) { - // Vultr: varies by plan, roughly 1-2TB for small, up to 10TB for large - // Generally less generous than Linode - let includedTb: number; - if (memoryGb <= 2) includedTb = 1; - else if (memoryGb <= 4) includedTb = 2; - else if (memoryGb <= 8) includedTb = 3; - else if (memoryGb <= 16) includedTb = 4; - else if (memoryGb <= 32) includedTb = 5; - else includedTb = Math.min(memoryGb / 4, 10); - - return { - included_tb: includedTb, - overage_per_gb: 0.01, // $0.01/GB = $10/TB - overage_per_tb: 10 - }; - } else { - // Default/Other providers: conservative estimate - return { - included_tb: Math.min(memoryGb, 5), - overage_per_gb: 0.01, - overage_per_tb: 10 - }; - } -} - -/** - * Calculate bandwidth cost info for a server - */ -function calculateBandwidthInfo( - server: Server, - bandwidthEstimate: BandwidthEstimate -): BandwidthInfo { - const allocation = getProviderBandwidthAllocation(server.provider_name, server.memory_gb); - const estimatedTb = bandwidthEstimate.monthly_tb; - const overageTb = Math.max(0, estimatedTb - allocation.included_tb); - const overageCost = overageTb * allocation.overage_per_tb; - - // Convert server price to USD if needed for total cost calculation - const serverPriceUsd = server.currency === 'KRW' - ? server.monthly_price / 1400 // Approximate KRW to USD - : server.monthly_price; - - const totalCost = serverPriceUsd + overageCost; - - let warning: string | undefined; - if (overageTb > allocation.included_tb) { - warning = `⚠️ 예상 트래픽(${estimatedTb.toFixed(1)}TB)이 기본 포함량(${allocation.included_tb}TB)의 2배 이상입니다. 상위 플랜을 고려하세요.`; - } else if (overageTb > 0) { - warning = `예상 초과 트래픽: ${overageTb.toFixed(1)}TB (추가 비용 ~$${overageCost.toFixed(0)}/월)`; - } - - return { - included_transfer_tb: allocation.included_tb, - overage_cost_per_gb: allocation.overage_per_gb, - overage_cost_per_tb: allocation.overage_per_tb, - estimated_monthly_tb: Math.round(estimatedTb * 10) / 10, - estimated_overage_tb: Math.round(overageTb * 10) / 10, - estimated_overage_cost: Math.round(overageCost * 100) / 100, - total_estimated_cost: Math.round(totalCost * 100) / 100, - warning - }; -} - -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; - example: Record; - 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 is provided and matches allowed list, return it - if (origin && allowedOrigins.includes(origin)) { - return origin; - } - - // For requests without Origin (non-browser: curl, API clients, server-to-server) - // Return empty string - CORS headers won't be sent but request is still processed - // This is safe because CORS only affects browser requests - if (!origin) { - return ''; - } - - // Origin provided but not in allowed list - return first allowed origin - // Browser will block the response due to CORS mismatch - 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, deny the request (fail closed) for security - return { allowed: false, requestId }; - } -} +import type { Env } from './types'; +import { getAllowedOrigin, checkRateLimit, jsonResponse } from './utils'; +import { handleHealth } from './handlers/health'; +import { handleGetServers } from './handlers/servers'; +import { handleRecommend } from './handlers/recommend'; /** * Main request handler @@ -783,1587 +88,3 @@ export default { } }, }; - -/** - * Health check endpoint - */ -function handleHealth(corsHeaders: Record): 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 -): Promise { - 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 -): Promise { - const requestId = crypto.randomUUID(); - - try { - // Check request body size to prevent large payload attacks - const contentLength = request.headers.get('Content-Length'); - if (contentLength && parseInt(contentLength, 10) > 10240) { // 10KB limit - return jsonResponse( - { error: 'Request body too large', max_size: '10KB' }, - 413, - corsHeaders - ); - } - - // 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 = { - '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(); - - 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})`); - - // Estimate specs for VPS benchmark query (doesn't need exact candidates) - const estimatedCores = minVcpu || 2; - const estimatedMemory = minMemoryMb ? Math.ceil(minMemoryMb / 1024) : 4; - const defaultProviders = bandwidthEstimate?.category === 'very_heavy' ? ['Linode'] : ['Linode', 'Vultr']; - - // Phase 2: Query candidate servers and VPS benchmarks in parallel - const [candidates, vpsBenchmarks] = await Promise.all([ - queryCandidateServers(env.DB, body, minMemoryMb, minVcpu, bandwidthEstimate, lang), - queryVPSBenchmarksBatch(env.DB, estimatedCores, estimatedMemory, defaultProviders).catch((err: unknown) => { - const message = err instanceof Error ? err.message : String(err); - console.warn('[Recommend] VPS benchmarks unavailable:', message); - return [] as VPSBenchmark[]; - }), - ]); - - console.log('[Recommend] Candidate servers:', candidates.length); - console.log('[Recommend] VPS benchmark data points:', vpsBenchmarks.length); - - if (candidates.length === 0) { - return jsonResponse( - { - error: 'No servers found matching your requirements', - recommendations: [], - request_id: requestId, - }, - 200, - corsHeaders - ); - } - - // Use initially fetched benchmark data (already filtered by tech stack) - const benchmarkData = benchmarkDataAll; - - // Use OpenAI GPT-4o-mini to analyze and recommend (techSpecs already queried above) - const aiResult = await getAIRecommendations( - env, - env.OPENAI_API_KEY, - body, - candidates, - benchmarkData, - vpsBenchmarks, - techSpecs, - bandwidthEstimate, - lang - ); - - console.log('[Recommend] Generated recommendations:', aiResult.recommendations.length); - - const response = { - recommendations: aiResult.recommendations, - infrastructure_tips: aiResult.infrastructure_tips || [], - bandwidth_estimate: { - monthly_tb: bandwidthEstimate.monthly_tb, - monthly_gb: bandwidthEstimate.monthly_gb, - daily_gb: bandwidthEstimate.daily_gb, - category: bandwidthEstimate.category, - description: bandwidthEstimate.description, - active_ratio: bandwidthEstimate.active_ratio, - calculation_note: `Based on ${body.expected_users} concurrent users with ${Math.round(bandwidthEstimate.active_ratio * 100)}% active ratio`, - }, - 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'); - console.error('[Recommend] Error details:', error instanceof Error ? error.message : 'Unknown error'); - return jsonResponse( - { - error: 'Failed to generate recommendations', - 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; - 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; - 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; - 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; - 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; - 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 { - // 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, - 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 preference based on bandwidth requirements (no hard filtering to avoid empty results) - // Heavy/Very heavy bandwidth → Prefer Linode (better bandwidth allowance), but allow all providers - // AI prompt will warn about bandwidth costs for non-Linode providers - if (bandwidthEstimate) { - if (bandwidthEstimate.category === 'very_heavy') { - // >6TB/month: Strongly prefer Linode, but don't exclude others (Linode may not be available in all regions) - console.log(`[Candidates] Very heavy bandwidth (${bandwidthEstimate.monthly_tb}TB/month): Linode strongly preferred, all providers included`); - } else if (bandwidthEstimate.category === 'heavy') { - // 2-6TB/month: Prefer Linode - 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 = { - '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/very_heavy bandwidth, prioritize Linode (p.id=1) due to generous bandwidth allowance - const isHighBandwidth = bandwidthEstimate?.category === 'heavy' || bandwidthEstimate?.category === 'very_heavy'; - const orderByClause = isHighBandwidth - ? `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'); - } - - // Add currency to each result and validate with type guard - const serversWithCurrency = (result.results as unknown[]).map(server => { - if (typeof server === 'object' && server !== null) { - return { ...server, currency }; - } - return server; - }); - const validServers = serversWithCurrency.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 { - // Map tech stack to relevant benchmark types - const techToBenchmark: Record = { - '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(); - 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(); - 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 { - 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 { - 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(); - 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 { - 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( - env: Env, - apiKey: string, - req: RecommendRequest, - candidates: Server[], - benchmarkData: BenchmarkData[], - vpsBenchmarks: VPSBenchmark[], - techSpecs: TechSpec[], - bandwidthEstimate: BandwidthEstimate, - lang: string = 'en' -): Promise<{ recommendations: RecommendationResult[]; infrastructure_tips?: string[] }> { - // Validate API key before making any API calls - if (!apiKey || !apiKey.trim()) { - console.error('[AI] OPENAI_API_KEY is not configured or empty'); - throw new Error('OPENAI_API_KEY not configured. Please set the secret via: wrangler secret put OPENAI_API_KEY'); - } - if (!apiKey.startsWith('sk-')) { - console.error('[AI] OPENAI_API_KEY has invalid format (should start with sk-)'); - throw new Error('Invalid OPENAI_API_KEY format'); - } - console.log('[AI] API key validated (format: sk-***)'); - - // 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 - 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.`; - - // Use AI Gateway if configured (bypasses regional restrictions like HKG) - // AI Gateway URL format: https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_name}/openai - const useAIGateway = !!env.AI_GATEWAY_URL; - const apiEndpoint = useAIGateway - ? `${env.AI_GATEWAY_URL}/chat/completions` - : 'https://api.openai.com/v1/chat/completions'; - - console.log(`[AI] Sending request to ${useAIGateway ? 'AI Gateway → ' : ''}OpenAI GPT-4o-mini`); - if (useAIGateway) { - console.log('[AI] Using Cloudflare AI Gateway to bypass regional restrictions'); - } - - // Create AbortController with 30 second timeout - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 30000); - - try { - const openaiResponse = await fetch(apiEndpoint, { - 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(); - - // Parse error details for better debugging - let errorDetails = ''; - try { - const errorObj = JSON.parse(errorText); - errorDetails = errorObj?.error?.message || errorObj?.error?.type || ''; - } catch { - errorDetails = errorText.slice(0, 200); - } - - // Sanitize API keys from error messages - const sanitized = errorDetails.replace(/sk-[a-zA-Z0-9-_]+/g, 'sk-***'); - - // Enhanced logging for specific error codes - if (openaiResponse.status === 403) { - const isRegionalBlock = errorDetails.includes('Country') || errorDetails.includes('region') || errorDetails.includes('territory'); - if (isRegionalBlock && !useAIGateway) { - console.error('[AI] ❌ REGIONAL BLOCK (403) - OpenAI blocked this region'); - console.error('[AI] Worker is running in a blocked region (e.g., HKG)'); - console.error('[AI] FIX: Set AI_GATEWAY_URL secret to use Cloudflare AI Gateway'); - console.error('[AI] 1. Create AI Gateway: https://dash.cloudflare.com → AI → AI Gateway'); - console.error('[AI] 2. Run: wrangler secret put AI_GATEWAY_URL'); - console.error('[AI] 3. Enter: https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_name}/openai'); - } else { - console.error('[AI] ❌ AUTH FAILED (403) - Possible causes:'); - console.error('[AI] 1. Invalid or expired OPENAI_API_KEY'); - console.error('[AI] 2. API key not properly set in Cloudflare secrets'); - console.error('[AI] 3. Account billing issue or quota exceeded'); - } - console.error('[AI] Error details:', sanitized); - } else if (openaiResponse.status === 429) { - console.error('[AI] ⚠️ RATE LIMITED (429) - Too many requests'); - console.error('[AI] Error details:', sanitized); - } else if (openaiResponse.status === 401) { - console.error('[AI] ❌ UNAUTHORIZED (401) - API key invalid'); - console.error('[AI] Error details:', sanitized); - } else { - 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(); - 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) - ); - } - - // Calculate bandwidth info for this server - const bandwidthInfo = calculateBandwidthInfo(server, bandwidthEstimate); - - results.push({ - server: server, - score: aiRec.score, - analysis: aiRec.analysis, - estimated_capacity: aiRec.estimated_capacity, - bandwidth_info: bandwidthInfo, - 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 { - // 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 - */ -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 - } - // Use >>> 0 to convert to unsigned 32-bit integer - return (hash >>> 0).toString(36); -} - -/** - * JSON response helper - */ -function jsonResponse( - data: any, - status: number, - headers: Record = {} -): Response { - return new Response(JSON.stringify(data), { - status, - headers: { - 'Content-Type': 'application/json', - 'Content-Security-Policy': "default-src 'none'", - 'X-Content-Type-Options': 'nosniff', - 'X-Frame-Options': 'DENY', - 'Cache-Control': 'no-store', - ...headers, - }, - }); -} diff --git a/src/types.ts b/src/types.ts index 5d11697..a4a7533 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,91 +2,174 @@ * Type definitions for Server Recommendation API */ -/** - * Server requirements provided by user - */ -export interface ServerRequirements { - cpu: string; // e.g., "4 cores", "8 vCPU" - memory: string; // e.g., "16GB", "32GB RAM" - storage: string; // e.g., "500GB SSD", "1TB NVMe" - network: string; // e.g., "1Gbps", "10Gbps unmetered" - availability: string; // e.g., "99.9%", "enterprise SLA" - budget: string; // e.g., "$100/month", "under $200" -} - -/** - * Server recommendation returned by AI - */ -export interface ServerRecommendation { - provider: string; // Hosting provider name - plan: string; // Specific plan/product name - cpu: string; // CPU specifications - memory: string; // RAM specifications - storage: string; // Storage specifications - network: string; // Network specifications - price: string; // Monthly cost - availability: string; // SLA or uptime guarantee - reason: string; // Why this server was recommended - matchScore: number; // Match score (0-100) -} - -/** - * API request body for recommendations - */ -export interface RecommendationRequest { - requirements: ServerRequirements; -} - -/** - * API response for recommendations - */ -export interface RecommendationResponse { - recommendations: ServerRecommendation[]; - cached: boolean; - timestamp: string; -} - -/** - * Error response structure - */ -export interface ErrorResponse { - error: string; - message: string; - timestamp: string; -} - -/** - * Cloudflare Worker environment bindings - */ export interface Env { - AI: Ai; // Workers AI binding - DB: D1Database; // D1 database binding - CACHE?: KVNamespace; // KV namespace binding (optional) + AI: Ai; // Legacy - kept for fallback + DB: D1Database; + CACHE: KVNamespace; + OPENAI_API_KEY: string; + AI_GATEWAY_URL?: string; // Cloudflare AI Gateway URL to bypass regional restrictions } -/** - * D1 database row for server catalog - */ -export interface ServerCatalogRow { +export interface ValidationError { + error: string; + missing_fields?: string[]; + invalid_fields?: { field: string; reason: string }[]; + schema: Record; + example: Record; +} + +export 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 +} + +export interface Server { id: number; - provider: string; - plan: string; - cpu: string; - memory: string; - storage: string; - network: string; - price: string; - availability: string; - features: string; // JSON string of additional features - created_at: string; - updated_at: string; + 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; } -/** - * Cache entry structure - */ -export interface CacheEntry { - recommendations: ServerRecommendation[]; - timestamp: string; - ttl: number; +export interface BandwidthInfo { + included_transfer_tb: number; // 기본 포함 트래픽 (TB/월) + overage_cost_per_gb: number; // 초과 비용 ($/GB) + overage_cost_per_tb: number; // 초과 비용 ($/TB) + estimated_monthly_tb: number; // 예상 월간 사용량 (TB) + estimated_overage_tb: number; // 예상 초과량 (TB) + estimated_overage_cost: number; // 예상 초과 비용 ($) + total_estimated_cost: number; // 총 예상 비용 (서버 + 트래픽) + warning?: string; // 트래픽 관련 경고 +} + +export 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; + }; + bandwidth_info?: BandwidthInfo; + benchmark_reference?: BenchmarkReference; + vps_benchmark_reference?: { + plan_name: string; + geekbench_single: number; + geekbench_multi: number; + monthly_price_usd: number; + performance_per_dollar: number; + }; +} + +export interface BenchmarkReference { + processor_name: string; + benchmarks: { + name: string; + category: string; + score: number; + percentile: number; + }[]; +} + +export interface BenchmarkData { + id: number; + processor_name: string; + benchmark_name: string; + category: string; + score: number; + percentile: number; + cores: number | null; +} + +export 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; +} + +export 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; +} + +export 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) + active_ratio: number; // Active user ratio (0.0-1.0) +} + +// Use case configuration for bandwidth estimation and user metrics +export interface UseCaseConfig { + category: 'video' | 'file' | 'gaming' | 'api' | 'ecommerce' | 'forum' | 'blog' | 'chat' | 'default'; + patterns: RegExp; + dauMultiplier: { min: number; max: number }; + activeRatio: number; +} + +export 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[]; } diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..dbaec10 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,642 @@ +/** + * Utility functions + */ + +import type { + RecommendRequest, + ValidationError, + Server, + VPSBenchmark, + TechSpec, + BenchmarkData, + AIRecommendationResponse, + UseCaseConfig, + BandwidthEstimate, + BandwidthInfo +} from './types'; +import { USE_CASE_CONFIGS, i18n } from './config'; + +/** + * JSON response helper + */ +export function jsonResponse( + data: any, + status: number, + headers: Record = {} +): Response { + return new Response(JSON.stringify(data), { + status, + headers: { + 'Content-Type': 'application/json', + 'Content-Security-Policy': "default-src 'none'", + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', + 'Cache-Control': 'no-store', + ...headers, + }, + }); +} + +/** + * 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.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('|')}`; +} + +/** + * Escape LIKE pattern special characters + */ +export function escapeLikePattern(pattern: string): string { + return pattern.replace(/[%_\\]/g, '\\$&'); +} + +/** + * Type guard to validate Server object structure + */ +export function isValidServer(obj: unknown): obj is Server { + if (!obj || typeof obj !== 'object') return false; + const s = obj as Record; + 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 + */ +export function isValidVPSBenchmark(obj: unknown): obj is VPSBenchmark { + if (!obj || typeof obj !== 'object') return false; + const v = obj as Record; + 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 + */ +export function isValidTechSpec(obj: unknown): obj is TechSpec { + if (!obj || typeof obj !== 'object') return false; + const t = obj as Record; + 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 + */ +export function isValidBenchmarkData(obj: unknown): obj is BenchmarkData { + if (!obj || typeof obj !== 'object') return false; + const b = obj as Record; + 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 + */ +export function isValidAIRecommendation(obj: unknown): obj is AIRecommendationResponse['recommendations'][0] { + if (!obj || typeof obj !== 'object') return false; + const r = obj as Record; + 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 + */ +export 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; +} + +/** + * Helper function to get allowed CORS origin + */ +export function getAllowedOrigin(request: Request): string { + const allowedOrigins = [ + 'https://server-recommend.kappa-d8e.workers.dev', + ]; + const origin = request.headers.get('Origin'); + + // If Origin is provided and matches allowed list, return it + if (origin && allowedOrigins.includes(origin)) { + return origin; + } + + // For requests without Origin (non-browser: curl, API clients, server-to-server) + // Return empty string - CORS headers won't be sent but request is still processed + // This is safe because CORS only affects browser requests + if (!origin) { + return ''; + } + + // Origin provided but not in allowed list - return first allowed origin + // Browser will block the response due to CORS mismatch + return allowedOrigins[0]; +} + +/** + * Find use case configuration by matching patterns + */ +export function findUseCaseConfig(useCase: string): UseCaseConfig { + const useCaseLower = useCase.toLowerCase(); + + for (const config of USE_CASE_CONFIGS) { + if (config.patterns.test(useCaseLower)) { + return config; + } + } + + // Default configuration + return { + category: 'default', + patterns: /.*/, + dauMultiplier: { min: 10, max: 14 }, + activeRatio: 0.5 + }; +} + +/** + * Get DAU multiplier based on use case (how many daily active users per concurrent user) + */ +export function getDauMultiplier(useCase: string): { min: number; max: number } { + return findUseCaseConfig(useCase).dauMultiplier; +} + +/** + * Get active user ratio (what percentage of DAU actually performs the bandwidth-heavy action) + */ +export function getActiveUserRatio(useCase: string): number { + return findUseCaseConfig(useCase).activeRatio; +} + +/** + * Estimate monthly bandwidth based on concurrent users and use case + */ +export function estimateBandwidth(concurrentUsers: number, useCase: string, trafficPattern?: string): BandwidthEstimate { + const useCaseLower = useCase.toLowerCase(); + + // Get use case configuration + const config = findUseCaseConfig(useCase); + const useCaseCategory = config.category; + + // Calculate DAU estimate from concurrent users with use-case-specific multipliers + const dauMultiplier = config.dauMultiplier; + const estimatedDauMin = Math.round(concurrentUsers * dauMultiplier.min); + const estimatedDauMax = Math.round(concurrentUsers * dauMultiplier.max); + const dailyUniqueVisitors = Math.round((estimatedDauMin + estimatedDauMax) / 2); + const activeUserRatio = config.activeRatio; + const activeDau = Math.round(dailyUniqueVisitors * activeUserRatio); + + // 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 + } + + let dailyBandwidthGB: number; + let bandwidthModel: string; + + // ========== IMPROVED BANDWIDTH MODELS ========== + // Each use case uses the most appropriate calculation method + + switch (useCaseCategory) { + case 'video': { + // VIDEO/STREAMING: Bitrate-based model + const is4K = /4k|uhd|ultra/i.test(useCaseLower); + const bitrateGBperHour = is4K ? 11.25 : 2.25; // 4K vs HD + const avgWatchTimeHours = is4K ? 1.0 : 1.5; + const gbPerActiveUser = bitrateGBperHour * avgWatchTimeHours; + dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; + bandwidthModel = `bitrate-based: ${activeDau} active × ${bitrateGBperHour} GB/hr × ${avgWatchTimeHours}hr`; + break; + } + + case 'file': { + // FILE DOWNLOAD: File-size based model + const isLargeFiles = /iso|video|backup|대용량/.test(useCaseLower); + const avgFileSizeGB = isLargeFiles ? 2.0 : 0.2; + const downloadsPerUser = isLargeFiles ? 1 : 3; + const gbPerActiveUser = avgFileSizeGB * downloadsPerUser; + dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; + bandwidthModel = `file-based: ${activeDau} active × ${avgFileSizeGB} GB × ${downloadsPerUser} downloads`; + break; + } + + case 'gaming': { + // GAMING: Session-duration based model + const isMinecraft = /minecraft|마인크래프트/.test(useCaseLower); + const mbPerHour = isMinecraft ? 150 : 80; + const avgSessionHours = isMinecraft ? 3 : 2.5; + const gbPerActiveUser = (mbPerHour * avgSessionHours) / 1024; + dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; + bandwidthModel = `session-based: ${activeDau} active × ${mbPerHour} MB/hr × ${avgSessionHours}hr`; + break; + } + + case 'api': { + // API/SAAS: Request-based model + const avgRequestKB = 20; + const requestsPerUserPerDay = 1000; + const gbPerActiveUser = (avgRequestKB * requestsPerUserPerDay) / (1024 * 1024); + dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; + bandwidthModel = `request-based: ${activeDau} active × ${avgRequestKB}KB × ${requestsPerUserPerDay} req`; + break; + } + + case 'ecommerce': { + // E-COMMERCE: Page-based model (images heavy) + const avgPageSizeMB = 2.5; + const pagesPerSession = 20; + const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024; + dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; + bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`; + break; + } + + case 'forum': { + // FORUM/COMMUNITY: Page-based model (text + some images) + const avgPageSizeMB = 0.7; + const pagesPerSession = 30; + const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024; + dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; + bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`; + break; + } + + case 'blog': { + // STATIC/BLOG: Lightweight page-based model + const avgPageSizeMB = 1.5; + const pagesPerSession = 4; + const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024; + dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; + bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`; + break; + } + + case 'chat': { + // CHAT/MESSAGING: Message-based model + const textBandwidthMB = (3 * 200) / 1024; // 3KB × 200 messages + const attachmentBandwidthMB = 20; // occasional images/files + const gbPerActiveUser = (textBandwidthMB + attachmentBandwidthMB) / 1024; + dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; + bandwidthModel = `message-based: ${activeDau} active × ~20MB/user (text+attachments)`; + break; + } + + default: { + // DEFAULT: General web app (page-based) + const avgPageSizeMB = 1.0; + const pagesPerSession = 10; + const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024; + dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; + bandwidthModel = `page-based (default): ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`; + break; + } + } + + console.log(`[Bandwidth] Model: ${bandwidthModel}`); + console.log(`[Bandwidth] DAU: ${dailyUniqueVisitors} (${dauMultiplier.min}-${dauMultiplier.max}x), Active: ${activeDau} (${(activeUserRatio * 100).toFixed(0)}%), Daily: ${dailyBandwidthGB.toFixed(1)} GB`); + + // 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, + active_ratio: activeUserRatio + }; +} + +/** + * Get provider bandwidth allocation based on memory size + * Returns included transfer in TB/month + */ +export function getProviderBandwidthAllocation(providerName: string, memoryGb: number): { + included_tb: number; + overage_per_gb: number; + overage_per_tb: number; +} { + const provider = providerName.toLowerCase(); + + if (provider.includes('linode')) { + // Linode: roughly 1TB per 1GB RAM (Nanode 1GB = 1TB, 2GB = 2TB, etc.) + const includedTb = Math.min(Math.max(memoryGb, 1), 20); + return { + included_tb: includedTb, + overage_per_gb: 0.005, // $0.005/GB = $5/TB + overage_per_tb: 5 + }; + } else if (provider.includes('vultr')) { + // Vultr: varies by plan, roughly 1-2TB for small, up to 10TB for large + let includedTb: number; + if (memoryGb <= 2) includedTb = 1; + else if (memoryGb <= 4) includedTb = 2; + else if (memoryGb <= 8) includedTb = 3; + else if (memoryGb <= 16) includedTb = 4; + else if (memoryGb <= 32) includedTb = 5; + else includedTb = Math.min(memoryGb / 4, 10); + + return { + included_tb: includedTb, + overage_per_gb: 0.01, // $0.01/GB = $10/TB + overage_per_tb: 10 + }; + } else { + // Default/Other providers: conservative estimate + return { + included_tb: Math.min(memoryGb, 5), + overage_per_gb: 0.01, + overage_per_tb: 10 + }; + } +} + +/** + * Calculate bandwidth cost info for a server + */ +export function calculateBandwidthInfo( + server: import('./types').Server, + bandwidthEstimate: BandwidthEstimate +): BandwidthInfo { + const allocation = getProviderBandwidthAllocation(server.provider_name, server.memory_gb); + const estimatedTb = bandwidthEstimate.monthly_tb; + const overageTb = Math.max(0, estimatedTb - allocation.included_tb); + const overageCost = overageTb * allocation.overage_per_tb; + + // Convert server price to USD if needed for total cost calculation + const serverPriceUsd = server.currency === 'KRW' + ? server.monthly_price / 1400 // Approximate KRW to USD + : server.monthly_price; + + const totalCost = serverPriceUsd + overageCost; + + let warning: string | undefined; + if (overageTb > allocation.included_tb) { + warning = `⚠️ 예상 트래픽(${estimatedTb.toFixed(1)}TB)이 기본 포함량(${allocation.included_tb}TB)의 2배 이상입니다. 상위 플랜을 고려하세요.`; + } else if (overageTb > 0) { + warning = `예상 초과 트래픽: ${overageTb.toFixed(1)}TB (추가 비용 ~$${overageCost.toFixed(0)}/월)`; + } + + return { + included_transfer_tb: allocation.included_tb, + overage_cost_per_gb: allocation.overage_per_gb, + overage_cost_per_tb: allocation.overage_per_tb, + estimated_monthly_tb: Math.round(estimatedTb * 10) / 10, + estimated_overage_tb: Math.round(overageTb * 10) / 10, + estimated_overage_cost: Math.round(overageCost * 100) / 100, + total_estimated_cost: Math.round(totalCost * 100) / 100, + warning + }; +} + +/** + * Rate limiting check using KV storage + */ +export async function checkRateLimit(clientIP: string, env: import('./types').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, deny the request (fail closed) for security + return { allowed: false, requestId }; + } +}