feat(CF): 现已支持部署到CloudFlare-Workers
This commit is contained in:
250
src/worker/index.ts
Normal file
250
src/worker/index.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user