first commit
This commit is contained in:
commit
26bdf14af5
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
node_trace.*.log
|
||||||
|
.env
|
||||||
90
README.md
Normal file
90
README.md
Normal 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
1
flags/.gitkeep
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
flags/un.svg
Normal file
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
1113
index.html
Normal file
File diff suppressed because it is too large
Load Diff
14
package-lock.json
generated
Normal file
14
package-lock.json
generated
Normal 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
14
package.json
Normal 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
277
server.js
Normal 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
12
servers.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"currentServerId": "local",
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"id": "local",
|
||||||
|
"name": "Localhost",
|
||||||
|
"address": "http://localhost:3000",
|
||||||
|
"country": "Локальный сервер",
|
||||||
|
"countryCode": "un"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user