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 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-25 17:46:16 +09:00
parent 0bb7296600
commit b682abc45d
9 changed files with 2729 additions and 2403 deletions

115
CLAUDE.md
View File

@@ -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

292
fix-tech-specs.sql Normal file
View File

@@ -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';

148
src/config.ts Normal file
View File

@@ -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<string, {
missingFields: string;
invalidFields: string;
schema: Record<string, string>;
example: Record<string, any>;
aiLanguageInstruction: string;
}> = {
en: {
missingFields: 'Missing required fields',
invalidFields: 'Invalid field values',
schema: {
tech_stack: "(required) string[] - e.g. ['nginx', 'nodejs']",
expected_users: "(required) number - expected concurrent users, e.g. 1000",
use_case: "(required) string - e.g. 'e-commerce website'",
traffic_pattern: "(optional) 'steady' | 'spiky' | 'growing'",
region_preference: "(optional) string[] - e.g. ['korea', 'japan']",
budget_limit: "(optional) number - max monthly USD",
provider_filter: "(optional) string[] - e.g. ['linode', 'vultr']",
lang: "(optional) 'en' | 'zh' | 'ja' | 'ko' - response language"
},
example: {
tech_stack: ["nginx", "nodejs", "postgresql"],
expected_users: 5000,
use_case: "SaaS application"
},
aiLanguageInstruction: 'Respond in English.'
},
zh: {
missingFields: '缺少必填字段',
invalidFields: '字段值无效',
schema: {
tech_stack: "(必填) string[] - 例如 ['nginx', 'nodejs']",
expected_users: "(必填) number - 预计同时在线用户数,例如 1000",
use_case: "(必填) string - 例如 '电商网站'",
traffic_pattern: "(可选) 'steady' | 'spiky' | 'growing'",
region_preference: "(可选) string[] - 例如 ['korea', 'japan']",
budget_limit: "(可选) number - 每月最高预算(美元)",
provider_filter: "(可选) string[] - 例如 ['linode', 'vultr']",
lang: "(可选) 'en' | 'zh' | 'ja' | 'ko' - 响应语言"
},
example: {
tech_stack: ["nginx", "nodejs", "postgresql"],
expected_users: 5000,
use_case: "SaaS应用程序"
},
aiLanguageInstruction: 'Respond in Chinese (Simplified). All analysis text must be in Chinese.'
},
ja: {
missingFields: '必須フィールドがありません',
invalidFields: 'フィールド値が無効です',
schema: {
tech_stack: "(必須) string[] - 例: ['nginx', 'nodejs']",
expected_users: "(必須) number - 予想同時接続ユーザー数、例: 1000",
use_case: "(必須) string - 例: 'ECサイト'",
traffic_pattern: "(任意) 'steady' | 'spiky' | 'growing'",
region_preference: "(任意) string[] - 例: ['korea', 'japan']",
budget_limit: "(任意) number - 月額予算上限(USD)",
provider_filter: "(任意) string[] - 例: ['linode', 'vultr']",
lang: "(任意) 'en' | 'zh' | 'ja' | 'ko' - 応答言語"
},
example: {
tech_stack: ["nginx", "nodejs", "postgresql"],
expected_users: 5000,
use_case: "SaaSアプリケーション"
},
aiLanguageInstruction: 'Respond in Japanese. All analysis text must be in Japanese.'
},
ko: {
missingFields: '필수 필드가 누락되었습니다',
invalidFields: '필드 값이 잘못되었습니다',
schema: {
tech_stack: "(필수) string[] - 예: ['nginx', 'nodejs']",
expected_users: "(필수) number - 예상 동시 접속자 수, 예: 1000",
use_case: "(필수) string - 예: '이커머스 웹사이트'",
traffic_pattern: "(선택) 'steady' | 'spiky' | 'growing'",
region_preference: "(선택) string[] - 예: ['korea', 'japan']",
budget_limit: "(선택) number - 월 예산 한도(원화, KRW)",
provider_filter: "(선택) string[] - 예: ['linode', 'vultr']",
lang: "(선택) 'en' | 'zh' | 'ja' | 'ko' - 응답 언어"
},
example: {
tech_stack: ["nginx", "nodejs", "postgresql"],
expected_users: 5000,
use_case: "SaaS 애플리케이션"
},
aiLanguageInstruction: 'Respond in Korean. All analysis text must be in Korean.'
}
};

20
src/handlers/health.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* Health check endpoint handler
*/
import { jsonResponse } from '../utils';
/**
* Health check endpoint
*/
export function handleHealth(corsHeaders: Record<string, string>): Response {
return jsonResponse(
{
status: 'ok',
timestamp: new Date().toISOString(),
service: 'server-recommend',
},
200,
corsHeaders
);
}

1240
src/handlers/recommend.ts Normal file

File diff suppressed because it is too large Load Diff

139
src/handlers/servers.ts Normal file
View File

