173 lines
4.6 KiB
TypeScript
173 lines
4.6 KiB
TypeScript
import express, { Request, Response } from 'express'
|
|
import cors from 'cors'
|
|
import rateLimit from 'express-rate-limit'
|
|
import ipaddr from 'ipaddr.js'
|
|
|
|
const app = express()
|
|
const PORT = process.env.PORT || 3000
|
|
const GLOBALPING_API = 'https://api.globalping.io/v1'
|
|
|
|
app.use(express.json())
|
|
app.use(cors({ origin: 'http://localhost:5173' }))
|
|
app.use(rateLimit({
|
|
windowMs: 60 * 1000,
|
|
limit: 10,
|
|
message: { error: 'Rate limit exceeded' }
|
|
}))
|
|
|
|
interface GlobalPingLocation {
|
|
country: string
|
|
city?: string
|
|
}
|
|
|
|
const NODE_LOCATIONS: Record<string, GlobalPingLocation> = {
|
|
'us-west': { country: 'US', city: 'San Francisco' },
|
|
'us-east': { country: 'US', city: 'New York' },
|
|
'europe': { country: 'DE', city: 'Frankfurt' },
|
|
'asia': { country: 'JP', city: 'Tokyo' },
|
|
'south-america': { country: 'BR', city: 'Sao Paulo' },
|
|
'africa': { country: 'ZA', city: 'Cape Town' },
|
|
'oceania': { country: 'AU', city: 'Sydney' }
|
|
}
|
|
|
|
function extractClientIp(req: Request): string | null {
|
|
const forwarded = req.headers['x-forwarded-for']
|
|
const raw = typeof forwarded === 'string' ? forwarded.split(',')[0].trim() : req.socket.remoteAddress
|
|
if (!raw) return null
|
|
const normalized = raw.replace(/^::ffff:/, '')
|
|
if (ipaddr.isValid(normalized)) return normalized
|
|
return null
|
|
}
|
|
|
|
function isPublicIp(ip: string): boolean {
|
|
if (!ipaddr.isValid(ip)) return false
|
|
const parsed = ipaddr.parse(ip)
|
|
return parsed.range() === 'unicast'
|
|
}
|
|
|
|
interface MeasurementResponse {
|
|
id: string
|
|
probesCount: number
|
|
}
|
|
|
|
interface MeasurementResult {
|
|
id: string
|
|
type: string
|
|
status: 'in-progress' | 'finished'
|
|
results?: Array<{
|
|
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
|
|
}
|
|
}
|
|
}>
|
|
}
|
|
|
|
async function createMeasurement(target: string, location: GlobalPingLocation): Promise<string> {
|
|
const res = await fetch(`${GLOBALPING_API}/measurements`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept-Encoding': 'gzip',
|
|
'User-Agent': 'LatencyTest/1.0.0'
|
|
},
|
|
body: JSON.stringify({
|
|
type: 'ping',
|
|
target,
|
|
locations: [location],
|
|
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
|
|
}
|
|
|
|
async function getMeasurementResult(id: string, timeout = 30000): Promise<{ latency: number | null; success: boolean }> {
|
|
const startTime = Date.now()
|
|
|
|
while (Date.now() - startTime < timeout) {
|
|
await new Promise(r => setTimeout(r, 500))
|
|
|
|
const res = await fetch(`${GLOBALPING_API}/measurements/${id}`, {
|
|
headers: {
|
|
'Accept-Encoding': 'gzip',
|
|
'User-Agent': 'LatencyTest/1.0.0'
|
|
}
|
|
})
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`Failed to get measurement: ${res.status}`)
|
|
}
|
|
|
|
const data = await res.json() as MeasurementResult
|
|
|
|
if (data.status !== 'in-progress') {
|
|
const result = data.results?.[0]?.result
|
|
if (result?.status === 'finished' && result.stats?.avg != null) {
|
|
return { latency: Math.round(result.stats.avg), success: true }
|
|
}
|
|
return { latency: null, success: false }
|
|
}
|
|
}
|
|
|
|
return { latency: null, success: false }
|
|
}
|
|
|
|
app.get('/api/ip', (req: Request, res: Response) => {
|
|
const ip = extractClientIp(req)
|
|
if (!ip) {
|
|
return res.status(500).json({ error: 'Unable to determine IP' })
|
|
}
|
|
res.json({ ip })
|
|
})
|
|
|
|
app.post('/api/latency', async (req: Request, res: Response) => {
|
|
const { targetIp, nodeId } = req.body
|
|
|
|
if (!targetIp || typeof targetIp !== 'string') {
|
|
return res.status(400).json({ error: 'targetIp is required' })
|
|
}
|
|
|
|
if (!nodeId || !NODE_LOCATIONS[nodeId]) {
|
|
return res.status(400).json({ error: `Invalid nodeId. Available: ${Object.keys(NODE_LOCATIONS).join(', ')}` })
|
|
}
|
|
|
|
if (!isPublicIp(targetIp)) {
|
|
return res.status(400).json({ error: 'Invalid or private IP address' })
|
|
}
|
|
|
|
try {
|
|
const measurementId = await createMeasurement(targetIp, NODE_LOCATIONS[nodeId])
|
|
const { latency, success } = await getMeasurementResult(measurementId)
|
|
res.json({ nodeId, latency, success })
|
|
} catch (error) {
|
|
console.error('Latency test error:', error)
|
|
res.status(500).json({ nodeId, latency: null, success: false })
|
|
}
|
|
})
|
|
|
|
app.listen(PORT, () => {
|
|
console.log(`Server running on http://localhost:${PORT}`)
|
|
})
|