"use client"; import { useState, useRef, useEffect } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { Activity, Download, Upload, Server, RefreshCw, Zap, } from "lucide-react"; import { CyberButton } from "../ui/cyber-button"; import { cn } from "@/lib/utils"; import { Dictionary } from "@/lib/IDict"; type TestStatus = | "idle" | "pinging" | "downloading" | "uploading" | "done" | "error"; interface SpeedtestClientProps { dict: Dictionary["speedtest"]; } // Мегабит везде считаем в десятичном виде (1 Мбит = 1_000_000 бит), как это // делают провайдеры и остальные спидтесты (Ookla, fast.com) — иначе цифра на // экране будет тише реальной на ~5% при том же подписанном "Мбит/с". const MEGABIT = 1_000_000; const bytesToMbps = (bytes: number, seconds: number) => seconds > 0 ? (bytes * 8) / MEGABIT / seconds : 0; const median = (values: number[]) => { const sorted = [...values].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; }; /** * Прогревочный + N таймированных запросов на /ping. Первый запрос выкидываем * из выборки — на нём поднимается TCP/TLS-соединение, и его RTT в 3-5 раз * больше "боевого", что раздувает среднее. Из таймированных сэмплов берём * медиану, а не среднее — она устойчива к единичным всплескам (например, * фоновая задача ОС притормозила один из запросов). */ async function measurePing( baseUrl: string, signal: AbortSignal, ): Promise { try { await fetch(`${baseUrl}/api/speedtest/ping?nc=${Date.now()}`, { cache: "no-store", signal, }); } catch (e: any) { if (e?.name === "AbortError") throw e; } const SAMPLES = 8; const samples: number[] = []; for (let i = 0; i < SAMPLES; i++) { const start = performance.now(); try { await fetch(`${baseUrl}/api/speedtest/ping?nc=${Date.now()}-${i}`, { cache: "no-store", signal, }); samples.push(performance.now() - start); } catch (e: any) { if (e?.name === "AbortError") throw e; } } if (samples.length === 0) throw new Error("Ping failed"); return Math.round(median(samples)); } /** * Скачивание несколькими параллельными потоками в течение фиксированного * окна времени (а не фиксированного объёма!). Один TCP-поток не насыщает * канал на быстрых/дальних соединениях (окно перегрузки не успевает * разогнаться) — поэтому качаем сразу несколько потоков одновременно, и как * только один исчерпал свой блоб — тут же открываем новый, не давая "трубе" * простаивать. Первую секунду (WARMUP_MS) не учитываем в расчёте — это * TCP slow-start, а не установившаяся скорость канала. */ async function measureDownload( baseUrl: string, outerSignal: AbortSignal, onProgress: (mbps: number) => void, ): Promise { const STREAMS = 4; const WARMUP_MS = 1000; const DURATION_MS = 8000; const controller = new AbortController(); const onAbort = () => controller.abort(); outerSignal.addEventListener("abort", onAbort); const testStart = performance.now(); const deadline = testStart + WARMUP_MS + DURATION_MS; let totalBytes = 0; let bytesAtWarmupEnd: number | null = null; const runStream = async () => { while (performance.now() < deadline) { let res: Response; try { res = await fetch( `${baseUrl}/api/speedtest/download?nc=${Date.now()}-${Math.random()}`, { cache: "no-store", signal: controller.signal }, ); } catch (e: any) { if (e?.name === "AbortError") return; throw e; } const reader = res.body?.getReader(); if (!reader) return; while (true) { const { done, value } = await reader.read(); if (value) totalBytes += value.length; if (done) break; if (performance.now() >= deadline) { await reader.cancel().catch(() => {}); return; } } } }; const warmupTimer = setTimeout(() => { bytesAtWarmupEnd = totalBytes; }, WARMUP_MS); let lastBytes = 0; let lastTime = testStart; const progressTimer = setInterval(() => { if (bytesAtWarmupEnd === null) return; const now = performance.now(); const dBytes = totalBytes - lastBytes; const dTime = (now - lastTime) / 1000; lastBytes = totalBytes; lastTime = now; onProgress(bytesToMbps(dBytes, dTime)); }, 250); try { await Promise.all(Array.from({ length: STREAMS }, runStream)); } finally { clearTimeout(warmupTimer); clearInterval(progressTimer); outerSignal.removeEventListener("abort", onAbort); } const measuredBytes = totalBytes - (bytesAtWarmupEnd ?? totalBytes); const measuredSeconds = (performance.now() - (testStart + WARMUP_MS)) / 1000; return bytesToMbps(measuredBytes, measuredSeconds); } const MAX_RANDOM_CHUNK = 65536; // лимит crypto.getRandomValues() за один вызов function makeRandomBytes(size: number): Uint8Array { const buf = new Uint8Array(size); for (let offset = 0; offset < size; offset += MAX_RANDOM_CHUNK) { crypto.getRandomValues( buf.subarray(offset, Math.min(offset + MAX_RANDOM_CHUNK, size)), ); } return buf; } /** * Отдача — тем же приёмом, что и скачивание: несколько параллельных потоков, * фиксированное окно времени, первые полсекунды не считаем. У fetch() нет * события прогресса отправки, поэтому вместо одного гигантского запроса шлём * последовательность отдельных чанков — так время оборота каждого запроса * меньше влияет на итоговую точность, и мы можем адаптировать размер чанка * под скорость канала (AIMD: чанк удался в срок — увеличиваем вдвое, * не уложились в таймаут — уменьшаем вдвое), вместо того чтобы гадать с * одним фиксированным размером на все каналы, от мобильного 3G до гигабита. * Каждый чанк — свежие случайные байты (а не повторяющийся блок), чтобы * сервер или прокси на пути не мог его сжать и исказить измерение. */ async function measureUpload( baseUrl: string, outerSignal: AbortSignal, onProgress: (mbps: number) => void, ): Promise { const STREAMS = 3; const WARMUP_MS = 500; const DURATION_MS = 6000; const MIN_CHUNK = 256 * 1024; const MAX_CHUNK = 4 * 1024 * 1024; const REQUEST_TIMEOUT_MS = 4000; const controller = new AbortController(); const onAbort = () => controller.abort(); outerSignal.addEventListener("abort", onAbort); const testStart = performance.now(); const deadline = testStart + WARMUP_MS + DURATION_MS; let totalBytes = 0; let bytesAtWarmupEnd: number | null = null; const runStream = async () => { let chunkSize = MIN_CHUNK; while (performance.now() < deadline) { const payload = makeRandomBytes(chunkSize); const reqController = new AbortController(); const onOuterAbort = () => reqController.abort(); controller.signal.addEventListener("abort", onOuterAbort); const timeoutId = setTimeout( () => reqController.abort(), REQUEST_TIMEOUT_MS, ); try { await fetch( `${baseUrl}/api/speedtest/upload?nc=${Date.now()}-${Math.random()}`, { method: "POST", body: payload, signal: reqController.signal, headers: { "Content-Type": "application/octet-stream" }, }, ); totalBytes += payload.length; chunkSize = Math.min(chunkSize * 2, MAX_CHUNK); } catch (e: any) { if (controller.signal.aborted) return; chunkSize = Math.max(Math.floor(chunkSize / 2), MIN_CHUNK); } finally { clearTimeout(timeoutId); controller.signal.removeEventListener("abort", onOuterAbort); } } }; const warmupTimer = setTimeout(() => { bytesAtWarmupEnd = totalBytes; }, WARMUP_MS); let lastBytes = 0; let lastTime = testStart; const progressTimer = setInterval(() => { if (bytesAtWarmupEnd === null) return; const now = performance.now(); const dBytes = totalBytes - lastBytes; const dTime = (now - lastTime) / 1000; lastBytes = totalBytes; lastTime = now; onProgress(bytesToMbps(dBytes, dTime)); }, 250); try { await Promise.all(Array.from({ length: STREAMS }, runStream)); } finally { clearTimeout(warmupTimer); clearInterval(progressTimer); outerSignal.removeEventListener("abort", onAbort); } const measuredBytes = totalBytes - (bytesAtWarmupEnd ?? totalBytes); const measuredSeconds = (performance.now() - (testStart + WARMUP_MS)) / 1000; return bytesToMbps(measuredBytes, measuredSeconds); } /** * Виджет спидтеста: пинг + скорость скачивания/отдачи через Rust-шлюз proxy-ws * (эндпоинты /api/speedtest/ping|download|upload, см. next.config.ts rewrites). * * В dev-режиме (NODE_ENV !== "production") реальные запросы не делаются — вместо * этого крутится runMockTest() с рандомными числами и искусственными задержками, * чтобы можно было верстать/проверять анимацию без поднятого локального proxy-ws. * В проде runRealTest() измеряет параллельными потоками в течение фиксированного * окна времени (см. measurePing/measureDownload/measureUpload выше) — так же, * как это делают Ookla/fast.com/Cloudflare speedtest, а не одним запросом в один * TCP-поток, который на быстрых каналах не успевает разогнаться и занижает результат. */ export function SpeedtestClient({ dict }: SpeedtestClientProps) { const [status, setStatus] = useState("idle"); const [metrics, setMetrics] = useState({ ping: 0, download: 0.0, upload: 0.0, }); const abortControllerRef = useRef(null); const getProxyUrl = () => { return process.env.NODE_ENV === "production" ? "/api/proxy" : "http://localhost:3001"; }; const runMockTest = async () => { const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)); setStatus("pinging"); setMetrics({ ping: 0, download: 0, upload: 0 }); await sleep(800); setMetrics((m) => ({ ...m, ping: Math.floor(Math.random() * 15) + 12 })); setStatus("downloading"); for (let i = 1; i <= 20; i++) { await sleep(150); setMetrics((m) => ({ ...m, download: +(15.5 * i + Math.random() * 10).toFixed(2), })); } setStatus("uploading"); for (let i = 1; i <= 20; i++) { await sleep(150); setMetrics((m) => ({ ...m, upload: +(8.2 * i + Math.random() * 5).toFixed(2), })); } setStatus("done"); }; const runRealTest = async () => { const baseUrl = getProxyUrl(); setStatus("pinging"); setMetrics({ ping: 0, download: 0, upload: 0 }); const controller = new AbortController(); abortControllerRef.current = controller; const signal = controller.signal; try { const ping = await measurePing(baseUrl, signal); setMetrics((m) => ({ ...m, ping })); setStatus("downloading"); const download = await measureDownload(baseUrl, signal, (mbps) => { setMetrics((m) => ({ ...m, download: Number(mbps.toFixed(2)) })); }); setMetrics((m) => ({ ...m, download: Number(download.toFixed(2)) })); setStatus("uploading"); const upload = await measureUpload(baseUrl, signal, (mbps) => { setMetrics((m) => ({ ...m, upload: Number(mbps.toFixed(2)) })); }); setMetrics((m) => ({ ...m, upload: Number(upload.toFixed(2)) })); setStatus("done"); } catch (err: any) { if (err?.name !== "AbortError") console.error("Speedtest error:", err); setStatus("error"); } finally { abortControllerRef.current = null; } }; const runTest = () => { if (process.env.NODE_ENV !== "production") { runMockTest(); } else { runRealTest(); } }; // Если пользователь уходит со страницы посреди теста — гасим все ещё // летящие запросы, а не оставляем их фоном дожёвывать сеть впустую. useEffect(() => { return () => abortControllerRef.current?.abort(); }, []); const isRunning = status !== "idle" && status !== "done" && status !== "error"; const getStatusLabel = () => { switch (status) { case "pinging": return dict.statusPinging; case "downloading": return dict.statusDownloading; case "uploading": return dict.statusUploading; default: return status.toUpperCase(); } }; return (
{isRunning && ( )}

{dict.title}

{process.env.NODE_ENV !== "production" ? ( {dict.mockMode} ) : ( dict.prodMode )}

{!isRunning ? ( {" "} {status === "error" ? dict.btnError : status === "done" ? dict.btnDone : dict.btnIdle} ) : ( {getStatusLabel()} ... )}
} isActive={status === "pinging"} isDone={ status === "downloading" || status === "uploading" || status === "done" } color="text-secondary" glowColor="shadow-secondary/20" /> } isActive={status === "downloading"} isDone={status === "uploading" || status === "done"} color="text-green-500" glowColor="shadow-green-500/20" /> } isActive={status === "uploading"} isDone={status === "done"} color="text-primary" glowColor="shadow-primary/20" />
); } function MetricCard({ title, value, unit, icon, isActive, isDone, color, glowColor, }: any) { return ( {isActive && ( )}
{icon} {title} {isActive && ( )}
{value === 0 && !isDone && !isActive ? "--" : value}{" "} {unit}
); }