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