"use strict";
/**
* HuggingMes + Hermes WebUI — single-port router on HF Space port 7861.
*
* Routes:
* /login -> HuggingMes login (password = GATEWAY_TOKEN)
* /health /status -> JSON health (unauthenticated — for HF probes + keepalive)
* /hm /hm/* -> HuggingMes status page + app (auth-gated)
* /dashboard -> redirect to /hm
* /v1 /v1/* -> Hermes gateway (bearer auth; HTML => login redirect)
* /telegram /telegram/*-> Telegram webhook (unauthenticated; Telegram needs to reach it)
* everything else -> Hermes WebUI (nesquena/hermes-webui) as the primary UI
* WebUI handles its own login at /login-... no, wait: WebUI
* also exposes /login. We keep HuggingMes' login at /login
* so the shared GATEWAY_TOKEN gates both.
*
* Based on github.com/somratpro/HuggingMes with added WebUI routing as the
* primary UI.
*/
const http = require("http");
const fs = require("fs");
const net = require("net");
const crypto = require("crypto");
const PORT = Number(process.env.PORT || 7861);
const GATEWAY_PORT = Number(process.env.API_SERVER_PORT || 8642);
const DASHBOARD_PORT = Number(process.env.DASHBOARD_PORT || 9119);
const TELEGRAM_WEBHOOK_PORT = Number(process.env.TELEGRAM_WEBHOOK_PORT || 8765);
const WEBUI_PORT = Number(process.env.HERMES_WEBUI_PORT || 8787);
const GATEWAY_HOST = "127.0.0.1";
const startTime = Date.now();
const API_SERVER_KEY = process.env.API_SERVER_KEY || "";
const HM_PREFIX = "/hm";
const LOGIN_PATH = "/hm/login";
const SESSION_COOKIE = "huggingmes_session";
const PRIMARY_UI = (process.env.PRIMARY_UI || "webui").toLowerCase();
const SYNC_STATUS_FILE = "/tmp/huggingmes-sync-status.json";
const CLOUDFLARE_KEEPALIVE_STATUS_FILE =
"/tmp/huggingmes-cloudflare-keepalive-status.json";
/* ── Port probing + auth ──────────────────────────────────────────── */
function canConnect(port, host = GATEWAY_HOST, timeoutMs = 600) {
return new Promise((resolve) => {
const socket = net.createConnection({ port, host });
const done = (ok) => {
socket.removeAllListeners();
socket.destroy();
resolve(ok);
};
socket.setTimeout(timeoutMs);
socket.once("connect", () => done(true));
socket.once("timeout", () => done(false));
socket.once("error", () => done(false));
});
}
function readJson(path, fallback = null) {
try {
if (fs.existsSync(path)) return JSON.parse(fs.readFileSync(path, "utf8"));
} catch {}
return fallback;
}
function timingSafeEqualString(left, right) {
if (!left || !right) return false;
const a = Buffer.from(left);
const b = Buffer.from(right);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
function expectedSessionValue() {
if (!API_SERVER_KEY) return "";
return crypto
.createHmac("sha256", API_SERVER_KEY)
.update("huggingmes-session-v1")
.digest("hex");
}
function parseCookies(req) {
const header = req.headers.cookie || "";
const cookies = {};
for (const item of header.split(";")) {
const sep = item.indexOf("=");
if (sep < 0) continue;
const name = item.slice(0, sep).trim();
const value = item.slice(sep + 1).trim();
if (!name) continue;
try {
cookies[name] = decodeURIComponent(value);
} catch {
cookies[name] = value;
}
}
return cookies;
}
function isHttpsRequest(req) {
return req.headers["x-forwarded-proto"] === "https";
}
function buildSessionCookie(req) {
const secure = isHttpsRequest(req) ? "; Secure" : "";
return `${SESSION_COOKIE}=${encodeURIComponent(expectedSessionValue())}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400${secure}`;
}
function getBearerToken(req) {
const value = req.headers.authorization || "";
const match = /^Bearer\s+(.+)$/i.exec(value);
return match ? match[1] : "";
}
function isAuthorized(req) {
if (!API_SERVER_KEY) return true;
return (
timingSafeEqualString(getBearerToken(req), API_SERVER_KEY) ||
timingSafeEqualString(
parseCookies(req)[SESSION_COOKIE],
expectedSessionValue(),
)
);
}
function sanitizeNext(value, fallback = "/") {
if (!value || typeof value !== "string") return fallback;
if (!value.startsWith("/") || value.startsWith("//")) return fallback;
return value;
}
function loginUrl(nextPath) {
return `${LOGIN_PATH}?next=${encodeURIComponent(sanitizeNext(nextPath))}`;
}
function wantsHtml(req) {
const accept = String(req.headers.accept || "");
return accept.includes("text/html");
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
function readRequestBody(req, limit = 64 * 1024) {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk) => {
body += chunk;
if (body.length > limit) {
reject(new Error("Request body is too large."));
req.destroy();
}
});
req.on("end", () => resolve(body));
req.on("error", reject);
});
}
/* ── Login page ───────────────────────────────────────────────────── */
function renderLoginPage(nextPath, errorMessage = "") {
const safeNext = sanitizeNext(nextPath, "/");
const errorHtml = errorMessage
? `
${escapeHtml(errorMessage)}
`
: "";
return `
HuggingMes + Hermes WebUI — Login
HuggingMes Admin
Enter the GATEWAY_TOKEN from your Space secrets to access the status dashboard.
For the Hermes chat UI, go to /.
${errorHtml}
`;
}
async function handleLogin(req, res, parsed) {
const nextPath = sanitizeNext(parsed.searchParams.get("next") || "/", "/");
if (!API_SERVER_KEY) {
redirect(res, nextPath);
return;
}
if (req.method === "GET") {
res.writeHead(200, {
"content-type": "text/html; charset=utf-8",
"cache-control": "no-store",
});
res.end(renderLoginPage(nextPath));
return;
}
if (req.method !== "POST") {
res.writeHead(405, { allow: "GET, POST" });
res.end("Method not allowed");
return;
}
try {
const body = await readRequestBody(req);
const params = new URLSearchParams(body);
const submittedToken = params.get("token") || "";
const submittedNext = sanitizeNext(params.get("next") || nextPath, "/");
if (!timingSafeEqualString(submittedToken, API_SERVER_KEY)) {
res.writeHead(401, {
"content-type": "text/html; charset=utf-8",
"cache-control": "no-store",
});
res.end(
renderLoginPage(
submittedNext,
"That token did not match GATEWAY_TOKEN.",
),
);
return;
}
res.writeHead(302, {
location: submittedNext,
"set-cookie": buildSessionCookie(req),
"cache-control": "no-store",
});
res.end();
} catch (error) {
res.writeHead(400, {
"content-type": "text/plain; charset=utf-8",
"cache-control": "no-store",
});
res.end(error.message || "Invalid login request.");
}
}
function requireAuth(req, res) {
if (isAuthorized(req)) return true;
const parsed = new URL(req.url, "http://localhost");
redirect(res, loginUrl(`${parsed.pathname}${parsed.search}`));
return false;
}
/* ── Upstream proxy ────────────────────────────────────────────────── */
function proxyRequest(
req,
res,
targetPort,
rewritePath = (path) => path,
headerOverrides = {},
) {
const parsed = new URL(req.url, "http://localhost");
const targetPath = rewritePath(parsed.pathname) + parsed.search;
const headers = {
...req.headers,
...headerOverrides,
host: `${GATEWAY_HOST}:${targetPort}`,
"x-forwarded-host": req.headers.host || "",
"x-forwarded-proto": req.headers["x-forwarded-proto"] || "https",
};
const proxy = http.request(
{
hostname: GATEWAY_HOST,
port: targetPort,
method: req.method,
path: targetPath,
headers,
},
(upstream) => {
res.writeHead(upstream.statusCode || 502, upstream.headers);
upstream.pipe(res);
},
);
proxy.on("error", (error) => {
res.writeHead(502, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "proxy_error", message: error.message }));
});
req.pipe(proxy);
}
function redirect(res, location, statusCode = 302) {
res.writeHead(statusCode, { location });
res.end();
}
/* ── Dashboard SPA proxy with HTML rewriting ──────────────────────────
*
* The Hermes dashboard is a Vite React app built for root-path deployment.
* Its HTML hardcodes window.__HERMES_BASE_PATH__="" and absolute src/href
* paths like /assets/index-XXX.js. Under /hm/app, React's router wouldn't
* know its basename and client-side routes (/config, /sessions, etc.) 404
* on refresh.
*
* This proxy:
* - serves the dashboard's index.html for any non-asset /hm/app/* path
* (SPA fallback, so /config, /profiles etc. work on direct load)
* - rewrites the returned HTML so React router uses /hm/app as its
* basename and absolute asset paths get prefixed with /hm/app
*/
function proxyDashboard(req, res) {
const parsed = new URL(req.url, "http://localhost");
const inner = parsed.pathname.replace(`${HM_PREFIX}/app`, "") || "/";
const isAssetLike =
inner.startsWith("/assets/") ||
inner.startsWith("/api/") ||
inner.startsWith("/dashboard-plugins/") ||
inner.startsWith("/ds-assets/") ||
/\.[a-z0-9]{1,6}$/i.test(inner);
// SPA routes → serve index.html; everything else → forward as-is.
const targetPath =
(isAssetLike || inner === "/" ? inner : "/") + parsed.search;
const headers = {
...req.headers,
host: `${GATEWAY_HOST}:${DASHBOARD_PORT}`,
"x-forwarded-host": req.headers.host || "",
"x-forwarded-proto": req.headers["x-forwarded-proto"] || "https",
// Disable upstream compression so we can rewrite text responses.
"accept-encoding": "identity",
};
const upstream = http.request(
{
hostname: GATEWAY_HOST,
port: DASHBOARD_PORT,
method: req.method,
path: targetPath,
headers,
},
(upRes) => {
const contentType = String(upRes.headers["content-type"] || "");
const shouldRewrite =
contentType.includes("text/html") ||
contentType.includes("application/xhtml");
if (!shouldRewrite) {
res.writeHead(upRes.statusCode || 502, upRes.headers);
upRes.pipe(res);
return;
}
const chunks = [];
upRes.on("data", (chunk) => chunks.push(chunk));
upRes.on("end", () => {
let body = Buffer.concat(chunks).toString("utf8");
// Tell the React router its basename.
body = body.replace(
/window\.__HERMES_BASE_PATH__\s*=\s*"[^"]*"/g,
`window.__HERMES_BASE_PATH__="${HM_PREFIX}/app"`,
);
// Prefix absolute asset URLs so they stay under /hm/app.
const prefix = `${HM_PREFIX}/app`;
body = body.replace(
/\b(src|href)="\/(?!\/|http)([^"]*)"/g,
(match, attr, rest) => {
if (
("/" + rest).startsWith(prefix + "/") ||
"/" + rest === prefix
) {
return match;
}
return `${attr}="${prefix}/${rest}"`;
},
);
const buf = Buffer.from(body, "utf8");
const outHeaders = { ...upRes.headers };
delete outHeaders["content-length"];
delete outHeaders["transfer-encoding"];
delete outHeaders["content-encoding"];
outHeaders["content-length"] = String(buf.length);
res.writeHead(upRes.statusCode || 502, outHeaders);
res.end(buf);
});
upRes.on("error", () => {
try {
res.writeHead(502);
res.end();
} catch {}
});
},
);
upstream.on("error", (error) => {
res.writeHead(502, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "proxy_error", message: error.message }));
});
req.pipe(upstream);
}
/* ── Status JSON + HuggingMes status page ─────────────────────────── */
function formatUptime(ms) {
const total = Math.floor(ms / 1000);
const days = Math.floor(total / 86400);
const hours = Math.floor((total % 86400) / 3600);
const minutes = Math.floor((total % 3600) / 60);
if (days) return `${days}d ${hours}h ${minutes}m`;
if (hours) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
async function statusPayload() {
const gateway = await canConnect(GATEWAY_PORT);
const dashboard = await canConnect(DASHBOARD_PORT);
const webui = await canConnect(WEBUI_PORT);
const telegramWebhook =
!!process.env.TELEGRAM_WEBHOOK_URL &&
(await canConnect(TELEGRAM_WEBHOOK_PORT));
const sync = readJson(
SYNC_STATUS_FILE,
process.env.HF_TOKEN
? { status: "configured", message: "Backup enabled; waiting for first sync." }
: { status: "disabled", message: "HF_TOKEN is not configured." },
);
return {
ok: gateway && webui,
uptime: formatUptime(Date.now() - startTime),
startedAt: new Date(startTime).toISOString(),
gateway,
dashboard,
webui,
authConfigured: !!API_SERVER_KEY,
primaryUi: PRIMARY_UI,
ports: {
public: PORT,
gateway: GATEWAY_PORT,
dashboard: DASHBOARD_PORT,
webui: WEBUI_PORT,
telegramWebhook: TELEGRAM_WEBHOOK_PORT,
},
telegram: {
configured: !!process.env.TELEGRAM_BOT_TOKEN,
webhook: !!process.env.TELEGRAM_WEBHOOK_URL,
webhookUrl: process.env.TELEGRAM_WEBHOOK_URL || "",
webhookListening: telegramWebhook,
proxy: process.env.CLOUDFLARE_PROXY_URL || "",
},
model:
process.env.MODEL_FOR_CONFIG ||
process.env.HERMES_MODEL ||
process.env.LLM_MODEL ||
"",
provider:
process.env.PROVIDER_FOR_CONFIG ||
process.env.HERMES_INFERENCE_PROVIDER ||
"auto",
backup: sync,
keepalive: readJson(CLOUDFLARE_KEEPALIVE_STATUS_FILE, null),
};
}
function toneBadge(label, tone = "neutral") {
return `${escapeHtml(label)}`;
}
function valueOrUnset(value, fallback = "Not set") {
return value
? escapeHtml(value)
: `${escapeHtml(fallback)}`;
}
function renderTile({ title, value, detail = "", tone = "neutral", meta = "" }) {
return `
${escapeHtml(title)}
${value}
${detail ? `${detail}
` : ""}
${meta ? `${meta}
` : ""}
`;
}
function renderStatusPage(data) {
const syncStatus = String(data.backup?.status || "unknown");
const syncTone = ["success", "restored", "synced", "configured"].includes(syncStatus)
? "ok"
: syncStatus === "disabled"
? "warn"
: "neutral";
const telegramTone = data.telegram.configured
? data.telegram.webhookListening || !data.telegram.webhook
? "ok"
: "warn"
: "warn";
const keepaliveConfigured = data.keepalive?.configured === true;
const keepaliveStatus = String(
data.keepalive?.status ||
(process.env.CLOUDFLARE_WORKERS_TOKEN ? "pending" : "not configured"),
);
const keepAliveTone = keepaliveConfigured
? "ok"
: process.env.CLOUDFLARE_WORKERS_TOKEN
? "warn"
: "neutral";
const telegramDetail = data.telegram.configured
? `${data.telegram.webhook ? "Webhook" : "Polling"}${data.telegram.proxy ? " via CF proxy" : ""}`
: "Not configured";
const backupDetail = data.backup?.message
? escapeHtml(data.backup.message)
: "No status yet";
const keepAliveDetail = keepaliveConfigured
? `Pinging ${escapeHtml(data.keepalive.targetUrl || "/health")}`
: keepaliveStatus === "error" && data.keepalive?.message
? escapeHtml(data.keepalive.message)
: process.env.CLOUDFLARE_WORKERS_TOKEN
? "Worker pending or failed"
: "Not configured";
const tiles = [
renderTile({
title: "WebUI",
value: toneBadge(data.webui ? "Online" : "Offline", data.webui ? "ok" : "off"),
detail: data.webui ? `Port ${data.ports.webui}` : "Unreachable",
tone: data.webui ? "ok" : "off",
}),
renderTile({
title: "Gateway",
value: toneBadge(data.gateway ? "Online" : "Offline", data.gateway ? "ok" : "off"),
detail: data.gateway ? `API on port ${data.ports.gateway}` : "Unreachable",
tone: data.gateway ? "ok" : "off",
meta: data.authConfigured ? "Protected" : "Unprotected",
}),
renderTile({
title: "Model",
value: `${valueOrUnset(data.model)}`,
detail: `Provider: ${valueOrUnset(data.provider || "auto")}`,
tone: data.model ? "ok" : "warn",
}),
renderTile({
title: "Runtime",
value: escapeHtml(data.uptime),
detail: `Port ${data.ports.public}`,
tone: "neutral",
}),
renderTile({
title: "Telegram",
value: toneBadge(data.telegram.configured ? "Configured" : "Disabled", telegramTone),
detail: telegramDetail,
tone: telegramTone,
}),
renderTile({
title: "Backup",
value: toneBadge(syncStatus.toUpperCase(), syncTone),
detail: backupDetail,
tone: syncTone,
meta: data.backup?.timestamp
? ``
: "",
}),
renderTile({
title: "Keep Awake",
value: toneBadge(
keepaliveConfigured ? "CF Cron" : keepaliveStatus.toUpperCase(),
keepAliveTone,
),
detail: keepAliveDetail,
tone: keepAliveTone,
}),
].join("");
return `
HuggingMes + Hermes WebUI
`;
}
/* ── Server ───────────────────────────────────────────────────────── */
const server = http.createServer(async (req, res) => {
const parsed = new URL(req.url, "http://localhost");
const path = parsed.pathname;
// 1. /hm/login — HuggingMes admin login (cookie-based, gates /hm/*).
// hermes-webui handles its own /login at the catch-all below.
if (path === LOGIN_PATH) {
await handleLogin(req, res, parsed);
return;
}
// 2. /health — unauthenticated; HF Spaces probes + Cloudflare keepalive.
if (path === "/health") {
const data = await statusPayload();
res.writeHead(data.ok ? 200 : 503, { "content-type": "application/json" });
res.end(
JSON.stringify({
ok: data.ok,
gateway: data.gateway,
webui: data.webui,
uptime: data.uptime,
}),
);
return;
}
// 3. /status — unauthenticated JSON status dump.
if (path === "/status" || path === "/api/status") {
const data = await statusPayload();
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(data, null, 2));
return;
}
// 4. /telegram — webhook endpoint; no auth (Telegram can't do our cookie).
if (path === "/telegram" || path.startsWith("/telegram/")) {
proxyRequest(req, res, TELEGRAM_WEBHOOK_PORT);
return;
}
// 5. /v1/* — Hermes gateway OpenAI-compatible API.
if (path === "/v1" || path.startsWith("/v1/")) {
if (!isAuthorized(req)) {
if (wantsHtml(req)) {
redirect(res, loginUrl(`${path}${parsed.search}`));
return;
}
res.writeHead(401, {
"content-type": "application/json",
"cache-control": "no-store",
});
res.end(
JSON.stringify({
error: "unauthorized",
message: "Use Authorization: Bearer .",
}),
);
return;
}
const upstreamHeaders =
getBearerToken(req) || !API_SERVER_KEY
? {}
: { authorization: `Bearer ${API_SERVER_KEY}` };
proxyRequest(req, res, GATEWAY_PORT, (p) => p, upstreamHeaders);
return;
}
// 6. /hm — HuggingMes status page.
if (path === HM_PREFIX || path === `${HM_PREFIX}/`) {
if (!requireAuth(req, res)) return;
const data = await statusPayload();
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
res.end(renderStatusPage(data));
return;
}
// /hm/app/* -> Hermes dashboard (SPA with HTML rewriting for base path)
if (path === `${HM_PREFIX}/app` || path.startsWith(`${HM_PREFIX}/app/`)) {
if (!requireAuth(req, res)) return;
proxyDashboard(req, res);
return;
}
// /hm/status -> JSON
if (path === `${HM_PREFIX}/status`) {
if (!requireAuth(req, res)) return;
const data = await statusPayload();
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(data, null, 2));
return;
}
// Legacy /dashboard -> /hm
if (path === "/dashboard" || path === "/dashboard/") {
redirect(res, `${HM_PREFIX}${parsed.search}`);
return;
}
// Root-path dashboard routes (config, env, providers, etc.) that users
// type or bookmark without the /hm/app prefix. Redirect them there.
const dashboardRootRoutes = new Set([
"/config",
"/env",
"/models",
"/providers",
"/profiles",
"/sessions",
"/skills",
"/cron",
"/analytics",
"/logs",
"/plugins",
"/chat",
"/docs",
]);
if (dashboardRootRoutes.has(path) || [...dashboardRootRoutes].some((r) => path.startsWith(r + "/"))) {
redirect(res, `${HM_PREFIX}/app${path}${parsed.search}`);
return;
}
// 6b. Root-path requests whose Referer came from /hm/app/* must go to
// the dashboard, not WebUI. This covers:
// - Absolute assets (/assets/*, /ds-assets/*, /dashboard-plugins/*)
// - API calls (/api/*) when dashboard code uses absolute paths
// - Favicon (/favicon.ico)
// - WebSocket upgrades from dashboard pages
// - File downloads (any extensioned path referenced by dashboard)
// Both the Hermes dashboard AND hermes-webui use /api/* internally,
// so the Referer is the only reliable way to disambiguate.
const refererPath = (() => {
const ref = String(req.headers.referer || "");
if (!ref) return "";
try {
return new URL(ref).pathname;
} catch {
return "";
}
})();
const refererIsDashboard = refererPath.startsWith(`${HM_PREFIX}/app`);
if (refererIsDashboard) {
// Anything with a Referer from the dashboard goes to the dashboard,
// *except* requests that explicitly start with /webui (escape hatch).
if (!path.startsWith("/webui")) {
if (!requireAuth(req, res)) return;
// Assets must NOT get the SPA fallback; pass them through as-is.
const parsed2 = new URL(req.url, "http://localhost");
const looksLikeAsset =
path.startsWith("/assets/") ||
path.startsWith("/ds-assets/") ||
path.startsWith("/dashboard-plugins/") ||
path.startsWith("/api/") ||
path === "/favicon.ico" ||
/\.[a-z0-9]{1,6}$/i.test(path);
if (looksLikeAsset) {
proxyRequest(req, res, DASHBOARD_PORT);
} else {
// Unlikely: a dashboard-referrer request for a non-asset, non-/hm
// path. Treat as a dashboard sub-route.
proxyDashboard(req, res);
}
return;
}
}
// 6c. /api/* routes — these are WebUI API calls when Referer isn't the
// dashboard. Fall through to the catch-all below.
// 7. Anything else -> Hermes WebUI (primary UI) OR HuggingMes status page.
// WebUI handles its own auth internally via HERMES_WEBUI_PASSWORD.
if (PRIMARY_UI === "dashboard" && path === "/") {
if (!requireAuth(req, res)) return;
const data = await statusPayload();
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
res.end(renderStatusPage(data));
return;
}
// Catch-all -> WebUI. Don't gate at the router level: WebUI has its own
// password login. GATEWAY_TOKEN *is* the WebUI password (start.sh sets
// HERMES_WEBUI_PASSWORD=$GATEWAY_TOKEN).
proxyRequest(req, res, WEBUI_PORT);
});
server.listen(PORT, "0.0.0.0", () => {
console.log(`HuggingMes + Hermes WebUI router listening on 0.0.0.0:${PORT}`);
});
/* ── WebSocket upgrade handling ─────────────────────────────────────
*
* Both the Hermes dashboard and hermes-webui can open WebSocket
* connections for live updates. Route the upgrade to the correct
* upstream based on path prefix + referer, same as HTTP requests.
*/
server.on("upgrade", (req, clientSocket, head) => {
const parsed = new URL(req.url, "http://localhost");
const path = parsed.pathname;
let targetPort = WEBUI_PORT;
let targetPath = req.url;
const refererPath = (() => {
const ref = String(req.headers.referer || "");
if (!ref) return "";
try {
return new URL(ref).pathname;
} catch {
return "";
}
})();
const refererIsDashboard = refererPath.startsWith(`${HM_PREFIX}/app`);
if (path === "/v1" || path.startsWith("/v1/")) {
targetPort = GATEWAY_PORT;
} else if (path === `${HM_PREFIX}/app` || path.startsWith(`${HM_PREFIX}/app/`)) {
targetPort = DASHBOARD_PORT;
targetPath = path.replace(`${HM_PREFIX}/app`, "") || "/";
if (parsed.search) targetPath += parsed.search;
} else if (refererIsDashboard && !path.startsWith("/webui")) {
targetPort = DASHBOARD_PORT;
} else if (path.startsWith("/webui/") || path === "/webui") {
targetPort = WEBUI_PORT;
targetPath = path.replace(/^\/webui/, "") || "/";
if (parsed.search) targetPath += parsed.search;
}
const upstream = net.createConnection(targetPort, GATEWAY_HOST, () => {
// Forward the HTTP upgrade handshake verbatim
const headerLines = [
`${req.method} ${targetPath} HTTP/1.1`,
];
for (const [name, value] of Object.entries(req.headers)) {
if (Array.isArray(value)) {
for (const v of value) headerLines.push(`${name}: ${v}`);
} else {
headerLines.push(`${name}: ${value}`);
}
}
headerLines.push("", "");
upstream.write(headerLines.join("\r\n"));
if (head && head.length) upstream.write(head);
upstream.pipe(clientSocket);
clientSocket.pipe(upstream);
});
upstream.on("error", () => {
try {
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
} catch {}
});
clientSocket.on("error", () => {
try {
upstream.destroy();
} catch {}
});
});