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

View File

@@ -6,11 +6,9 @@
},
"permissions": {
"allow": [
"mcp__gemini__gemini",
"mcp__codex__codex",
"Bash(npm run build)",
"Bash(npm install react-globe.gl three)"
"mcp__gemini__gemini"
],
"deny": []
}
}
}

3
.gitignore vendored
View File

@@ -173,3 +173,6 @@ dist
.yarn/install-state.gz
.pnp.*
.claude
.idea
.wrangler

View File

@@ -1,5 +1,68 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="AliAccessStaticViaInstance" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AliArrayNamingShouldHaveBracket" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AliControlFlowStatementWithoutBraces" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AliDeprecation" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AliEqualsAvoidNull" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AliLongLiteralsEndingWithLowercaseL" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AliMissingOverrideAnnotation" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AliWrapperTypeEquality" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAbstractClassShouldStartWithAbstractNaming" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAbstractMethodOrInterfaceMethodMustUseJavadoc" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidApacheBeanUtilsCopy" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidCallStaticSimpleDateFormat" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidCommentBehindStatement" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidComplexCondition" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidConcurrentCompetitionRandom" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidDoubleOrFloatEqualCompare" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidManuallyCreateThread" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidMissUseOfMathRandom" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidNegationOperator" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidNewDateGetTime" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidPatternCompileInMethod" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidReturnInFinally" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidStartWithDollarAndUnderLineNaming" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaAvoidUseTimer" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaBigDecimalAvoidDoubleConstructor" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaBooleanPropertyShouldNotStartWithIs" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaClassCastExceptionWithSubListToArrayList" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaClassCastExceptionWithToArray" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaClassMustHaveAuthor" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaClassNamingShouldBeCamel" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaCollectionInitShouldAssignCapacity" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaCommentsMustBeJavadocFormat" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaConcurrentExceptionWithModifyOriginSubList" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaConstantFieldShouldBeUpperCase" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaCountDownShouldInFinally" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaDontModifyInForeachCircle" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaEnumConstantsMustHaveComment" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaExceptionClassShouldEndWithException" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaIbatisMethodQueryForList" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaLockShouldWithTryFinally" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaLowerCamelCaseVariableNaming" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaMethodReturnWrapperType" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaMethodTooLong" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaPackageNaming" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaPojoMustOverrideToString" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaPojoMustUsePrimitiveField" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaPojoNoDefaultValue" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaRemoveCommentedCode" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaServiceOrDaoClassShouldEndWithImpl" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaSneakyThrowsWithoutExceptionType" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaStringConcat" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaSwitchExpression" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaSwitchStatement" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaTestClassShouldEndWithTestNaming" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaThreadLocalShouldRemove" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaThreadPoolCreation" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaThreadShouldSetName" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaTransactionMustHaveRollback" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaUndefineMagicConstant" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaUnsupportedExceptionWithModifyAsList" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaUseQuietReferenceNotation" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AlibabaUseRightCaseForDateFormat" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="MapOrSetKeyShouldOverrideHashCodeEquals" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

2
.idea/modules.xml generated
View File

@@ -2,7 +2,7 @@
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/LatencyTest.iml" filepath="$PROJECT_DIR$/.idea/LatencyTest.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/LatencyTest-CF.iml" filepath="$PROJECT_DIR$/.idea/LatencyTest-CF.iml" />
</modules>
</component>
</project>

2182
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,8 +10,11 @@
"build": "npm run build:client && npm run build:server",
"build:client": "tsc -b tsconfig.client.json && vite build",
"build:server": "tsc -b tsconfig.server.json",
"build:worker": "npm run build:client && esbuild src/worker/index.ts --bundle --outfile=dist/worker/index.js --format=esm --platform=browser --target=es2022 --alias:@shared=./src/shared",
"preview": "vite preview",
"start": "node dist/server/index.js"
"start": "node dist/server/index.js",
"deploy": "npm run build:worker && wrangler deploy",
"dev:worker": "wrangler dev"
},
"dependencies": {
"cors": "^2.8.5",
@@ -25,6 +28,7 @@
"three": "^0.182.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20241218.0",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.11.30",
@@ -32,8 +36,10 @@
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.1.2",
"esbuild": "^0.24.0",
"tsx": "^4.7.0",
"typescript": "~5.6.2",
"vite": "^6.0.5"
"vite": "^6.0.5",
"wrangler": "^3.99.0"
}
}

View File

@@ -35,7 +35,7 @@ function AppContent() {
<div className="app">
<FloatingHeader />
<main className="app-main" style={{ paddingTop: '5rem' }}>
<main className="app-main" style={{ paddingTop: '8rem' }}>
<p className="app-description">
{t('从全球各地测试到任意IP地址或域名的网络延迟', 'Test network latency from global locations to any IP address or domain')}
</p>
@@ -53,7 +53,7 @@ function AppContent() {
</main>
<footer className="app-footer">
<p>{t('© 2024 延迟测试。由 GlobalPing 提供支持。', '© 2024 Latency Test. Powered by GlobalPing.')}</p>
<p>{t('© 2025 全球延迟测试。由 GlobalPing 提供服务支持。', '© 2025 Global Latency Test. Powered by GlobalPing.')}</p>
</footer>
</div>
)

View File

@@ -1,6 +1,6 @@
.floating-header {
position: fixed;
top: 1.25rem;
top: 1rem;
left: 50%;
transform: translateX(-50%);
z-index: 100;
@@ -8,7 +8,7 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 3rem;
padding: 0.75rem 2rem;
background: var(--card-bg);
backdrop-filter: var(--glass-blur);
@@ -18,7 +18,7 @@
box-shadow: 0 4px 20px -2px rgba(0, 0, 0, 0.1);
transition: opacity var(--transition-smooth);
width: calc(100% - 2rem);
width: 80%;
max-width: 1100px;
}
@@ -58,8 +58,9 @@
@media (max-width: 640px) {
.floating-header {
top: 1rem;
top: 0.75rem;
gap: 1rem;
padding: 0.5rem 1rem;
width: 90%;
}
}

View File

@@ -13,7 +13,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
const saved = localStorage.getItem('theme') as Theme
if (saved) return saved
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
return 'dark'
})
useEffect(() => {

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 })
}
}

20
tsconfig.worker.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"paths": {
"@shared/*": ["./src/shared/*"]
},
"baseUrl": "."
},
"include": ["src/worker/**/*", "src/shared/**/*"]
}

9
wrangler.toml Normal file
View File

@@ -0,0 +1,9 @@
name = "latency-test"
main = "dist/worker/index.js"
compatibility_date = "2025-12-22"
compatibility_flags = ["nodejs_compat"]
assets = { directory = "./dist/client" }
[vars]
GLOBALPING_API = "https://api.globalping.io/v1"