278 lines
7.5 KiB
JavaScript
278 lines
7.5 KiB
JavaScript
// @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];
|
||
}
|