feat: add region diversity, HTML report, and transfer pricing

Region Diversity:
- No region specified → same spec from 3 different regions
- Cache key now includes region_preference
- Fixed server_id to use ap.id (pricing) instead of ai.id (instance)

HTML Report:
- New /api/recommend/report endpoint for printable reports
- Supports multi-language (en, ko, ja, zh)
- Displays bandwidth_info with proper KRW formatting

Transfer Pricing:
- bandwidth_info includes overage costs from anvil_transfer_pricing
- available_regions shows alternative regions with prices

Code Quality:
- Extracted region-utils.ts for flexible region matching
- Cleaned up AI prompt (removed obsolete provider references)
- Renamed project to cloud-orchestrator

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-26 02:49:24 +09:00
parent 580cc1bbe2
commit 411cde4801
11 changed files with 1132 additions and 356 deletions

View File

@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
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`
**Production URL**: `https://cloud-orchestrator.kappa-d8e.workers.dev`
## Commands
@@ -34,10 +34,12 @@ src/
├── config.ts # Configuration constants
├── types.ts # TypeScript type definitions
├── utils.ts # Utilities (bandwidth, response, AI, benchmarks, candidates, techSpecs)
├── region-utils.ts # Region matching utilities (flexible region conditions)
└── handlers/
├── health.ts # GET /api/health
├── servers.ts # GET /api/servers - List servers with filtering
── recommend.ts # POST /api/recommend - AI-powered recommendations
── recommend.ts # POST /api/recommend - AI-powered recommendations
└── report.ts # GET /api/recommend/report - HTML report generation
```
### Key Data Flow
@@ -52,9 +54,10 @@ src/
### D1 Database Tables (cloud-instances-db)
**Primary tables (Anvil pricing)**:
- `anvil_instances` - Anvil server specifications (vcpus, memory_gb, disk_gb, etc.)
- `anvil_instances` - Anvil server specifications (vcpus, memory_gb, disk_gb, transfer_tb, etc.)
- `anvil_regions` - Anvil data center regions (name, display_name, country_code)
- `anvil_pricing` - Anvil pricing data (monthly_price in USD)
- `anvil_transfer_pricing` - Transfer/bandwidth overage pricing by region (price_per_gb in USD)
**Support tables**:
- `tech_specs` - Resource requirements per technology (vcpu_per_users, min_memory_mb)
@@ -93,7 +96,7 @@ Estimates monthly bandwidth based on use_case patterns:
| Analytics | 0.7MB | 30 | 50% |
| Blog/Content | 1.5MB | 4 | 30% |
Heavy bandwidth (>1TB/month) prefers Linode for included bandwidth.
Heavy bandwidth (>1TB/month) triggers warning about overage costs.
### Flexible Region Matching
@@ -115,10 +118,62 @@ Country names are auto-expanded via `COUNTRY_NAME_TO_REGIONS` mapping.
- Exchange rate fetched from open.er-api.com with 1-hour KV cache
- Fallback rate: 1450 KRW/USD if API unavailable
### Transfer Pricing (`bandwidth_info`)
Each recommendation includes `bandwidth_info` with transfer/bandwidth cost details:
| Field | Description | KRW Rounding |
|-------|-------------|--------------|
| `included_transfer_tb` | Free bandwidth included in plan (TB/month) | - |
| `overage_cost_per_gb` | Overage cost per GB | 1원 단위 |
| `overage_cost_per_tb` | Overage cost per TB | 100원 단위 |
| `estimated_monthly_tb` | Estimated monthly usage (TB) | - |
| `estimated_overage_tb` | Estimated overage (TB) | - |
| `estimated_overage_cost` | Estimated overage charges | 100원 단위 |
| `total_estimated_cost` | Server + overage total | 100원 단위 |
| `currency` | "USD" or "KRW" | - |
**Data sources**:
- `included_transfer_tb`: From `anvil_instances.transfer_tb`
- `overage_cost_per_gb`: From `anvil_transfer_pricing.price_per_gb`
### HTML Report Endpoint (`handlers/report.ts`)
`GET /api/recommend/report?data={base64}&lang={en|ko}`
Generates printable/PDF-friendly HTML report from recommendation results.
**Parameters**:
- `data`: Base64-encoded JSON of recommendation response
- `lang`: Language (en, ko, ja, zh) - defaults to 'en'
**Usage**:
```javascript
// Get recommendations
const result = await fetch('/api/recommend', {...});
const data = await result.json();
// Generate report URL
const reportUrl = `/api/recommend/report?data=${btoa(JSON.stringify(data))}&lang=ko`;
window.open(reportUrl); // Opens printable HTML
```
### Region-Based Recommendation Strategy (`recommend.ts`)
**When region IS specified** (e.g., `region_preference: ["seoul"]`):
- Returns 3 spec tiers (Budget/Balanced/Premium) within that region
- Example: Seoul 1 - Standard 4GB, Standard 8GB, Pro 16GB
**When NO region specified**:
- Returns same/similar spec from 3 DIFFERENT regions for location comparison
- Example: Standard 4GB from Osaka 2, Seoul 1, Singapore 1
- Implemented by sending only 1 server per region to AI (forces diversity)
### AI Prompt Strategy (`recommend.ts`)
- Uses OpenAI GPT-4o-mini via Cloudflare AI Gateway (bypasses regional restrictions)
- Server list format: `[server_id=XXXX] Provider Name...` for accurate ID extraction
- **server_id uses `ap.id`** (pricing ID, unique per instance+region combination)
- Scoring: Cost efficiency (40%) + Capacity fit (30%) + Scalability (30%)
- Capacity response in Korean for Korean users
- **Prompt injection protection**: User inputs sanitized via `sanitizeForAIPrompt()`
@@ -168,24 +223,45 @@ OPENAI_API_KEY = "sk-..." # Set via wrangler secret
```bash
# Health check
curl -s https://server-recommend.kappa-d8e.workers.dev/api/health | jq .
curl -s https://cloud-orchestrator.kappa-d8e.workers.dev/api/health | jq .
# Recommendation - nodejs/redis real-time chat (Japan)
curl -s -X POST https://server-recommend.kappa-d8e.workers.dev/api/recommend -H "Content-Type: application/json" -d '{"tech_stack":["nodejs","redis"],"expected_users":1000,"use_case":"real-time chat","region_preference":["japan"]}' | jq .
curl -s -X POST https://cloud-orchestrator.kappa-d8e.workers.dev/api/recommend -H "Content-Type: application/json" -d '{"tech_stack":["nodejs","redis"],"expected_users":1000,"use_case":"real-time chat","region_preference":["japan"]}' | jq .
# Recommendation - php/mysql community forum (Korea)
curl -s -X POST https://server-recommend.kappa-d8e.workers.dev/api/recommend -H "Content-Type: application/json" -d '{"tech_stack":["php","mysql"],"expected_users":800,"use_case":"community forum","region_preference":["korea"]}' | jq .
curl -s -X POST https://cloud-orchestrator.kappa-d8e.workers.dev/api/recommend -H "Content-Type: application/json" -d '{"tech_stack":["php","mysql"],"expected_users":800,"use_case":"community forum","region_preference":["korea"]}' | jq .
# Recommendation - analytics dashboard (heavier DB workload)
curl -s -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"]}' | jq .
curl -s -X POST https://cloud-orchestrator.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"]}' | jq .
# Server list with filters (supports flexible region: korea, seoul, tokyo, etc.)
curl -s "https://server-recommend.kappa-d8e.workers.dev/api/servers?region=korea&minCpu=4" | jq .
curl -s "https://cloud-orchestrator.kappa-d8e.workers.dev/api/servers?region=korea&minCpu=4" | jq .
# HTML Report (encode recommendation result as base64)
# 1. Get recommendation and save to variable
RESULT=$(curl -s -X POST https://cloud-orchestrator.kappa-d8e.workers.dev/api/recommend -H "Content-Type: application/json" -d '{"tech_stack":["nodejs"],"expected_users":500,"use_case":"simple api","lang":"ko"}')
# 2. Generate report URL with base64-encoded data
REPORT_URL="https://cloud-orchestrator.kappa-d8e.workers.dev/api/recommend/report?data=$(echo $RESULT | base64 | tr -d '\n')&lang=ko"
# 3. Open in browser or fetch
echo $REPORT_URL
```
## Recent Changes
### Anvil Pricing Migration (Latest)
### Region Diversity & Bug Fixes (Latest)
- **Region diversity**: No region specified → same spec from 3 different regions for comparison
- **Cache key fix**: `region_preference` now included in cache key
- **Server ID fix**: Changed from `ai.id` (instance) to `ap.id` (pricing) for unique region+instance identification
- **Prompt cleanup**: Removed obsolete Linode/Vultr/DigitalOcean references (Anvil only)
### Transfer Pricing & Reporting
- **Transfer pricing**: Added `anvil_transfer_pricing` table data to recommendations
- **bandwidth_info**: Each recommendation includes transfer costs (included_tb, overage costs)
- **available_regions**: Lists other regions where same server spec is available with prices
- **HTML report**: New `/api/recommend/report` endpoint for printable reports
- **KRW conversion**: Bandwidth costs converted to KRW for Korean users (GB: 1원, TB/total: 100원 rounding)
### Anvil Pricing Migration
- **New tables**: Migrated from `pricing` to `anvil_pricing` tables
- **Provider**: Now uses "Anvil" as single provider (previously Linode/Vultr)
- **Exchange rate**: Real-time USD→KRW conversion via open.er-api.com

View File

@@ -1,5 +1,5 @@
{
"name": "server-recommend",
"name": "cloud-orchestrator",
"version": "1.0.0",
"description": "",
"main": "index.js",

View File

@@ -76,6 +76,7 @@ export const USE_CASE_CONFIGS: UseCaseConfig[] = [
export const i18n: Record<string, {
missingFields: string;
invalidFields: string;
techStackItemLength: string;
schema: Record<string, string>;
example: Record<string, any>;
aiLanguageInstruction: string;
@@ -83,6 +84,7 @@ export const i18n: Record<string, {
en: {
missingFields: 'Missing required fields',
invalidFields: 'Invalid field values',
techStackItemLength: 'all items must be strings with max 50 characters',
schema: {
tech_stack: "(required) string[] - e.g. ['nginx', 'nodejs']",
expected_users: "(required) number - expected concurrent users, e.g. 1000",
@@ -102,6 +104,7 @@ export const i18n: Record<string, {
zh: {
missingFields: '缺少必填字段',
invalidFields: '字段值无效',
techStackItemLength: '所有项目必须是最长50个字符的字符串',
schema: {
tech_stack: "(必填) string[] - 例如 ['nginx', 'nodejs']",
expected_users: "(必填) number - 预计同时在线用户数,例如 1000",
@@ -121,6 +124,7 @@ export const i18n: Record<string, {
ja: {
missingFields: '必須フィールドがありません',
invalidFields: 'フィールド値が無効です',
techStackItemLength: 'すべての項目は最大50文字の文字列でなければなりません',
schema: {
tech_stack: "(必須) string[] - 例: ['nginx', 'nodejs']",
expected_users: "(必須) number - 予想同時接続ユーザー数、例: 1000",
@@ -140,6 +144,7 @@ export const i18n: Record<string, {
ko: {
missingFields: '필수 필드가 누락되었습니다',
invalidFields: '필드 값이 잘못되었습니다',
techStackItemLength: '모든 항목은 50자 이하의 문자열이어야 합니다',
schema: {
tech_stack: "(필수) string[] - 예: ['nginx', 'nodejs']",
expected_users: "(필수) number - 예상 동시 접속자 수, 예: 1000",

View File

@@ -22,7 +22,6 @@ import {
generateCacheKey,
estimateBandwidth,
calculateBandwidthInfo,
escapeLikePattern,
isValidServer,
isValidBenchmarkData,
isValidVPSBenchmark,
@@ -31,6 +30,7 @@ import {
sanitizeForAIPrompt,
getExchangeRate
} from '../utils';
import { escapeLikePattern, buildFlexibleRegionConditionsAnvil } from '../region-utils';
export async function handleRecommend(
request: Request,
@@ -62,7 +62,26 @@ export async function handleRecommend(
);
}
const body = JSON.parse(bodyText) as RecommendRequest;
// Parse JSON with explicit error handling
let body: RecommendRequest;
try {
body = JSON.parse(bodyText) as RecommendRequest;
} catch (parseError) {
console.error('[Recommend] JSON parse error:', parseError instanceof Error ? parseError.message : 'Unknown');
return jsonResponse({
error: 'Invalid JSON format',
request_id: requestId,
}, 400, corsHeaders);
}
// Validate body is an object before proceeding
if (!body || typeof body !== 'object' || Array.isArray(body)) {
return jsonResponse({
error: body && 'lang' in body && body.lang === 'ko' ? '요청 본문은 객체여야 합니다' : 'Request body must be an object',
request_id: requestId,
}, 400, corsHeaders);
}
const lang = body.lang || 'en';
const validationError = validateRecommendRequest(body, lang);
if (validationError) {
@@ -74,7 +93,6 @@ export async function handleRecommend(
expected_users: body.expected_users,
use_case_length: body.use_case.length,
traffic_pattern: body.traffic_pattern,
has_region_pref: !!body.region_preference,
has_budget: !!body.budget_limit,
lang: lang,
});
@@ -227,16 +245,27 @@ export async function handleRecommend(
const estimatedMemory = minMemoryMb ? Math.ceil(minMemoryMb / 1024) : 4;
const defaultProviders = bandwidthEstimate?.category === 'very_heavy' ? ['Linode'] : ['Linode', 'Vultr'];
// Phase 2: Query candidate servers and VPS benchmarks in parallel
const [candidates, vpsBenchmarks] = await Promise.all([
queryCandidateServers(env.DB, env, body, minMemoryMb, minVcpu, bandwidthEstimate, lang),
// Phase 2: Parallel queries including exchange rate for Korean users
const exchangeRatePromise = lang === 'ko' ? getExchangeRate(env) : Promise.resolve(1);
const [candidates, vpsBenchmarks, exchangeRate] = await Promise.all([
queryCandidateServers(env.DB, env, body, minMemoryMb, minVcpu, bandwidthEstimate, lang, 1), // Pass temporary rate of 1
queryVPSBenchmarksBatch(env.DB, estimatedCores, estimatedMemory, defaultProviders).catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
console.warn('[Recommend] VPS benchmarks unavailable:', message);
return [] as VPSBenchmark[];
}),
exchangeRatePromise,
]);
// Apply exchange rate to candidates if needed (Korean users)
if (lang === 'ko' && exchangeRate !== 1) {
candidates.forEach(c => {
c.monthly_price = Math.round(c.monthly_price * exchangeRate);
c.currency = 'KRW';
});
}
console.log('[Recommend] Candidate servers:', candidates.length);
console.log('[Recommend] VPS benchmark data points:', vpsBenchmarks.length);
@@ -265,7 +294,8 @@ export async function handleRecommend(
vpsBenchmarks,
techSpecs,
bandwidthEstimate,
lang
lang,
exchangeRate
);
console.log('[Recommend] Generated recommendations:', aiResult.recommendations.length);
@@ -315,17 +345,18 @@ async function queryCandidateServers(
minMemoryMb?: number,
minVcpu?: number,
bandwidthEstimate?: BandwidthEstimate,
lang: string = 'en'
lang: string = 'en',
exchangeRate: number = 1
): Promise<Server[]> {
// Get exchange rate for KRW display (Korean users)
const exchangeRate = lang === 'ko' ? await getExchangeRate(env) : 1;
const currency = lang === 'ko' ? 'KRW' : 'USD';
// Currency display based on language (exchange rate applied in handleRecommend)
const currency = 'USD'; // Always return USD prices, converted to KRW in handleRecommend if needed
// Build query using anvil_* tables
// anvil_pricing.monthly_price is stored in USD
// Join anvil_transfer_pricing for overage bandwidth costs
let query = `
SELECT
ai.id,
ap.id,
'Anvil' as provider_name,
ai.name as instance_id,
ai.display_name as instance_name,
@@ -340,20 +371,22 @@ async function queryCandidateServers(
ap.monthly_price as monthly_price_usd,
ar.display_name as region_name,
ar.name as region_code,
ar.country_code
ar.country_code,
ai.transfer_tb,
atp.price_per_gb as transfer_price_per_gb
FROM anvil_instances ai
JOIN anvil_pricing ap ON ap.anvil_instance_id = ai.id
JOIN anvil_regions ar ON ap.anvil_region_id = ar.id
LEFT JOIN anvil_transfer_pricing atp ON atp.anvil_region_id = ar.id
WHERE ai.active = 1 AND ar.active = 1
`;
const params: (string | number)[] = [];
// Filter by budget limit (convert to USD for comparison)
// Filter by budget limit (assume budget is in USD, conversion happens in handleRecommend)
if (req.budget_limit) {
const budgetUsd = lang === 'ko' ? req.budget_limit / exchangeRate : req.budget_limit;
query += ` AND ap.monthly_price <= ?`;
params.push(budgetUsd);
params.push(req.budget_limit);
}
// Filter by minimum memory requirement (from tech specs)
@@ -372,19 +405,18 @@ async function queryCandidateServers(
console.log(`[Candidates] Filtering by minimum vCPU: ${minVcpu}`);
}
// Flexible region matching using anvil_regions table
// r.* aliases need to change to ar.* for anvil_regions
// Filter by region preference if specified
if (req.region_preference && req.region_preference.length > 0) {
const { conditions, params: regionParams } = buildFlexibleRegionConditionsAnvil(req.region_preference);
query += ` AND (${conditions.join(' OR ')})`;
params.push(...regionParams);
} else {
// No region specified → default to Seoul/Tokyo/Singapore
query += ` AND ${DEFAULT_ANVIL_REGION_FILTER_SQL}`;
if (conditions.length > 0) {
query += ` AND (${conditions.join(' OR ')})`;
params.push(...regionParams);
console.log(`[Candidates] Filtering by regions: ${req.region_preference.join(', ')}`);
}
}
// Order by price
query += ` ORDER BY ap.monthly_price ASC LIMIT 50`;
// Order by price - return ALL matching servers across all regions
query += ` ORDER BY ap.monthly_price ASC`;
const result = await db.prepare(query).bind(...params).all();
@@ -392,17 +424,17 @@ async function queryCandidateServers(
throw new Error('Failed to query candidate servers');
}
// Convert USD prices to display currency and validate
// Add USD currency to each result and validate
// Price conversion to KRW happens in handleRecommend if needed
const serversWithCurrency = (result.results as unknown[]).map(server => {
if (typeof server === 'object' && server !== null) {
const s = server as Record<string, unknown>;
const priceUsd = s.monthly_price_usd as number;
// Convert to KRW if Korean, otherwise keep USD
const displayPrice = lang === 'ko' ? Math.round(priceUsd * exchangeRate) : priceUsd;
return {
...s,
monthly_price: displayPrice,
currency
monthly_price: s.monthly_price_usd as number,
currency,
transfer_tb: s.transfer_tb as number | null,
transfer_price_per_gb: s.transfer_price_per_gb as number | null
};
}
return server;
@@ -413,83 +445,11 @@ async function queryCandidateServers(
if (invalidCount > 0) {
console.warn(`[Candidates] Filtered out ${invalidCount} invalid server records`);
}
console.log(`[Candidates] Found ${validServers.length} servers matching technical requirements (all regions)`);
return validServers;
}
/**
* Default region filter SQL for anvil_regions (when no region is specified)
*/
const DEFAULT_ANVIL_REGION_FILTER_SQL = `(
-- Korea (Seoul)
ar.name IN ('icn', 'ap-northeast-2') OR
LOWER(ar.display_name) LIKE '%seoul%' OR
-- Japan (Tokyo, Osaka)
ar.name IN ('nrt', 'itm', 'ap-northeast-1', 'ap-northeast-3') OR
LOWER(ar.name) LIKE '%tyo%' OR
LOWER(ar.name) LIKE '%osa%' OR
LOWER(ar.display_name) LIKE '%tokyo%' OR
LOWER(ar.display_name) LIKE '%osaka%' OR
-- Singapore
ar.name IN ('sgp', 'ap-southeast-1') OR
LOWER(ar.name) LIKE '%sin%' OR
LOWER(ar.name) LIKE '%sgp%' OR
LOWER(ar.display_name) LIKE '%singapore%'
)`;
/**
* Build flexible region matching SQL conditions for anvil_regions
*/
function buildFlexibleRegionConditionsAnvil(
regions: string[]
): { conditions: string[]; params: (string | number)[] } {
const conditions: string[] = [];
const params: (string | number)[] = [];
// Country name to region mapping
const COUNTRY_NAME_TO_REGIONS: Record<string, string[]> = {
'korea': ['seoul', 'koreacentral', 'kr', 'ap-northeast-2'],
'south korea': ['seoul', 'koreacentral', 'kr', 'ap-northeast-2'],
'japan': ['tokyo', 'osaka', 'ap-northeast-1', 'ap-northeast-3'],
'singapore': ['singapore', 'ap-southeast-1'],
'indonesia': ['jakarta', 'ap-southeast-3'],
'australia': ['sydney', 'melbourne', 'au-east', 'ap-southeast-2'],
'india': ['mumbai', 'delhi', 'chennai', 'ap-south'],
'usa': ['us-east', 'us-west', 'us-central', 'america'],
'us': ['us-east', 'us-west', 'us-central', 'america'],
'united states': ['us-east', 'us-west', 'us-central', 'america'],
'uk': ['london', 'uk-south', 'eu-west-2'],
'united kingdom': ['london', 'uk-south', 'eu-west-2'],
'germany': ['frankfurt', 'eu-central', 'eu-west-3'],
'france': ['paris', 'eu-west-3'],
'netherlands': ['amsterdam', 'eu-west-1'],
'brazil': ['sao paulo', 'sa-east'],
'canada': ['toronto', 'montreal', 'ca-central'],
};
const escapeLikePattern = (pattern: string): string => {
return pattern.replace(/[%_\\]/g, '\\$&');
};
for (const region of regions) {
const lowerRegion = region.toLowerCase();
const expandedRegions = COUNTRY_NAME_TO_REGIONS[lowerRegion] || [lowerRegion];
const allRegions = [lowerRegion, ...expandedRegions];
for (const r of allRegions) {
const escapedRegion = escapeLikePattern(r);
conditions.push(`(
LOWER(ar.name) = ? OR
LOWER(ar.name) LIKE ? ESCAPE '\\' OR
LOWER(ar.display_name) LIKE ? ESCAPE '\\' OR
LOWER(ar.country_code) = ?
)`);
params.push(r, `%${escapedRegion}%`, `%${escapedRegion}%`, r);
}
}
return { conditions, params };
}
/**
* Query relevant benchmark data for tech stack
*/
@@ -832,7 +792,8 @@ async function getAIRecommendations(
vpsBenchmarks: VPSBenchmark[],
techSpecs: TechSpec[],
bandwidthEstimate: BandwidthEstimate,
lang: string = 'en'
lang: string = 'en',
exchangeRate: number = 1
): Promise<{ recommendations: RecommendationResult[]; infrastructure_tips?: string[] }> {
// Validate API key before making any API calls
if (!apiKey || !apiKey.trim()) {
@@ -862,15 +823,9 @@ CRITICAL RULES:
4. Nginx/reverse proxy needs very little resources - 1 vCPU can handle 1000+ req/sec.
5. Provide 3 options: Budget (cheapest viable), Balanced (some headroom), Premium (growth ready).
BANDWIDTH CONSIDERATIONS (VERY IMPORTANT):
BANDWIDTH CONSIDERATIONS:
- Estimated monthly bandwidth is provided based on concurrent users and use case.
- TOTAL COST = Base server price + Bandwidth overage charges
- Provider bandwidth allowances:
* Linode: 1TB (1GB plan) to 20TB (192GB plan) included free, $0.005/GB overage
* Vultr: 1TB-10TB depending on plan, $0.01/GB overage (2x Linode rate)
* DigitalOcean: 1TB-12TB depending on plan, $0.01/GB overage
- For bandwidth >1TB/month: Linode is often cheaper despite higher base price
- For bandwidth >3TB/month: Linode is STRONGLY preferred (overage savings significant)
- Always mention bandwidth implications in cost_efficiency analysis
${techSpecsPrompt}
@@ -890,10 +845,35 @@ ${languageInstruction}`;
const vpsBenchmarkSummary = formatVPSBenchmarkSummary(vpsBenchmarks);
// Pre-filter candidates to reduce AI prompt size and cost
// Sort by price and limit to top 15 most affordable options
const topCandidates = candidates
.sort((a, b) => a.monthly_price - b.monthly_price)
.slice(0, 15);
// Ensure region diversity when no region_preference is specified
let topCandidates: Server[];
const hasRegionPreference = req.region_preference && req.region_preference.length > 0;
if (hasRegionPreference) {
// If region preference specified, just take top 15 cheapest
topCandidates = candidates
.sort((a, b) => a.monthly_price - b.monthly_price)
.slice(0, 15);
} else {
// No region preference: pick ONLY the best server from EACH region
// This forces AI to recommend different regions (no choice!)
const bestByRegion = new Map<string, Server>();
for (const server of candidates) {
const region = server.region_name;
const existing = bestByRegion.get(region);
// Keep the cheapest server that meets requirements for each region
if (!existing || server.monthly_price < existing.monthly_price) {
bestByRegion.set(region, server);
}
}
// Convert to array and sort by price
topCandidates = Array.from(bestByRegion.values())
.sort((a, b) => a.monthly_price - b.monthly_price);
console.log(`[AI] Region diversity FORCED: ${topCandidates.length} regions, 1 server each`);
console.log(`[AI] Regions: ${topCandidates.map(s => s.region_name).join(', ')}`);
}
console.log(`[AI] Filtered ${candidates.length} candidates to ${topCandidates.length} for AI analysis`);
@@ -910,8 +890,7 @@ ${languageInstruction}`;
- Use Case: ${sanitizedUseCase}
- Traffic Pattern: ${req.traffic_pattern || 'steady'}
- **Estimated Monthly Bandwidth**: ${bandwidthEstimate.monthly_tb >= 1 ? `${bandwidthEstimate.monthly_tb} TB` : `${bandwidthEstimate.monthly_gb} GB`} (${bandwidthEstimate.category})
${isHighTraffic ? `- ⚠️ HIGH BANDWIDTH WORKLOAD (${bandwidthEstimate.monthly_tb} TB/month): MUST recommend Linode over Vultr. Linode includes 1-6TB/month transfer vs Vultr overage charges ($0.01/GB). Bandwidth cost savings > base price difference.` : ''}
${req.region_preference ? `- Region Preference: ${req.region_preference.join(', ')}` : ''}
${isHighTraffic ? `- ⚠️ HIGH BANDWIDTH WORKLOAD (${bandwidthEstimate.monthly_tb} TB/month): Consider bandwidth overage costs when evaluating total cost.` : ''}
${req.budget_limit ? `- Budget Limit: $${req.budget_limit}/month` : ''}
## Real VPS Benchmark Data (Geekbench 6 normalized - actual VPS tests)
@@ -921,13 +900,9 @@ ${vpsBenchmarkSummary || 'No similar VPS benchmark data available.'}
${benchmarkSummary || 'No relevant CPU benchmark data available.'}
## Available Servers (IMPORTANT: Use the server_id value, NOT the list number!)
${topCandidates.map((s) => `
[server_id=${s.id}] ${s.provider_name} - ${s.instance_name}${s.instance_family ? ` (${s.instance_family})` : ''}
Instance: ${s.instance_id}
vCPU: ${s.vcpu} | Memory: ${s.memory_gb} GB | Storage: ${s.storage_gb} GB
Network: ${s.network_speed_gbps ? `${s.network_speed_gbps} Gbps` : 'N/A'}${s.gpu_count > 0 ? ` | GPU: ${s.gpu_count}x ${s.gpu_type || 'Unknown'}` : ' | GPU: None'}
Price: ${s.currency === 'KRW' ? '₩' : '$'}${s.currency === 'KRW' ? Math.round(s.monthly_price).toLocaleString() : s.monthly_price.toFixed(2)}/month (${s.currency}) | Region: ${s.region_name} (${s.region_code})
`).join('\n')}
${topCandidates.map((s) => `[server_id=${s.id}] ${s.provider_name} - ${s.instance_name}
vCPU: ${s.vcpu} | RAM: ${s.memory_gb}GB | Storage: ${s.storage_gb}GB${s.gpu_count > 0 ? ` | GPU: ${s.gpu_count}x ${s.gpu_type}` : ''}
Price: ${s.currency === 'KRW' ? '₩' : '$'}${s.currency === 'KRW' ? Math.round(s.monthly_price).toLocaleString() : s.monthly_price.toFixed(2)}/mo | Region: ${s.region_name}`).join('\n')}
Return ONLY a valid JSON object (no markdown, no code blocks) with this exact structure:
{
@@ -954,7 +929,7 @@ Return ONLY a valid JSON object (no markdown, no code blocks) with this exact st
}
Provide exactly 3 recommendations:
1. BUDGET option: Cheapest TOTAL cost (base + bandwidth) that can handle the load (highest score if viable)
1. BUDGET option: Cheapest TOTAL cost (base + bandwidth) that can handle the load
2. BALANCED option: Some headroom for traffic spikes
3. PREMIUM option: Ready for 2-3x growth
@@ -1054,7 +1029,6 @@ The option with the LOWEST TOTAL MONTHLY COST (including bandwidth) should have
const response = openaiResult.choices[0]?.message?.content || '';
console.log('[AI] Response received from OpenAI, length:', response.length);
console.log('[AI] Raw response preview:', response.substring(0, 500));
// Parse AI response
const aiResult = parseAIResponse(response);
@@ -1110,8 +1084,8 @@ The option with the LOWEST TOTAL MONTHLY COST (including bandwidth) should have
);
}
// Calculate bandwidth info for this server
const bandwidthInfo = calculateBandwidthInfo(server, bandwidthEstimate);
// Calculate bandwidth info for this server (with currency conversion for Korean)
const bandwidthInfo = calculateBandwidthInfo(server, bandwidthEstimate, lang, exchangeRate);
// Find all available regions for the same server spec
const availableRegions: AvailableRegion[] = candidates
@@ -1233,7 +1207,7 @@ function parseAIResponse(response: unknown): AIRecommendationResponse {
} as AIRecommendationResponse;
} catch (error) {
console.error('[AI] Parse error:', error);
console.error('[AI] Response was:', response);
console.error('[AI] Response parse failed, length:', typeof response === 'string' ? response.length : 'N/A', 'preview:', typeof response === 'string' ? response.substring(0, 100).replace(/[^\x20-\x7E]/g, '?') : 'Invalid response type');
throw new Error(`Failed to parse AI response: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}

639
src/handlers/report.ts Normal file
View File

@@ -0,0 +1,639 @@
/**
* GET /api/recommend/report - Generate HTML report from recommendation results
*
* Query params:
* - data: Base64-encoded JSON of recommendation response
* - lang: Language (en, ko, ja, zh) - default: en
*/
import type { Env, RecommendationResult, BandwidthEstimate } from '../types';
import { jsonResponse } from '../utils';
interface ReportData {
recommendations: RecommendationResult[];
bandwidth_estimate: BandwidthEstimate & {
calculation_note?: string;
};
total_candidates: number;
request?: {
tech_stack: string[];
expected_users: number;
use_case: string;
};
}
const i18n: Record<string, Record<string, string>> = {
en: {
title: 'Server Recommendation Report',
subtitle: 'AI-Powered Infrastructure Analysis',
requirements: 'Requirements',
techStack: 'Tech Stack',
expectedUsers: 'Expected Users',
useCase: 'Use Case',
bandwidthEstimate: 'Bandwidth Estimate',
monthly: 'Monthly',
daily: 'Daily',
category: 'Category',
recommendations: 'Recommendations',
budget: 'Budget',
balanced: 'Balanced',
premium: 'Premium',
specs: 'Specifications',
vcpu: 'vCPU',
memory: 'Memory',
storage: 'Storage',
region: 'Region',
price: 'Price',
score: 'Score',
analysis: 'Analysis',
techFit: 'Tech Fit',
capacity: 'Capacity',
costEfficiency: 'Cost Efficiency',
scalability: 'Scalability',
bandwidthInfo: 'Bandwidth Cost',
includedTransfer: 'Included Transfer',
overagePrice: 'Overage Price',
estimatedUsage: 'Estimated Usage',
estimatedOverage: 'Estimated Overage',
estimatedCost: 'Est. Overage Cost',
totalCost: 'Total Est. Cost',
warning: 'Warning',
generatedAt: 'Generated at',
poweredBy: 'Powered by Cloud Orchestrator',
printNote: 'Print this page or save as PDF using your browser',
perMonth: '/month',
perGb: '/GB',
},
ko: {
title: '서버 추천 보고서',
subtitle: 'AI 기반 인프라 분석',
requirements: '요구사항',
techStack: '기술 스택',
expectedUsers: '예상 사용자',
useCase: '용도',
bandwidthEstimate: '대역폭 예측',
monthly: '월간',
daily: '일간',
category: '카테고리',
recommendations: '추천 서버',
budget: '예산형',
balanced: '균형형',
premium: '프리미엄',
specs: '사양',
vcpu: 'vCPU',
memory: '메모리',
storage: '스토리지',
region: '리전',
price: '가격',
score: '점수',
analysis: '분석',
techFit: '기술 적합성',
capacity: '용량',
costEfficiency: '비용 효율성',
scalability: '확장성',
bandwidthInfo: '대역폭 비용',
includedTransfer: '기본 제공',
overagePrice: '초과 요금',
estimatedUsage: '예상 사용량',
estimatedOverage: '예상 초과량',
estimatedCost: '예상 초과 비용',
totalCost: '총 예상 비용',
warning: '주의',
generatedAt: '생성 시각',
poweredBy: 'Cloud Orchestrator 제공',
printNote: '브라우저에서 인쇄하거나 PDF로 저장하세요',
perMonth: '/월',
perGb: '/GB',
},
};
function getLabels(lang: string): Record<string, string> {
return i18n[lang] || i18n['en'];
}
function formatPrice(price: number, currency: string): string {
if (currency === 'KRW') {
return `${Math.round(price).toLocaleString()}`;
}
return `$${price.toFixed(2)}`;
}
function getTierLabel(index: number, labels: Record<string, string>): string {
const tiers = [labels.budget, labels.balanced, labels.premium];
return tiers[index] || `Option ${index + 1}`;
}
function getTierColor(index: number): string {
const colors = ['#10b981', '#3b82f6', '#8b5cf6']; // green, blue, purple
return colors[index] || '#6b7280';
}
export async function handleReport(
request: Request,
env: Env,
corsHeaders: Record<string, string>
): Promise<Response> {
try {
const url = new URL(request.url);
const dataParam = url.searchParams.get('data');
const lang = url.searchParams.get('lang') || 'en';
if (!dataParam) {
return jsonResponse(
{ error: 'Missing data parameter. Provide Base64-encoded recommendation data.' },
400,
corsHeaders
);
}
// Decode Base64 data
let reportData: ReportData;
try {
const decoded = atob(dataParam);
reportData = JSON.parse(decoded) as ReportData;
} catch {
return jsonResponse(
{ error: 'Invalid data parameter. Must be valid Base64-encoded JSON.' },
400,
corsHeaders
);
}
if (!reportData.recommendations || reportData.recommendations.length === 0) {
return jsonResponse(
{ error: 'No recommendations in data.' },
400,
corsHeaders
);
}
const labels = getLabels(lang);
const html = generateReportHTML(reportData, labels, lang);
return new Response(html, {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
...corsHeaders,
},
});
} catch (error) {
console.error('[Report] Error:', error);
return jsonResponse(
{ error: 'Failed to generate report' },
500,
corsHeaders
);
}
}
function generateReportHTML(
data: ReportData,
labels: Record<string, string>,
lang: string
): string {
const { recommendations, bandwidth_estimate, request } = data;
const now = new Date().toISOString();
const recommendationCards = recommendations.map((rec, index) => {
const { server, score, analysis, bandwidth_info } = rec;
const tierLabel = getTierLabel(index, labels);
const tierColor = getTierColor(index);
const currency = server.currency || 'USD';
return `
<div class="recommendation-card" style="border-left-color: ${tierColor}">
<div class="card-header">
<div class="tier-badge" style="background-color: ${tierColor}">${tierLabel}</div>
<div class="score">${labels.score}: ${score}/100</div>
</div>
<h3 class="server-name">${server.provider_name} - ${server.instance_name}</h3>
<div class="specs-grid">
<div class="spec-item">
<span class="spec-label">${labels.vcpu}</span>
<span class="spec-value">${server.vcpu} cores</span>
</div>
<div class="spec-item">
<span class="spec-label">${labels.memory}</span>
<span class="spec-value">${server.memory_gb} GB</span>
</div>
<div class="spec-item">
<span class="spec-label">${labels.storage}</span>
<span class="spec-value">${server.storage_gb} GB</span>
</div>
<div class="spec-item">
<span class="spec-label">${labels.region}</span>
<span class="spec-value">${server.region_name}</span>
</div>
<div class="spec-item highlight">
<span class="spec-label">${labels.price}</span>
<span class="spec-value">${formatPrice(server.monthly_price, currency)}${labels.perMonth}</span>
</div>
</div>
${bandwidth_info ? `
<div class="bandwidth-section">
<h4>${labels.bandwidthInfo}</h4>
<div class="bandwidth-grid">
<div class="bandwidth-item">
<span class="bw-label">${labels.includedTransfer}</span>
<span class="bw-value">${bandwidth_info.included_transfer_tb} TB${labels.perMonth}</span>
</div>
<div class="bandwidth-item">
<span class="bw-label">${labels.overagePrice}</span>
<span class="bw-value">${bandwidth_info.currency === 'KRW' ? `${bandwidth_info.overage_cost_per_gb}` : `$${bandwidth_info.overage_cost_per_gb.toFixed(4)}`}${labels.perGb}</span>
</div>
<div class="bandwidth-item">
<span class="bw-label">${labels.estimatedUsage}</span>
<span class="bw-value">${bandwidth_info.estimated_monthly_tb} TB</span>
</div>
${bandwidth_info.estimated_overage_tb > 0 ? `
<div class="bandwidth-item warning">
<span class="bw-label">${labels.estimatedOverage}</span>
<span class="bw-value">${bandwidth_info.estimated_overage_tb} TB</span>
</div>
<div class="bandwidth-item warning">
<span class="bw-label">${labels.estimatedCost}</span>
<span class="bw-value">+${bandwidth_info.currency === 'KRW' ? `${Math.round(bandwidth_info.estimated_overage_cost).toLocaleString()}` : `$${bandwidth_info.estimated_overage_cost.toFixed(2)}`}</span>
</div>
` : ''}
<div class="bandwidth-item total">
<span class="bw-label">${labels.totalCost}</span>
<span class="bw-value">${bandwidth_info.currency === 'KRW' ? `${Math.round(bandwidth_info.total_estimated_cost).toLocaleString()}` : `$${bandwidth_info.total_estimated_cost.toFixed(2)}`}${labels.perMonth}</span>
</div>
</div>
${bandwidth_info.warning ? `<div class="bandwidth-warning">${bandwidth_info.warning}</div>` : ''}
</div>
` : ''}
<div class="analysis-section">
<h4>${labels.analysis}</h4>
<div class="analysis-item">
<strong>${labels.techFit}:</strong> ${analysis.tech_fit}
</div>
<div class="analysis-item">
<strong>${labels.capacity}:</strong> ${analysis.capacity}
</div>
<div class="analysis-item">
<strong>${labels.costEfficiency}:</strong> ${analysis.cost_efficiency}
</div>
<div class="analysis-item">
<strong>${labels.scalability}:</strong> ${analysis.scalability}
</div>
</div>
</div>
`;
}).join('\n');
return `<!DOCTYPE html>
<html lang="${lang}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${labels.title}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #1f2937;
background: #f9fafb;
padding: 2rem;
}
.container {
max-width: 1000px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
padding: 2rem;
}
header {
text-align: center;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 2px solid #e5e7eb;
}
h1 {
font-size: 1.875rem;
color: #111827;
margin-bottom: 0.5rem;
}
.subtitle {
color: #6b7280;
font-size: 1rem;
}
.print-note {
background: #fef3c7;
color: #92400e;
padding: 0.75rem 1rem;
border-radius: 8px;
margin-bottom: 1.5rem;
font-size: 0.875rem;
text-align: center;
}
.requirements-section {
background: #f3f4f6;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.requirements-section h2 {
font-size: 1.25rem;
margin-bottom: 1rem;
color: #374151;
}
.req-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.req-item {
background: white;
padding: 1rem;
border-radius: 6px;
}
.req-label {
font-size: 0.75rem;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.req-value {
font-size: 1rem;
font-weight: 600;
color: #111827;
margin-top: 0.25rem;
}
.bandwidth-estimate {
background: #eff6ff;
padding: 1rem;
border-radius: 6px;
margin-top: 1rem;
}
.bandwidth-estimate h3 {
font-size: 0.875rem;
color: #1e40af;
margin-bottom: 0.5rem;
}
.bw-stats {
display: flex;
gap: 2rem;
flex-wrap: wrap;
}
.bw-stat {
font-size: 0.875rem;
}
.bw-stat strong {
color: #1e40af;
}
h2.section-title {
font-size: 1.5rem;
color: #111827;
margin-bottom: 1.5rem;
}
.recommendation-card {
background: white;
border: 1px solid #e5e7eb;
border-left: 4px solid;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.tier-badge {
color: white;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.score {
font-weight: 600;
color: #374151;
}
.server-name {
font-size: 1.25rem;
color: #111827;
margin-bottom: 1rem;
}
.specs-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.spec-item {
background: #f9fafb;
padding: 0.75rem;
border-radius: 6px;
}
.spec-item.highlight {
background: #ecfdf5;
}
.spec-label {
display: block;
font-size: 0.75rem;
color: #6b7280;
}
.spec-value {
display: block;
font-weight: 600;
color: #111827;
}
.bandwidth-section {
background: #fefce8;
padding: 1rem;
border-radius: 6px;
margin-bottom: 1rem;
}
.bandwidth-section h4 {
font-size: 0.875rem;
color: #854d0e;
margin-bottom: 0.75rem;
}
.bandwidth-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.5rem;
}
.bandwidth-item {
display: flex;
justify-content: space-between;
font-size: 0.8125rem;
padding: 0.25rem 0;
}
.bandwidth-item.warning {
color: #dc2626;
}
.bandwidth-item.total {
font-weight: 600;
border-top: 1px solid #fde68a;
padding-top: 0.5rem;
margin-top: 0.25rem;
}
.bw-label {
color: #6b7280;
}
.bw-value {
font-weight: 500;
}
.bandwidth-warning {
margin-top: 0.75rem;
padding: 0.5rem;
background: #fef2f2;
color: #991b1b;
border-radius: 4px;
font-size: 0.8125rem;
}
.analysis-section h4 {
font-size: 0.875rem;
color: #374151;
margin-bottom: 0.75rem;
}
.analysis-item {
font-size: 0.875rem;
margin-bottom: 0.5rem;
color: #4b5563;
}
.analysis-item strong {
color: #374151;
}
footer {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid #e5e7eb;
text-align: center;
color: #9ca3af;
font-size: 0.875rem;
}
@media print {
body {
background: white;
padding: 0;
}
.container {
box-shadow: none;
padding: 1rem;
}
.print-note {
display: none;
}
.recommendation-card {
break-inside: avoid;
}
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>${labels.title}</h1>
<p class="subtitle">${labels.subtitle}</p>
</header>
<div class="print-note">
🖨️ ${labels.printNote}
</div>
${request ? `
<section class="requirements-section">
<h2>${labels.requirements}</h2>
<div class="req-grid">
<div class="req-item">
<span class="req-label">${labels.techStack}</span>
<span class="req-value">${request.tech_stack.join(', ')}</span>
</div>
<div class="req-item">
<span class="req-label">${labels.expectedUsers}</span>
<span class="req-value">${request.expected_users.toLocaleString()}</span>
</div>
<div class="req-item">
<span class="req-label">${labels.useCase}</span>
<span class="req-value">${request.use_case}</span>
</div>
</div>
${bandwidth_estimate ? `
<div class="bandwidth-estimate">
<h3>${labels.bandwidthEstimate}</h3>
<div class="bw-stats">
<span class="bw-stat"><strong>${labels.monthly}:</strong> ${bandwidth_estimate.monthly_tb >= 1 ? `${bandwidth_estimate.monthly_tb} TB` : `${bandwidth_estimate.monthly_gb} GB`}</span>
<span class="bw-stat"><strong>${labels.daily}:</strong> ${bandwidth_estimate.daily_gb} GB</span>
<span class="bw-stat"><strong>${labels.category}:</strong> ${bandwidth_estimate.category}</span>
</div>
</div>
` : ''}
</section>
` : ''}
<section>
<h2 class="section-title">${labels.recommendations}</h2>
${recommendationCards}
</section>
<footer>
<p>${labels.generatedAt}: ${now}</p>
<p>${labels.poweredBy}</p>
</footer>
</div>
</body>
</html>`;
}

