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