feat(CF): 现已支持部署到CloudFlare-Workers

This commit is contained in:
2025-12-22 14:55:40 +08:00
parent 6f6dea9f03
commit 1c7837d50c
12 changed files with 2441 additions and 119 deletions

250
src/worker/index.ts Normal file
View File

@@ -0,0 +1,250 @@
import { TEST_NODES } from '../shared/types'
interface Env {
GLOBALPING_API: string
}
interface MeasurementResponse {
id: string
probesCount: number
}
interface ProbeResult {
probe: {
continent: string
country: string
city: string
asn: number
network: string
}
result: {
status: string
rawOutput: string
stats?: {
min: number
max: number
avg: number
total: number
loss: number
}
}
}
interface MeasurementResult {
id: string
type: string
status: 'in-progress' | 'finished'
results?: ProbeResult[]
}
interface BatchLatencyResult {
nodeId: string
latency: number | null
success: boolean
}
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
}
function jsonResponse(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: {
'Content-Type': 'application/json',
...corsHeaders,
},
})
}
const IP_REGEX = /^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)$/
const DOMAIN_REGEX = /^(?!:\/\/)([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/
function isValidTarget(target: string): boolean {
return IP_REGEX.test(target) || DOMAIN_REGEX.test(target)
}
function extractClientIp(request: Request): string | null {
const cfConnectingIp = request.headers.get('CF-Connecting-IP')
if (cfConnectingIp) return cfConnectingIp
const forwarded = request.headers.get('X-Forwarded-For')
if (forwarded) return forwarded.split(',')[0].trim()
return null
}
async function createBatchMeasurement(target: string, env: Env): Promise<string> {
const locations = TEST_NODES.map(node => ({
country: node.country,
city: node.city,
limit: 1
}))
const res = await fetch(`${env.GLOBALPING_API}/measurements`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'LatencyTest/1.0.0'
},
body: JSON.stringify({
type: 'ping',
target,
inProgressUpdates: true,
locations,
measurementOptions: {
packets: 3
}
})
})
if (!res.ok) {
const error = await res.json().catch(() => ({})) as { error?: { message?: string } }
throw new Error(error.error?.message || `GlobalPing API error: ${res.status}`)
}
const data = await res.json() as MeasurementResponse
return data.id
}
function normalizeLocationName(value?: string | null): string {
if (!value) return ''
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-zA-Z0-9]+/g, ' ')
.trim()
.toLowerCase()
}
function matchProbeToNode(probe: ProbeResult['probe']): string | null {
const candidates = TEST_NODES.filter(node => node.country === probe.country)
if (candidates.length === 0) return null
const probeCity = normalizeLocationName(probe.city)
if (probeCity) {
for (const node of candidates) {
const nodeCity = normalizeLocationName(node.city)
if (nodeCity && (probeCity.includes(nodeCity) || nodeCity.includes(probeCity))) {
return node.id
}
}
}
if (candidates.length === 1) {
return candidates[0].id
}
return null
}
async function handleGetIp(request: Request): Promise<Response> {
const ip = extractClientIp(request)
if (!ip) {
return jsonResponse({ error: 'Unable to determine IP' }, 500)
}
return jsonResponse({ ip })
}
async function handleCreateMeasurement(request: Request, env: Env): Promise<Response> {
try {
const body = await request.json() as { target?: string }
const { target } = body
if (!target || typeof target !== 'string') {
return jsonResponse({ error: 'target is required' }, 400)
}
const trimmedTarget = target.trim().toLowerCase()
if (!isValidTarget(trimmedTarget)) {
return jsonResponse({ error: 'Invalid target. Please enter a valid IP address or domain name.' }, 400)
}
const measurementId = await createBatchMeasurement(trimmedTarget, env)
return jsonResponse({ measurementId })
} catch (error) {
console.error('Batch measurement creation error:', error)
return jsonResponse({ error: 'Failed to create measurement' }, 500)
}
}
async function handleGetMeasurement(measurementId: string, env: Env): Promise<Response> {
try {
const fetchRes = await fetch(`${env.GLOBALPING_API}/measurements/${measurementId}`, {
headers: {
'User-Agent': 'LatencyTest/1.0.0'
}
})
if (!fetchRes.ok) {
return jsonResponse({ error: 'Failed to get measurement' }, fetchRes.status)
}
const data = await fetchRes.json() as MeasurementResult
const results: BatchLatencyResult[] = []
const matchedNodes = new Set<string>()
if (data.results) {
for (const probeResult of data.results) {
const result = probeResult.result
const nodeId = matchProbeToNode(probeResult.probe)
if (nodeId && !matchedNodes.has(nodeId)) {
matchedNodes.add(nodeId)
if (result.status === 'finished') {
const latency = result.stats?.avg != null ? Math.round(result.stats.avg) : null
results.push({ nodeId, latency, success: latency !== null })
} else {
results.push({ nodeId, latency: null, success: false })
}
}
}
}
for (const node of TEST_NODES) {
if (!matchedNodes.has(node.id)) {
results.push({ nodeId: node.id, latency: null, success: false })
}
}
return jsonResponse({
status: data.status,
results
})
} catch (error) {
console.error('Get measurement error:', error)
return jsonResponse({ error: 'Failed to get measurement' }, 500)
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
const path = url.pathname
// Handle CORS preflight
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders })
}
// API routes
if (path === '/api/ip' && request.method === 'GET') {
return handleGetIp(request)
}
if (path === '/api/latency/batch' && request.method === 'POST') {
return handleCreateMeasurement(request, env)
}
const batchMatch = path.match(/^\/api\/latency\/batch\/([a-zA-Z0-9-]+)$/)
if (batchMatch && request.method === 'GET') {
return handleGetMeasurement(batchMatch[1], env)
}
// Serve static files for other routes (handled by Cloudflare Pages/Workers Sites)
return new Response('Not Found', { status: 404 })
}
}