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:
115
CLAUDE.md
115
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
|
||||
|
||||
292
fix-tech-specs.sql
Normal file
292
fix-tech-specs.sql
Normal 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
148
src/config.ts
Normal 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
20
src/handlers/health.ts
Normal 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
1240
src/handlers/recommend.ts
Normal file
File diff suppressed because it is too large
Load Diff
139
src/handlers/servers.ts
Normal file
139
src/handlers/servers.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
2291
src/index.ts
2291
src/index.ts
File diff suppressed because it is too large
Load Diff
245
src/types.ts
245
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<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
642
src/utils.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user