View File

@@ -4,85 +4,10 @@
import type { Env } from '../types';
import { jsonResponse, isValidServer } from '../utils';
/**
* Default region filter SQL for anvil_regions (when no region is specified)
*/
const DEFAULT_ANVIL_REGION_FILTER_SQL = `(
-- Korea (Seoul)
ar.name IN ('icn', 'ap-northeast-2') OR
LOWER(ar.display_name) LIKE '%seoul%' OR
-- Japan (Tokyo, Osaka)
ar.name IN ('nrt', 'itm', 'ap-northeast-1', 'ap-northeast-3') OR
LOWER(ar.name) LIKE '%tyo%' OR
LOWER(ar.name) LIKE '%osa%' OR
LOWER(ar.display_name) LIKE '%tokyo%' OR
LOWER(ar.display_name) LIKE '%osaka%' OR
-- Singapore
ar.name IN ('sgp', 'ap-southeast-1') OR
LOWER(ar.name) LIKE '%sin%' OR
LOWER(ar.name) LIKE '%sgp%' OR
LOWER(ar.display_name) LIKE '%singapore%'
)`;
/**
* Country name to region code/city mapping for flexible region matching
*/
const COUNTRY_NAME_TO_REGIONS: Record<string, string[]> = {
'korea': ['seoul', 'koreacentral', 'kr', 'ap-northeast-2'],
'south korea': ['seoul', 'koreacentral', 'kr', 'ap-northeast-2'],
'japan': ['tokyo', 'osaka', 'ap-northeast-1', 'ap-northeast-3'],
'singapore': ['singapore', 'ap-southeast-1'],
'indonesia': ['jakarta', 'ap-southeast-3'],
'australia': ['sydney', 'melbourne', 'au-east', 'ap-southeast-2'],
'india': ['mumbai', 'delhi', 'chennai', 'ap-south'],
'usa': ['us-east', 'us-west', 'us-central', 'america'],
'us': ['us-east', 'us-west', 'us-central', 'america'],
'united states': ['us-east', 'us-west', 'us-central', 'america'],
'uk': ['london', 'uk-south', 'eu-west-2'],
'united kingdom': ['london', 'uk-south', 'eu-west-2'],
'germany': ['frankfurt', 'eu-central', 'eu-west-3'],
'france': ['paris', 'eu-west-3'],
'netherlands': ['amsterdam', 'eu-west-1'],
'brazil': ['sao paulo', 'sa-east'],
'canada': ['toronto', 'montreal', 'ca-central'],
};
/**
* Escape LIKE pattern special characters
*/
function escapeLikePattern(pattern: string): string {
return pattern.replace(/[%_\\]/g, '\\$&');
}
/**
* Build flexible region matching SQL conditions for anvil_regions
*/
function buildFlexibleRegionConditionsAnvil(
regions: string[]
): { conditions: string[]; params: (string | number)[] } {
const conditions: string[] = [];
const params: (string | number)[] = [];
for (const region of regions) {
const lowerRegion = region.toLowerCase();
const expandedRegions = COUNTRY_NAME_TO_REGIONS[lowerRegion] || [lowerRegion];
const allRegions = [lowerRegion, ...expandedRegions];
for (const r of allRegions) {
const escapedRegion = escapeLikePattern(r);
conditions.push(`(
LOWER(ar.name) = ? OR
LOWER(ar.name) LIKE ? ESCAPE '\\' OR
LOWER(ar.display_name) LIKE ? ESCAPE '\\' OR
LOWER(ar.country_code) = ?
)`);
params.push(r, `%${escapedRegion}%`, `%${escapedRegion}%`, r);
}
}
return { conditions, params };
}
import {
DEFAULT_ANVIL_REGION_FILTER_SQL,
buildFlexibleRegionConditionsAnvil
} from '../region-utils';
/**
* GET /api/servers - Server list with filtering
@@ -105,6 +30,18 @@ export async function handleGetServers(
region,
});
// Generate cache key from query parameters
const cacheKey = `servers:${url.search || 'all'}`;
// Check cache first
if (env.CACHE) {
const cached = await env.CACHE.get(cacheKey);
if (cached) {
console.log('[GetServers] Cache hit for:', cacheKey);
return jsonResponse({ ...JSON.parse(cached), cached: true }, 200, corsHeaders);
}
}
// Build SQL query using anvil_* tables
let query = `
SELECT
@@ -182,15 +119,20 @@ export async function handleGetServers(
console.log('[GetServers] Found servers:', servers.length);
return jsonResponse(
{
servers,
count: servers.length,
filters: { minCpu, minMemory, region },
},
200,
corsHeaders
);
const responseData = {
servers,
count: servers.length,
filters: { minCpu, minMemory, region },
};
// Cache successful results (only if we have servers)
if (env.CACHE && servers.length > 0) {
await env.CACHE.put(cacheKey, JSON.stringify(responseData), {
expirationTtl: 300, // 5 minutes
});
}
return jsonResponse(responseData, 200, corsHeaders);
} catch (error) {
console.error('[GetServers] Error:', error);
const requestId = crypto.randomUUID();

View File

@@ -9,6 +9,7 @@ import { getAllowedOrigin, checkRateLimit, jsonResponse } from './utils';
import { handleHealth } from './handlers/health';
import { handleGetServers } from './handlers/servers';
import { handleRecommend } from './handlers/recommend';
import { handleReport } from './handlers/report';
/**
* Main request handler
@@ -66,6 +67,10 @@ export default {
return handleRecommend(request, env, corsHeaders);
}
if (path === '/api/recommend/report' && request.method === 'GET') {
return handleReport(request, env, corsHeaders);
}
return jsonResponse(
{ error: 'Not found', request_id: requestId },
404,

137
src/region-utils.ts Normal file
View File

@@ -0,0 +1,137 @@
/**
* Region-related utilities for flexible region matching
* Consolidates duplicated region code from utils.ts, recommend.ts, and servers.ts
*/
/**
* Country name to region code/city mapping for flexible region matching
* Note: Use specific city names to avoid LIKE pattern collisions (e.g., 'de' matches 'Delhi')
*/
export const COUNTRY_NAME_TO_REGIONS: Record<string, string[]> = {
'korea': ['seoul', 'koreacentral', 'kr', 'ap-northeast-2'],
'south korea': ['seoul', 'koreacentral', 'kr', 'ap-northeast-2'],
'japan': ['tokyo', 'osaka', 'ap-northeast-1', 'ap-northeast-3'],
'singapore': ['singapore', 'ap-southeast-1'],
'indonesia': ['jakarta', 'ap-southeast-3'],
'australia': ['sydney', 'melbourne', 'au-east', 'ap-southeast-2'],
'india': ['mumbai', 'delhi', 'chennai', 'ap-south'],
'usa': ['us-east', 'us-west', 'us-central', 'america'],
'us': ['us-east', 'us-west', 'us-central', 'america'],
'united states': ['us-east', 'us-west', 'us-central', 'america'],
'uk': ['london', 'uk-south', 'eu-west-2'],
'united kingdom': ['london', 'uk-south', 'eu-west-2'],
'germany': ['frankfurt', 'eu-central', 'eu-west-3'],
'france': ['paris', 'eu-west-3'],
'netherlands': ['amsterdam', 'eu-west-1'],
'brazil': ['sao paulo', 'sa-east'],
'canada': ['toronto', 'montreal', 'ca-central'],
};
/**
* Escape LIKE pattern special characters
*/
export function escapeLikePattern(pattern: string): string {
return pattern.replace(/[%_\\]/g, '\\$&');
}
/**
* Default region filter SQL for legacy regions table (when no region is specified)
* Used in /api/recommend for backward compatibility
*/
export const DEFAULT_REGION_FILTER_SQL = `(
-- 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%'
)`;
/**
* Default region filter SQL for anvil_regions (when no region is specified)
* Used in both /api/recommend and /api/servers
*/
export const DEFAULT_ANVIL_REGION_FILTER_SQL = `(
-- Korea (Seoul)
ar.name IN ('icn', 'ap-northeast-2') OR
LOWER(ar.display_name) LIKE '%seoul%' OR
-- Japan (Tokyo, Osaka)
ar.name IN ('nrt', 'itm', 'ap-northeast-1', 'ap-northeast-3') OR
LOWER(ar.name) LIKE '%tyo%' OR
LOWER(ar.name) LIKE '%osa%' OR
LOWER(ar.display_name) LIKE '%tokyo%' OR
LOWER(ar.display_name) LIKE '%osaka%' OR
-- Singapore
ar.name IN ('sgp', 'ap-southeast-1') OR
LOWER(ar.name) LIKE '%sin%' OR
LOWER(ar.name) LIKE '%sgp%' OR
LOWER(ar.display_name) LIKE '%singapore%'
)`;
/**
* Build flexible region matching SQL conditions for legacy regions table
* Returns SQL conditions and parameters for use in prepared statements
*/
export function buildFlexibleRegionConditions(
regions: string[]
): { conditions: string[]; params: (string | number)[] } {
const conditions: string[] = [];
const params: (string | number)[] = [];
for (const region of regions) {
const lowerRegion = region.toLowerCase();
const expandedRegions = COUNTRY_NAME_TO_REGIONS[lowerRegion] || [lowerRegion];
const allRegions = [lowerRegion, ...expandedRegions];
for (const r of allRegions) {
const escapedRegion = escapeLikePattern(r);
conditions.push(`(
LOWER(r.region_code) = ? OR
LOWER(r.region_code) LIKE ? ESCAPE '\\' OR
LOWER(r.region_name) LIKE ? ESCAPE '\\' OR
LOWER(r.country_code) = ?
)`);
params.push(r, `%${escapedRegion}%`, `%${escapedRegion}%`, r);
}
}
return { conditions, params };
}
/**
* Build flexible region matching SQL conditions for anvil_regions
* Returns SQL conditions and parameters for use in prepared statements
*/
export function buildFlexibleRegionConditionsAnvil(
regions: string[]
): { conditions: string[]; params: (string | number)[] } {
const conditions: string[] = [];
const params: (string | number)[] = [];
for (const region of regions) {
const lowerRegion = region.toLowerCase();
const expandedRegions = COUNTRY_NAME_TO_REGIONS[lowerRegion] || [lowerRegion];
const allRegions = [lowerRegion, ...expandedRegions];
for (const r of allRegions) {
const escapedRegion = escapeLikePattern(r);
conditions.push(`(
LOWER(ar.name) = ? OR
LOWER(ar.name) LIKE ? ESCAPE '\\' OR
LOWER(ar.display_name) LIKE ? ESCAPE '\\' OR
LOWER(ar.country_code) = ?
)`);
params.push(r, `%${escapedRegion}%`, `%${escapedRegion}%`, r);
}
}
return { conditions, params };
}

