test-net/server.js

279 lines
7.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @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 FLAG_API_URL = "https://flagicons.lipis.dev/flags/4x3";
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];
}