commit 26bdf14af5ef0bd5952f8db53b4b02a20cfb9d96 Author: Илья Братских Date: Wed Jun 24 08:30:20 2026 +0300 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cc9c0c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +npm-debug.log* +node_trace.*.log +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..f070b8a --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# Test Net + +Self-hosted тест скорости сети на Node.js без внешних npm-зависимостей. +Приложение показывает download/upload скорость, HTTP-пинг до каждого сервера, +позволяет выбрать целевой сервер и переключать светлую/темную тему с акцентным +цветом. + +## Возможности + +- выбор сервера из общего `servers.json`; +- измерение HTTP-пинга от браузера клиента до каждого сервера; +- download/upload тесты через выбранный сервер; +- SVG-флаги стран в карточках серверов; +- автоматическое скачивание недостающих флагов в `flags/`; +- светлая и темная тема, пользовательский акцентный цвет; +- адаптивный canvas-график скорости с пиками и средними значениями; +- cluster-режим Node.js для использования всех CPU. + +## Запуск + +```bash +npm start +``` + +По умолчанию сервер слушает `http://localhost:3000`. + +Переменные окружения: + +```bash +PORT=3000 SERVER_ID=local WORKERS=4 npm start +``` + +- `PORT` - порт HTTP-сервера. +- `SERVER_ID` - id текущего сервера из `servers.json`. +- `WORKERS` - количество Node.js worker-процессов. Если не задано, используется + количество CPU. + +## Конфигурация серверов + +`servers.json` можно держать одинаковым и синхронизировать на всех серверах. +Текущий инстанс выбирается через `SERVER_ID`; если переменная не задана, +используется `currentServerId`. + +```json +{ + "currentServerId": "de-1", + "servers": [ + { + "id": "de-1", + "name": "Frankfurt", + "address": "https://de.example.com", + "country": "Германия", + "countryCode": "de" + }, + { + "id": "nl-1", + "name": "Amsterdam", + "address": "https://nl.example.com", + "country": "Нидерланды", + "countryCode": "nl" + } + ] +} +``` + +Важно: `address` должен быть доступен из браузера клиента. Для тестов между +разными доменами backend отправляет CORS-заголовки. + +## Флаги + +При старте primary-процесс читает `servers.json`, создает папку `flags/` и +скачивает недостающие SVG-флаги с `https://flagcdn.com/.svg`. +Если флаг уже есть локально, повторная загрузка не выполняется. + +## API + +- `GET /` - интерфейс теста. +- `GET /api/servers` - JSON со списком серверов и текущим сервером. +- `GET /ping` - легкий endpoint для измерения задержки из браузера. +- `GET /download` - бесконечный поток байтов для download-теста. +- `POST /upload` - принимает поток данных и возвращает количество байтов. +- `GET /flags/:code.svg` - локально сохраненный SVG-флаг. + +## Как работает измерение + +Клиент сначала загружает `/api/servers`, затем для каждого сервера делает +несколько запросов к `/ping` и показывает минимальное значение. Выбранный сервер +используется как base URL для `/download` и `/upload`. График строится в +браузере по текущим скоростям, которые обновляются каждые несколько сотен +миллисекунд. diff --git a/flags/.gitkeep b/flags/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/flags/.gitkeep @@ -0,0 +1 @@ + diff --git a/flags/un.svg b/flags/un.svg new file mode 100644 index 0000000..b81a88b --- /dev/null +++ b/flags/un.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..172459e --- /dev/null +++ b/index.html @@ -0,0 +1,1113 @@ + + + + + + Тест скорости сети + + + +
+
+
+

Измерение скорости сети

+

+ Выберите сервер, проверьте задержку до каждого узла и запустите + download/upload тест. Настройки темы и акцентного цвета сохраняются + в браузере. +

+
+ +
+ + +
+
+ +
+
+
+

Серверы

+ +
+
+

Загружаю список серверов...

+
+ + +
+ +
+
+
+

Скачивание