@@ -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<string, string>
): Promise<Response> {
try {
const url = new URL(request.url);
const provider = url.searchParams.get('provider');
const minCpu = url.searchParams.get('minCpu');
const minMemory = url.searchParams.get('minMemory');
const region = url.searchParams.get('region');
console.log('[GetServers] Query params:', {
provider,
minCpu,
minMemory,
region,
});
// Build SQL query dynamically
let query = `
SELECT
it.id,
p.display_name as provider_name,
it.instance_id,
it.instance_name,
it.vcpu,
it.memory_mb,
ROUND(it.memory_mb / 1024.0, 1) as memory_gb,
it.storage_gb,
it.network_speed_gbps,
it.instance_family,
it.gpu_count,
it.gpu_type,
MIN(pr.monthly_price) as monthly_price,
MIN(r.region_name) as region_name,
MIN(r.region_code) as region_code
FROM instance_types it
JOIN providers p ON it.provider_id = p.id
JOIN pricing pr ON pr.instance_type_id = it.id
JOIN regions r ON pr.region_id = r.id
WHERE p.id IN (1, 2) -- Linode, Vultr only
AND (
-- Korea (Seoul)
r.region_code IN ('icn', 'ap-northeast-2') OR
LOWER(r.region_name) LIKE '%seoul%' OR
-- Japan (Tokyo, Osaka)
r.region_code IN ('nrt', 'itm', 'ap-northeast-1', 'ap-northeast-3') OR
LOWER(r.region_code) LIKE '%tyo%' OR
LOWER(r.region_code) LIKE '%osa%' OR
LOWER(r.region_name) LIKE '%tokyo%' OR
LOWER(r.region_name) LIKE '%osaka%' OR
-- Singapore
r.region_code IN ('sgp', 'ap-southeast-1') OR
LOWER(r.region_code) LIKE '%sin%' OR
LOWER(r.region_code) LIKE '%sgp%' OR
LOWER(r.region_name) LIKE '%singapore%'
)
`;
const params: (string | number)[] = [];
if (provider) {
query += ` AND p.name = ?`;
params.push(provider);
}
if (minCpu) {
const parsedCpu = parseInt(minCpu, 10);
if (isNaN(parsedCpu)) {
return jsonResponse({ error: 'Invalid minCpu parameter' }, 400, corsHeaders);
}
query += ` AND it.vcpu >= ?`;
params.push(parsedCpu);
}
if (minMemory) {
const parsedMemory = parseInt(minMemory, 10);
if (isNaN(parsedMemory)) {
return jsonResponse({ error: 'Invalid minMemory parameter' }, 400, corsHeaders);
}
query += ` AND it.memory_mb >= ?`;
params.push(parsedMemory * 1024);
}
if (region) {
query += ` AND r.region_code = ?`;
params.push(region);
}
query += ` GROUP BY it.id ORDER BY MIN(pr.monthly_price) ASC LIMIT 100`;
const result = await env.DB.prepare(query).bind(...params).all();
if (!result.success) {
throw new Error('Database query failed');
}
// Validate each result with type guard
const servers = (result.results as unknown[]).filter(isValidServer);
const invalidCount = result.results.length - servers.length;
if (invalidCount > 0) {
console.warn(`[GetServers] Filtered out ${invalidCount} invalid server records`);
}
console.log('[GetServers] Found servers:', servers.length);
return jsonResponse(
{
servers,
count: servers.length,
filters: { provider, minCpu, minMemory, region },
},
200,
corsHeaders
);
} catch (error) {
console.error('[GetServers] Error:', error);
const requestId = crypto.randomUUID();
return jsonResponse(
{
error: 'Failed to retrieve servers',
request_id: requestId,
},
500,
corsHeaders
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<string, string>;
example: Record<string, any>;
}
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[];
}

642
src/utils.ts Normal file
View File

@@ -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<string, string> = {}
): 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<string, unknown>;
return (
typeof s.id === 'number' &&
typeof s.provider_name === 'string' &&
typeof s.instance_id === 'string' &&
typeof s.vcpu === 'number' &&
typeof s.memory_mb === 'number' &&
typeof s.monthly_price === 'number'
);
}
/**
* Type guard to validate VPSBenchmark object structure
*/
export function isValidVPSBenchmark(obj: unknown): obj is VPSBenchmark {
if (!obj || typeof obj !== 'object') return false;
const v = obj as Record<string, unknown>;
return (
typeof v.id === 'number' &&
typeof v.provider_name === 'string' &&
typeof v.vcpu === 'number' &&
typeof v.geekbench_single === 'number'
);
}
/**
* Type guard to validate TechSpec object structure
*/
export function isValidTechSpec(obj: unknown): obj is TechSpec {
if (!obj || typeof obj !== 'object') return false;
const t = obj as Record<string, unknown>;
return (
typeof t.id === 'number' &&
typeof t.name === 'string' &&
typeof t.vcpu_per_users === 'number' &&
typeof t.min_memory_mb === 'number'
);
}
/**
* Type guard to validate BenchmarkData object structure
*/
export function isValidBenchmarkData(obj: unknown): obj is BenchmarkData {
if (!obj || typeof obj !== 'object') return false;
const b = obj as Record<string, unknown>;
return (
typeof b.id === 'number' &&
typeof b.processor_name === 'string' &&
typeof b.benchmark_name === 'string' &&
typeof b.score === 'number'
);
}
/**
* Type guard to validate AI recommendation structure
*/
export function isValidAIRecommendation(obj: unknown): obj is AIRecommendationResponse['recommendations'][0] {
if (!obj || typeof obj !== 'object') return false;
const r = obj as Record<string, unknown>;
return (
(typeof r.server_id === 'number' || typeof r.server_id === 'string') &&
typeof r.score === 'number' &&
r.analysis !== null &&
typeof r.analysis === 'object' &&
r.estimated_capacity !== null &&
typeof r.estimated_capacity === 'object'
);
}
/**
* Validate recommendation request
*/
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 };
}
}