// Single public entrypoint for HF Spaces: dashboard + reverse proxy to OpenClaw + JupyterLab.
const http = require("http");
const fs = require("fs");
const net = require("net");
function isTrue(value) {
return /^(true|1|yes|on)$/i.test(String(value || "").trim());
}
function normalizeBase(value, fallback) {
const raw = String(value || fallback || "").trim() || fallback;
if (!raw) return fallback;
const base = raw.startsWith("/") ? raw : `/${raw}`;
return base.replace(/\/+$/, "") || fallback;
}
const PORT = Number.parseInt(process.env.PORT || "7861", 10);
const GATEWAY_PORT = Number.parseInt(process.env.GATEWAY_PORT || "7860", 10);
const GATEWAY_HOST = "127.0.0.1";
const JUPYTER_PORT = Number.parseInt(process.env.JUPYTER_PORT || "8888", 10);
const JUPYTER_HOST = "127.0.0.1";
const JUPYTER_BASE = normalizeBase(process.env.JUPYTER_BASE, "/terminal");
const DEV_MODE_ENABLED = isTrue(process.env.DEV_MODE);
const JUPYTER_ENABLED = /^(true|1|yes|on)$/i.test(
process.env.HUGGINGCLAW_JUPYTER_ENABLED || (DEV_MODE_ENABLED ? "true" : "false")
);
const startTime = Date.now();
const LLM_MODEL = process.env.LLM_MODEL || "Not Set";
const TELEGRAM_ENABLED = !!process.env.TELEGRAM_BOT_TOKEN;
const WHATSAPP_ENABLED = isTrue(process.env.WHATSAPP_ENABLED);
const WHATSAPP_STATUS_FILE = "/tmp/huggingclaw-wa-status.json";
const HF_BACKUP_ENABLED = !!process.env.HF_TOKEN;
const SYNC_INTERVAL = (process.env.SYNC_INTERVAL || "180").trim() || "180";
const BACKUP_DATASET_NAME = (process.env.BACKUP_DATASET_NAME || process.env.BACKUP_DATASET || "huggingclaw-backup").trim() || "huggingclaw-backup";
const DEVDATA_DATASET_NAME = (process.env.DEVDATA_DATASET_NAME || "huggingclaw-devdata").trim() || "huggingclaw-devdata";
const DEVDATA_SYNC_INTERVAL = (process.env.DEVDATA_SYNC_INTERVAL || "180").trim() || "180";
const DEVDATA_SEPARATE_DATASET = DEVDATA_DATASET_NAME !== BACKUP_DATASET_NAME;
const DEVDATA_ENABLED = JUPYTER_ENABLED && HF_BACKUP_ENABLED && DEVDATA_SEPARATE_DATASET && !/^(off|false|0|no)$/i.test((process.env.DEVDATA || "on").trim());
const APP_BASE = normalizeBase(process.env.APP_BASE, "/app");
const SYNC_STATUS_FILE = "/tmp/sync-status.json";
const CLOUDFLARE_KEEPALIVE_STATUS_FILE =
"/tmp/huggingclaw-cloudflare-keepalive-status.json";
function parseRequestUrl(url) {
try { return new URL(url, "http://localhost"); }
catch { return new URL("http://localhost/"); }
}
function getSyncStatus() {
try {
if (fs.existsSync(SYNC_STATUS_FILE))
return JSON.parse(fs.readFileSync(SYNC_STATUS_FILE, "utf8"));
} catch {}
if (HF_BACKUP_ENABLED)
return { status: "configured", message: `Backup enabled. Waiting for sync window (${SYNC_INTERVAL}s).` };
return { status: "unknown", message: "No sync data yet" };
}
function readGuardianStatus() {
if (!WHATSAPP_ENABLED) return { configured: false, connected: false, pairing: false };
try {
if (fs.existsSync(WHATSAPP_STATUS_FILE)) {
const p = JSON.parse(fs.readFileSync(WHATSAPP_STATUS_FILE, "utf8"));
return { configured: p.configured !== false, connected: p.connected === true, pairing: p.pairing === true };
}
} catch {}
return { configured: true, connected: false, pairing: false };
}
function getKeepaliveStatus() {
try {
if (fs.existsSync(CLOUDFLARE_KEEPALIVE_STATUS_FILE))
return JSON.parse(fs.readFileSync(CLOUDFLARE_KEEPALIVE_STATUS_FILE, "utf8"));
} catch {}
return null;
}
function probePort(host, port, path, timeoutMs = 1500) {
return new Promise((resolve) => {
const req = http.get({ hostname: host, port, path, timeout: timeoutMs }, (res) => {
res.resume();
resolve(res.statusCode >= 200 && res.statusCode < 500);
});
req.on("timeout", () => { req.destroy(); resolve(false); });
req.on("error", () => resolve(false));
});
}
function formatUptime(ms) {
const t = Math.floor(ms / 1000);
const d = Math.floor(t / 86400), h = Math.floor((t % 86400) / 3600), m = Math.floor((t % 3600) / 60);
if (d) return `${d}d ${h}h ${m}m`;
if (h) return `${h}h ${m}m`;
return `${m}m`;
}
function escapeHtml(v) {
return String(v).replace(/&/g,"&").replace(//g,">").replace(/"/g,""");
}
function badge(label, tone = "neutral") {
return `${escapeHtml(label)}`;
}
function tile({ title, value, detail = "", tone = "neutral", meta = "" }) {
return `${escapeHtml(LLM_MODEL)}`, detail: "Primary LLM configured", tone: "neutral" }),
tile({ title: "Runtime", value: escapeHtml(data.uptimeHuman), detail: `Public port ${PORT}`, tone: "neutral" }),
tile({ title: "Telegram", value: badge(TELEGRAM_ENABLED ? "Enabled" : "Disabled", TELEGRAM_ENABLED ? "ok" : "neutral"), detail: TELEGRAM_ENABLED ? "Bot channel active" : "Not configured", tone: TELEGRAM_ENABLED ? "ok" : "neutral" }),
tile({ title: "Backup", value: badge(syncStatus.toUpperCase(), syncTone), detail: escapeHtml(data.sync?.message || "No status yet"), tone: syncTone, meta: data.sync?.timestamp ? `` : "" }),
tile({ title: "Keep Awake", value: badge(kaConf ? "CF Cron" : kaStatus.toUpperCase(), kaTone), detail: kaConf ? `Pinging ${escapeHtml(data.keepalive?.targetUrl || "/health")}` : process.env.CLOUDFLARE_WORKERS_TOKEN ? "Worker pending or failed" : "Not configured", tone: kaTone }),
];
if (JUPYTER_ENABLED) {
tiles.push(tile({ title: "Terminal", value: badge(data.jupyterReady ? "Online" : "Starting…", data.jupyterReady ? "ok" : "warn"), detail: `JupyterLab at ${JUPYTER_BASE}/`, tone: data.jupyterReady ? "ok" : "warn" }));
tiles.push(tile({
title: "DevData",
value: badge(DEVDATA_ENABLED ? "Enabled" : "Disabled", DEVDATA_ENABLED ? "ok" : "neutral"),
detail: DEVDATA_ENABLED ? `Separate dataset ${escapeHtml(DEVDATA_DATASET_NAME)}` : DEVDATA_SEPARATE_DATASET ? "Separate Jupyter dataset backup inactive" : "DevData dataset must be separate from main backup dataset",
tone: DEVDATA_ENABLED ? "ok" : "neutral",
meta: `Sync interval ${escapeHtml(DEVDATA_SYNC_INTERVAL)}s`,
}));
}
const tilesHtml = tiles.join("");
return `
${escapeHtml(exc.message)}`;
}
}
// ── Generic proxy ──
function proxiedPath(url, { stripPrefix = "" } = {}) {
if (!stripPrefix) return url.pathname + url.search;
if (url.pathname === stripPrefix) return "/" + url.search;
if (url.pathname.startsWith(stripPrefix + "/")) {
return url.pathname.slice(stripPrefix.length) + url.search;
}
return url.pathname + url.search;
}
function rewriteProxyHeaders(headers, { publicPrefix = "", targetHost = "", targetPort = "" } = {}) {
const next = { ...headers };
// Keep browser redirects inside the public HF Space path. Backends may emit
// root-relative redirects ("/login") or absolute redirects pointing at their
// internal listener ("http://127.0.0.1:8888/..."). Both break from a browser
// if we do not normalize them back to the public mount path.
if (publicPrefix && typeof next.location === "string") {
try {
const internalOrigins = new Set([
"http://huggingclaw.local",
`http://${targetHost}:${targetPort}`,
`http://localhost:${targetPort}`,
`http://127.0.0.1:${targetPort}`,
]);
const location = new URL(next.location, "http://huggingclaw.local");
if (internalOrigins.has(location.origin)) {
let path = location.pathname;
if (path !== publicPrefix && !path.startsWith(publicPrefix + "/")) {
path = publicPrefix + (path.startsWith("/") ? path : `/${path}`);
}
next.location = path + location.search + location.hash;
}
} catch {}
}
return next;
}
function sendServiceUnavailable(res) {
if (!res.headersSent) {
res.writeHead(503, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "starting", message: "Service is initializing… please wait." }));
} else {
res.end();
}
}
function proxyHTTP(req, res, targetHost, targetPort, options = {}) {
const url = parseRequestUrl(req.url);
const headers = {
...req.headers,
host: `${targetHost}:${targetPort}`,
"x-forwarded-for": req.socket.remoteAddress,
"x-forwarded-host": req.headers.host,
"x-forwarded-proto": "https",
"x-forwarded-prefix": options.publicPrefix || "",
};
const canReplayRequest = req.method === "GET" || req.method === "HEAD";
const proxyOnce = (path, retryOn404) => {
const pr = http.request({ hostname: targetHost, port: targetPort, path, method: req.method, headers }, (pres) => {
if (canReplayRequest && retryOn404 && pres.statusCode === 404 && options.stripPrefix) {
pres.resume();
return proxyOnce(proxiedPath(url, { stripPrefix: options.stripPrefix }), false);
}
res.writeHead(pres.statusCode, rewriteProxyHeaders(pres.headers, { ...options, targetHost, targetPort }));
pres.pipe(res);
pres.on("error", () => res.end());
});
req.on("error", () => pr.destroy());
res.on("error", () => pr.destroy());
pr.on("error", () => sendServiceUnavailable(res));
req.pipe(pr);
};
// First try the public path as-is because OpenClaw and JupyterLab are both
// configured with base paths. If a backend still returns 404, retry with the
// mount prefix stripped; that covers images built before the base-path config
// took effect and avoids the common HF Spaces "404 at /app or /terminal" trap.
proxyOnce(url.pathname + url.search, !!options.retryWithoutPrefixOn404);
}
// ── HTTP server ──
const server = http.createServer(async (req, res) => {
const { pathname } = parseRequestUrl(req.url);
if (pathname === "/health") {
const gatewayReady = await probePort(GATEWAY_HOST, GATEWAY_PORT, "/health");
res.writeHead(gatewayReady ? 200 : 503, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ status: gatewayReady ? "ok" : "degraded", gatewayReady, uptime: formatUptime(Date.now() - startTime), sync: getSyncStatus(), keepalive: getKeepaliveStatus() }));
}
if (pathname === "/status") {
const [gatewayReady, jupyterReady] = await Promise.all([
probePort(GATEWAY_HOST, GATEWAY_PORT, "/health"),
JUPYTER_ENABLED ? probePort(JUPYTER_HOST, JUPYTER_PORT, `${JUPYTER_BASE}/login`) : Promise.resolve(false),
]);
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ model: LLM_MODEL, uptime: formatUptime(Date.now() - startTime), gatewayReady, jupyterReady, sync: getSyncStatus(), whatsapp: readGuardianStatus(), keepalive: getKeepaliveStatus() }));
}
if (pathname === "/env-builder" || pathname === "/env-builder/") {
res.writeHead(200, { "Content-Type": "text/html" });
return res.end(renderEnvBuilder());
}
if (pathname === "/" || pathname === "/dashboard") {
const [gatewayReady, jupyterReady] = await Promise.all([
probePort(GATEWAY_HOST, GATEWAY_PORT, "/health"),
JUPYTER_ENABLED ? probePort(JUPYTER_HOST, JUPYTER_PORT, `${JUPYTER_BASE}/login`) : Promise.resolve(false),
]);
res.writeHead(200, { "Content-Type": "text/html" });
return res.end(renderDashboard({ uptimeHuman: formatUptime(Date.now() - startTime), gatewayReady, jupyterReady, sync: getSyncStatus(), whatsapp: readGuardianStatus(), keepalive: getKeepaliveStatus() }));
}
// JupyterLab terminal
if (pathname === JUPYTER_BASE || pathname.startsWith(JUPYTER_BASE + "/")) {
if (!JUPYTER_ENABLED) {
res.writeHead(404, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ status: "disabled", message: "JupyterLab terminal is disabled. Set DEV_MODE=true to enable /terminal/." }));
}
return proxyHTTP(req, res, JUPYTER_HOST, JUPYTER_PORT, {
publicPrefix: JUPYTER_BASE,
// Jupyter is started with --ServerApp.base_url=/terminal/, so keep the
// /terminal prefix when proxying. Stripping it breaks static/theme URLs.
stripPrefix: "",
retryWithoutPrefixOn404: false,
});
}
// OpenClaw Control UI mounted under /app. Retry without the mount prefix on
// 404 so deployments keep working across OpenClaw basePath behavior changes.
if (pathname === APP_BASE || pathname.startsWith(APP_BASE + "/")) {
return proxyHTTP(req, res, GATEWAY_HOST, GATEWAY_PORT, {
publicPrefix: APP_BASE,
stripPrefix: APP_BASE,
retryWithoutPrefixOn404: true,
});
}
// OpenClaw gateway API/static fallback (everything else)
proxyHTTP(req, res, GATEWAY_HOST, GATEWAY_PORT);
});
// ── WebSocket upgrade (JupyterLab kernels + terminals need this) ──
server.on("upgrade", (req, socket, head) => {
const { pathname, search } = parseRequestUrl(req.url);
const isJupyter = JUPYTER_ENABLED && (pathname === JUPYTER_BASE || pathname.startsWith(JUPYTER_BASE + "/"));
const isApp = pathname === APP_BASE || pathname.startsWith(APP_BASE + "/");
const [targetHost, targetPort] = isJupyter ? [JUPYTER_HOST, JUPYTER_PORT] : [GATEWAY_HOST, GATEWAY_PORT];
const publicPrefix = isJupyter ? JUPYTER_BASE : isApp ? APP_BASE : "";
const targetPath = pathname + search;
const ps = net.connect(targetPort, targetHost, () => {
ps.write(`${req.method} ${targetPath} HTTP/${req.httpVersion}\r\n`);
ps.write(`Host: ${targetHost}:${targetPort}\r\n`);
ps.write(`X-Forwarded-For: ${req.socket.remoteAddress || ""}\r\n`);
ps.write(`X-Forwarded-Host: ${req.headers.host || ""}\r\n`);
ps.write("X-Forwarded-Proto: https\r\n");
if (publicPrefix) ps.write(`X-Forwarded-Prefix: ${publicPrefix}\r\n`);
for (let i = 0; i < req.rawHeaders.length; i += 2) {
const header = req.rawHeaders[i];
const lower = header.toLowerCase();
if (["host", "x-forwarded-for", "x-forwarded-host", "x-forwarded-proto", "x-forwarded-prefix"].includes(lower)) continue;
ps.write(`${header}: ${req.rawHeaders[i + 1]}\r\n`);
}
ps.write("\r\n");
if (head && head.length) ps.write(head);
ps.pipe(socket).pipe(ps);
});
ps.on("error", () => socket.destroy());
ps.on("close", () => socket.destroy());
socket.on("error", () => ps.destroy());
socket.on("close", () => ps.destroy());
});
server.timeout = 0;
server.keepAliveTimeout = 65000;
server.on("error", (err) => console.error(`[health-server] Server error:`, err));
server.listen(PORT, "0.0.0.0", () =>
console.log(`🦞 HuggingClaw :${PORT} → Gateway :${GATEWAY_PORT}${JUPYTER_ENABLED ? ` | Terminal :${JUPYTER_PORT} at ${JUPYTER_BASE}/` : " | Terminal disabled"}`),
);