+ Пик: -- +
+
--
+
Мбит/с
+
+
Скачано: 0 Б
+
+
+ +
+
+

Отдача

+ Пик: -- +
+
--
+
Мбит/с
+
+
Отправлено: 0 Б
+
+
+
+ +
+
+

График скорости

+ Шкала: -- +
+
+ +
+
+
+ + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f99874a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,14 @@ +{ + "name": "test_net", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "test_net", + "version": "1.0.0", + "license": "ISC", + "type": "module" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7aaae9f --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "test_net", + "version": "1.0.0", + "description": "Self-hosted network speed test with multi-server selection.", + "type": "module", + "main": "server.js", + "scripts": { + "start": "node server.js", + "run": "node server.js" + }, + "keywords": [], + "author": "", + "license": "ISC" +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..a728466 --- /dev/null +++ b/server.js @@ -0,0 +1,277 @@ +// @ts-check +"use strict"; + +import cluster from "node:cluster"; +import { createWriteStream, existsSync, readFileSync } from "node:fs"; +import { mkdir, rm } from "node:fs/promises"; +import http from "node:http"; +import https from "node:https"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PORT = Number(process.env.PORT || 3000); +const CHUNK_SIZE = 64 * 1024; +const INDEX_PATH = path.join(__dirname, "index.html"); +const SERVERS_CONFIG_PATH = path.join(__dirname, "servers.json"); +const FLAGS_DIR = path.join(__dirname, "flags"); +const FLAG_API_URL = "https://flagcdn.com"; + +const serversConfig = loadServersConfig(); + +if (cluster.isPrimary) { + await ensureConfiguredFlags(serversConfig.servers); + + const workerCount = Number(process.env.WORKERS || os.cpus().length); + console.log(`Master ${process.pid} запущен, форкаем ${workerCount} воркеров`); + + for (let i = 0; i < workerCount; i += 1) cluster.fork(); + cluster.on("exit", (worker) => { + console.log(`Воркер ${worker.process.pid} упал, перезапуск`); + cluster.fork(); + }); +} else { + const server = http.createServer((req, res) => { + handleRequest(req, res); + }); + + server.listen(PORT, () => { + console.log(`Воркер ${process.pid} слушает порт ${PORT}`); + }); +} + +/** + * Загружает общий список серверов. Один и тот же файл можно синхронизировать на + * все инстансы, а текущий сервер выбирать через SERVER_ID. + */ +function loadServersConfig() { + try { + const raw = readFileSync(SERVERS_CONFIG_PATH, "utf8"); + const config = JSON.parse(raw); + if (!Array.isArray(config.servers) || config.servers.length === 0) { + throw new Error("servers must be a non-empty array"); + } + return config; + } catch (error) { + console.warn(`Не удалось прочитать servers.json: ${error.message}`); + return { + currentServerId: "local", + servers: [ + { + id: "local", + name: "Localhost", + address: `http://localhost:${PORT}`, + country: "Local", + countryCode: "un", + }, + ], + }; + } +} + +/** + * Скачивает недостающие SVG-флаги при старте. Если API недоступен, сервер + * продолжит работу, а карточка покажет текстовый код страны. + */ +async function ensureConfiguredFlags(servers) { + await mkdir(FLAGS_DIR, { recursive: true }); + + await Promise.all( + servers + .filter((server) => server.countryCode) + .map(async (server) => { + const code = String(server.countryCode).toLowerCase(); + const targetPath = path.join(FLAGS_DIR, `${code}.svg`); + if (existsSync(targetPath)) return; + + try { + await downloadFile(`${FLAG_API_URL}/${code}.svg`, targetPath); + console.log(`Флаг ${code.toUpperCase()} сохранен в flags/`); + } catch (error) { + console.warn(`Не удалось скачать флаг ${code}: ${error.message}`); + } + }), + ); +} + +function downloadFile(url, targetPath) { + return new Promise((resolve, reject) => { + const file = createWriteStream(targetPath); + https + .get(url, (response) => { + if (response.statusCode !== 200) { + file.close(); + rm(targetPath, { force: true }).finally(() => {}); + reject(new Error(`HTTP ${response.statusCode}`)); + return; + } + response.pipe(file); + file.on("finish", () => { + file.close(resolve); + }); + }) + .on("error", (error) => { + file.close(); + rm(targetPath, { force: true }).finally(() => {}); + reject(error); + }); + }); +} + +function handleRequest(req, res) { + const requestUrl = new URL(req.url || "/", `http://${req.headers.host}`); + + setCorsHeaders(res); + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + + if (requestUrl.pathname === "/" && req.method === "GET") { + sendHtml(res); + return; + } + + if (requestUrl.pathname === "/api/servers" && req.method === "GET") { + sendJson(res, buildServersPayload(req)); + return; + } + + if (requestUrl.pathname === "/ping" && req.method === "GET") { + sendJson(res, { + ok: true, + serverId: getCurrentServer(req).id, + now: Date.now(), + }); + return; + } + + if (requestUrl.pathname.startsWith("/flags/") && req.method === "GET") { + sendFlag(requestUrl.pathname, res); + return; + } + + if (requestUrl.pathname === "/download" && req.method === "GET") { + handleDownload(req, res); + return; + } + + if (requestUrl.pathname === "/upload" && req.method === "POST") { + handleUpload(req, res); + return; + } + + res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("Not found"); +} + +function setCorsHeaders(res) { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Cache-Control"); +} + +function sendHtml(res) { + const indexHtml = readFileSync(INDEX_PATH); + res.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + }); + res.end(indexHtml); +} + +function sendJson(res, data) { + res.writeHead(200, { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + }); + res.end(JSON.stringify(data)); +} + +function sendFlag(pathname, res) { + const fileName = path.basename(pathname); + const flagPath = path.join(FLAGS_DIR, fileName); + + if (!fileName.endsWith(".svg") || !existsSync(flagPath)) { + res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("Flag not found"); + return; + } + + res.writeHead(200, { + "Content-Type": "image/svg+xml; charset=utf-8", + "Cache-Control": "public, max-age=86400", + }); + res.end(readFileSync(flagPath)); +} + +function handleDownload(req, res) { + res.writeHead(200, { + "Content-Type": "application/octet-stream", + "Cache-Control": "no-store", + }); + + const chunk = Buffer.alloc(CHUNK_SIZE); + let finished = false; + + function writeChunk() { + if (finished) return; + + while (true) { + const ok = res.write(chunk); + if (!ok) { + res.once("drain", writeChunk); + return; + } + } + } + + writeChunk(); + req.on("close", () => { + finished = true; + res.end(); + }); +} + +function handleUpload(req, res) { + let totalBytes = 0; + req.on("data", (chunk) => { + totalBytes += chunk.length; + }); + req.on("end", () => { + res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }); + res.end(String(totalBytes)); + }); +} + +function buildServersPayload(req) { + const current = getCurrentServer(req); + return { + current, + servers: serversConfig.servers.map((server) => ({ + ...server, + flagUrl: server.countryCode + ? `/flags/${String(server.countryCode).toLowerCase()}.svg` + : null, + })), + }; +} + +function getCurrentServer(req) { + const envServerId = process.env.SERVER_ID || serversConfig.currentServerId; + const byEnv = serversConfig.servers.find((server) => server.id === envServerId); + if (byEnv) return byEnv; + + const requestHost = req.headers.host; + const byHost = serversConfig.servers.find((server) => { + try { + return new URL(server.address).host === requestHost; + } catch { + return false; + } + }); + + return byHost || serversConfig.servers[0]; +} diff --git a/servers.json b/servers.json new file mode 100644 index 0000000..f923b3e --- /dev/null +++ b/servers.json @@ -0,0 +1,12 @@ +{ + "currentServerId": "local", + "servers": [ + { + "id": "local", + "name": "Localhost", + "address": "http://localhost:3000", + "country": "Локальный сервер", + "countryCode": "un" + } + ] +}