251 lines
6.5 KiB
TypeScript
251 lines
6.5 KiB
TypeScript
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 })
|
|
}
|
|
}
|