first commit

This commit is contained in:
Илья Братских 2026-06-24 08:30:20 +03:00 committed by KBAHTNET
commit 26bdf14af5
9 changed files with 1526 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
npm-debug.log*
node_trace.*.log
.env

90
README.md Normal file
View File

@ -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/<countryCode>.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`. График строится в
браузере по текущим скоростям, которые обновляются каждые несколько сотен
миллисекунд.

1
flags/.gitkeep Normal file
View File

@ -0,0 +1 @@

1
flags/un.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 33 KiB

1113
index.html Normal file

File diff suppressed because it is too large Load Diff

14
package-lock.json generated Normal file
View File

@ -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"
}
}
}

14
package.json Normal file
View File

@ -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"
}

277
server.js Normal file
View File

@ -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];
}

12
servers.json Normal file
View File

@ -0,0 +1,12 @@
{
"currentServerId": "local",
"servers": [
{
"id": "local",
"name": "Localhost",
"address": "http://localhost:3000",
"country": "Локальный сервер",
"countryCode": "un"
}
]
}