View File

@@ -50,16 +50,20 @@ export interface Server {
currency: 'USD' | 'KRW';
region_name: string;
region_code: string;
// Transfer pricing (from anvil_instances + anvil_transfer_pricing)
transfer_tb: number | null; // 기본 포함 트래픽 (TB/월)
transfer_price_per_gb: number | null; // 초과 트래픽 가격 ($/GB)
}
export interface BandwidthInfo {
included_transfer_tb: number; // 기본 포함 트래픽 (TB/월)
overage_cost_per_gb: number; // 초과 비용 ($/GB)
overage_cost_per_tb: number; // 초과 비용 ($/TB)
overage_cost_per_gb: number; // 초과 비용 ($/GB 또는 ₩/GB)
overage_cost_per_tb: number; // 초과 비용 ($/TB 또는 ₩/TB)
estimated_monthly_tb: number; // 예상 월간 사용량 (TB)
estimated_overage_tb: number; // 예상 초과량 (TB)
estimated_overage_cost: number; // 예상 초과 비용 ($)
estimated_overage_cost: number; // 예상 초과 비용
total_estimated_cost: number; // 총 예상 비용 (서버 + 트래픽)
currency: 'USD' | 'KRW'; // 통화
warning?: string; // 트래픽 관련 경고
}

View File

