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