@@ -88,16 +88,17 @@ export function generateCacheKey(req: RecommendRequest): string {
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}`);
}
// Include region preference in cache key
if (req.region_preference && req.region_preference.length > 0) {
const sortedRegions = [...req.region_preference].sort();
const sanitizedRegions = sortedRegions.map(sanitizeCacheValue).join(',');
parts.push(`region:${sanitizedRegions}`);
}
// Include language in cache key
if (req.lang) {
parts.push(`lang:${req.lang}`);
@@ -107,85 +108,15 @@ export function generateCacheKey(req: RecommendRequest): string {
}
/**
* Default region filter SQL for when no region is specified
* Used in both /api/recommend and /api/servers
* Re-export region utilities from region-utils.ts for backward compatibility
*/
export const DEFAULT_REGION_FILTER_SQL = `(
-- 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%'
)`;
/**
* Escape LIKE pattern special characters
*/
export function escapeLikePattern(pattern: string): string {
return pattern.replace(/[%_\\]/g, '\\$&');
}
/**
* Country name to region code/city mapping for flexible region matching
* Note: Use specific city names to avoid LIKE pattern collisions (e.g., 'de' matches 'Delhi')
*/
export const COUNTRY_NAME_TO_REGIONS: Record<string, string[]> = {
'korea': ['seoul', 'koreacentral', 'kr', 'ap-northeast-2'],
'south korea': ['seoul', 'koreacentral', 'kr', 'ap-northeast-2'],
'japan': ['tokyo', 'osaka', 'ap-northeast-1', 'ap-northeast-3'],
'singapore': ['singapore', 'ap-southeast-1'],
'indonesia': ['jakarta', 'ap-southeast-3'],
'australia': ['sydney', 'melbourne', 'au-east', 'ap-southeast-2'],
'india': ['mumbai', 'delhi', 'chennai', 'ap-south'],
'usa': ['us-east', 'us-west', 'us-central', 'america'],
'us': ['us-east', 'us-west', 'us-central', 'america'],
'united states': ['us-east', 'us-west', 'us-central', 'america'],
'uk': ['london', 'uk-south', 'eu-west-2'],
'united kingdom': ['london', 'uk-south', 'eu-west-2'],
'germany': ['frankfurt', 'eu-central', 'eu-west-3'],
'france': ['paris', 'eu-west-3'],
'netherlands': ['amsterdam', 'eu-west-1'],
'brazil': ['sao paulo', 'sa-east'],
'canada': ['toronto', 'montreal', 'ca-central'],
};
/**
* Build flexible region matching SQL conditions and parameters
*/
export function buildFlexibleRegionConditions(
regions: string[]
): { conditions: string[]; params: (string | number)[] } {
const conditions: string[] = [];
const params: (string | number)[] = [];
for (const region of regions) {
const lowerRegion = region.toLowerCase();
const expandedRegions = COUNTRY_NAME_TO_REGIONS[lowerRegion] || [lowerRegion];
const allRegions = [lowerRegion, ...expandedRegions];
for (const r of allRegions) {
const escapedRegion = escapeLikePattern(r);
conditions.push(`(
LOWER(r.region_code) = ? OR
LOWER(r.region_code) LIKE ? ESCAPE '\\' OR
LOWER(r.region_name) LIKE ? ESCAPE '\\' OR
LOWER(r.country_code) = ?
)`);
params.push(r, `%${escapedRegion}%`, `%${escapedRegion}%`, r);
}
}
return { conditions, params };
}
export {
DEFAULT_ANVIL_REGION_FILTER_SQL,
COUNTRY_NAME_TO_REGIONS,
escapeLikePattern,
buildFlexibleRegionConditions,
buildFlexibleRegionConditionsAnvil
} from './region-utils';
/**
* Type guard to validate Server object structure
@@ -288,8 +219,10 @@ export function validateRecommendRequest(body: any, lang: string = 'en'): Valida
invalidFields.push({ field: 'tech_stack', reason: 'must be a non-empty array of strings' });
} else if (body.tech_stack.length > LIMITS.MAX_TECH_STACK) {
invalidFields.push({ field: 'tech_stack', reason: `must not exceed ${LIMITS.MAX_TECH_STACK} items` });
} else if (!body.tech_stack.every((item: any) => typeof item === 'string')) {
invalidFields.push({ field: 'tech_stack', reason: 'all items must be strings' });
} else if (!body.tech_stack.every((item: unknown) =>
typeof item === 'string' && item.length <= 50
)) {
invalidFields.push({ field: 'tech_stack', reason: messages.techStackItemLength || 'all items must be strings with max 50 characters' });
}
if (body.expected_users === undefined) {
@@ -313,16 +246,6 @@ export function validateRecommendRequest(body: any, lang: string = 'en'): Valida
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 > LIMITS.MAX_REGION_PREFERENCE) {
invalidFields.push({ field: 'region_preference', reason: `must not exceed ${LIMITS.MAX_REGION_PREFERENCE} 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' });
}
@@ -617,38 +540,80 @@ export function getProviderBandwidthAllocation(providerName: string, memoryGb: n
/**
* Calculate bandwidth cost info for a server
* Uses actual DB values from anvil_transfer_pricing when available
* @param server Server object
* @param bandwidthEstimate Bandwidth estimate
* @param lang Language code (ko = KRW, others = USD)
* @param exchangeRate Exchange rate (USD to KRW)
*/
export function calculateBandwidthInfo(
server: import('./types').Server,
bandwidthEstimate: BandwidthEstimate
bandwidthEstimate: BandwidthEstimate,
lang: string = 'en',
exchangeRate: number = 1
): 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;
// Use actual DB values if available (Anvil servers), fallback to provider-based estimation
let includedTb: number;
let overagePerGbUsd: number;
let overagePerTbUsd: number;
// Convert server price to USD if needed for total cost calculation
if (server.transfer_tb !== null && server.transfer_price_per_gb !== null) {
// Use actual values from anvil_instances + anvil_transfer_pricing
includedTb = server.transfer_tb;
overagePerGbUsd = server.transfer_price_per_gb;
overagePerTbUsd = server.transfer_price_per_gb * 1024;
} else {
// Fallback to provider-based estimation for non-Anvil servers
const allocation = getProviderBandwidthAllocation(server.provider_name, server.memory_gb);
includedTb = allocation.included_tb;
overagePerGbUsd = allocation.overage_per_gb;
overagePerTbUsd = allocation.overage_per_tb;
}
const estimatedTb = bandwidthEstimate.monthly_tb;
const overageTb = Math.max(0, estimatedTb - includedTb);
const overageCostUsd = overageTb * overagePerTbUsd;
// Get server price in USD for total calculation
const serverPriceUsd = server.currency === 'KRW'
? server.monthly_price / 1400 // Approximate KRW to USD
? server.monthly_price / exchangeRate
: server.monthly_price;
const totalCost = serverPriceUsd + overageCost;
const totalCostUsd = serverPriceUsd + overageCostUsd;
// Convert to KRW if Korean language, round to nearest 100
const isKorean = lang === 'ko';
const currency: 'USD' | 'KRW' = isKorean ? 'KRW' : 'USD';
// KRW: GB당은 1원 단위, TB당/총 비용은 100원 단위 반올림
const roundKrw100 = (usd: number) => Math.round((usd * exchangeRate) / 100) * 100;
const toKrw = (usd: number) => Math.round(usd * exchangeRate);
const overagePerGb = isKorean ? toKrw(overagePerGbUsd) : overagePerGbUsd;
const overagePerTb = isKorean ? roundKrw100(overagePerTbUsd) : overagePerTbUsd;
const overageCost = isKorean ? roundKrw100(overageCostUsd) : Math.round(overageCostUsd * 100) / 100;
const totalCost = isKorean ? roundKrw100(totalCostUsd) : Math.round(totalCostUsd * 100) / 100;
let warning: string | undefined;
if (overageTb > allocation.included_tb) {
warning = `⚠️ 예상 트래픽(${estimatedTb.toFixed(1)}TB)이 기본 포함량(${allocation.included_tb}TB)의 2배 이상입니다. 상위 플랜을 고려하세요.`;
if (overageTb > includedTb) {
const costStr = isKorean ? `${overageCost.toLocaleString()}` : `$${overageCost.toFixed(0)}`;
warning = `⚠️ 예상 트래픽(${estimatedTb.toFixed(1)}TB)이 기본 포함량(${includedTb}TB)의 2배 이상입니다. 상위 플랜을 고려하세요.`;
} else if (overageTb > 0) {
warning = `예상 초과 트래픽: ${overageTb.toFixed(1)}TB (추가 비용 ~$${overageCost.toFixed(0)}/월)`;
const costStr = isKorean ? `${overageCost.toLocaleString()}` : `$${overageCost.toFixed(0)}`;
warning = isKorean
? `예상 초과 트래픽: ${overageTb.toFixed(1)}TB (추가 비용 ~${costStr}/월)`
: `예상 초과 트래픽: ${overageTb.toFixed(1)}TB (추가 비용 ~${costStr}/월)`;
}
return {
included_transfer_tb: allocation.included_tb,
overage_cost_per_gb: allocation.overage_per_gb,
overage_cost_per_tb: allocation.overage_per_tb,
included_transfer_tb: includedTb,
overage_cost_per_gb: isKorean ? Math.round(overagePerGb) : Math.round(overagePerGb * 10000) / 10000,
overage_cost_per_tb: isKorean ? Math.round(overagePerTb) : Math.round(overagePerTb * 100) / 100,
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,
estimated_overage_cost: overageCost,
total_estimated_cost: totalCost,
currency,
warning
};
}
@@ -657,16 +622,35 @@ export function calculateBandwidthInfo(
* Sanitize user input for AI prompts to prevent prompt injection
*/
export function sanitizeForAIPrompt(input: string, maxLength: number = 200): string {
// Remove potential prompt injection patterns
let sanitized = input
.replace(/ignore\s*(all|previous|above)?\s*instruction/gi, '[filtered]')
.replace(/system\s*prompt/gi, '[filtered]')
.replace(/you\s*are\s*(now|a)/gi, '[filtered]')
.replace(/pretend\s*(to\s*be|you)/gi, '[filtered]')
.replace(/act\s*as/gi, '[filtered]')
.replace(/disregard/gi, '[filtered]');
// 1. Normalize Unicode (NFKC form collapses homoglyphs)
let sanitized = input.normalize('NFKC');
// 2. Remove zero-width characters
sanitized = sanitized.replace(/[\u200B-\u200D\uFEFF\u00AD]/g, '');
// 3. Expanded blocklist patterns
const dangerousPatterns = [
/ignore\s*(all|previous|above)?\s*instruction/gi,
/system\s*prompt/gi,
/you\s*are\s*(now|a)/gi,
/pretend\s*(to\s*be|you)/gi,
/act\s*as/gi,
/disregard/gi,
/forget\s*(everything|all|previous)/gi,
/new\s*instruction/gi,
/override/gi,
/\[system\]/gi,
/<\|im_start\|>/gi,
/<\|im_end\|>/gi,
/```[\s\S]*?```/g, // Code blocks that might contain injection
/"""/g, // Triple quotes
/---+/g, // Horizontal rules/delimiters
];
for (const pattern of dangerousPatterns) {
sanitized = sanitized.replace(pattern, '[filtered]');
}
// Limit length
return sanitized.slice(0, maxLength);
}
@@ -698,10 +682,16 @@ export async function getExchangeRate(env: Env): Promise<number> {
// Fetch fresh rate from API
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
const response = await fetch('https://open.er-api.com/v6/latest/USD', {
headers: { 'Accept': 'application/json' },
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`API returned ${response.status}`);
}
@@ -733,7 +723,11 @@ export async function getExchangeRate(env: Env): Promise<number> {
return rate;
} catch (error) {
console.error('[ExchangeRate] API error:', error);
if (error instanceof Error && error.name === 'AbortError') {
console.warn('[ExchangeRate] Request timed out, using fallback');
} else {
console.error('[ExchangeRate] API error:', error);
}
return EXCHANGE_RATE_FALLBACK;
}
}

View File

@@ -1,4 +1,4 @@
name = "server-recommend"
name = "cloud-orchestrator"
main = "src/index.ts"
compatibility_date = "2025-01-23"
compatibility_flags = ["nodejs_compat"]