Spaces:
Running
Running
| // Single public entrypoint for HF Spaces: dashboard + reverse proxy to OpenClaw + JupyterLab. | |
| const http = require("http"); | |
| const https = require("https"); | |
| const fs = require("fs"); | |
| const net = require("net"); | |
| const crypto = require("crypto"); | |
| 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 GATEWAY_TOKEN = (process.env.GATEWAY_TOKEN || "").trim(); | |
| const SESSION_COOKIE = "hc_session"; | |
| const LOGIN_PATH = "/login"; | |
| const DEV_MODE_ENABLED = isTrue(process.env.DEV_MODE); | |
| // Explicit HUGGINGCLAW_JUPYTER_ENABLED=true enables Jupyter. | |
| // Otherwise DEV_MODE=true enables it unless HUGGINGCLAW_JUPYTER_ENABLED is explicitly false. | |
| // HUGGINGCLAW_JUPYTER_ENABLED=true is the explicit user override and always wins. | |
| const JUPYTER_ENABLED = | |
| /^(true|1|yes|on)$/i.test(String(process.env.HUGGINGCLAW_JUPYTER_ENABLED || "").trim()) || | |
| ( | |
| isTrue(process.env.DEV_MODE) && | |
| !/^(false|0|no|off)$/i.test(String(process.env.HUGGINGCLAW_JUPYTER_ENABLED || "").trim()) | |
| ); | |
| const startTime = Date.now(); | |
| const LLM_MODEL = process.env.LLM_MODEL || "Not Set"; | |
| const LLM_PROVIDER = LLM_MODEL.includes("/") ? LLM_MODEL.split("/")[0] : ""; | |
| const TELEGRAM_WEBHOOK_URL = (process.env.TELEGRAM_WEBHOOK_URL || "").trim(); | |
| 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"; | |
| // ββ Private Space redirect support ββ | |
| // HF automatically sets SPACE_ID as "username/spacename" in every Space container. | |
| const SPACE_ID = (process.env.SPACE_ID || "").trim(); | |
| function deriveHfSpaceUrl() { | |
| if (SPACE_ID) return `https://huggingface.co/spaces/${SPACE_ID}`; | |
| const host = (process.env.SPACE_HOST || "").replace(/\.hf\.space$/i, ""); | |
| const author = (process.env.SPACE_AUTHOR_NAME || "").trim().toLowerCase(); | |
| if (author && host.toLowerCase().startsWith(author + "-")) { | |
| const spaceName = host.slice(author.length + 1); | |
| return `https://huggingface.co/spaces/${process.env.SPACE_AUTHOR_NAME}/${spaceName}`; | |
| } | |
| return ""; | |
| } | |
| const HF_SPACE_URL = deriveHfSpaceUrl(); | |
| const _privacyWaitRaw = Number(process.env.PRIVACY_DETECTION_WAIT_MS || "1500"); | |
| const PRIVACY_DETECTION_WAIT_MS = Number.isFinite(_privacyWaitRaw) | |
| ? Math.max(0, Math.floor(_privacyWaitRaw)) | |
| : 1500; | |
| // ββ Privacy Detection ββ | |
| // Priority order: | |
| // 1. SPACE_PRIVACY env var ("public" / "private") β explicit user override, most reliable | |
| // 2. HF API call to huggingface.co β auto-detect | |
| // 3. Fail-secure default: treat as private if SPACE_ID is set | |
| // 1. Check explicit env var override first | |
| const _spacPrivacyEnv = (process.env.SPACE_PRIVACY || "").trim().toLowerCase(); | |
| let SPACE_IS_PRIVATE; | |
| let _privacyDetectionDone = false; | |
| let _privacyDetectionResolve; | |
| const privacyDetectionReady = new Promise((res) => { _privacyDetectionResolve = res; }); | |
| if (_spacPrivacyEnv === "public") { | |
| // User explicitly set SPACE_PRIVACY=public β skip API call entirely | |
| SPACE_IS_PRIVATE = false; | |
| _privacyDetectionDone = true; | |
| console.log("[health-server] Space privacy: public (SPACE_PRIVACY env var override)"); | |
| _privacyDetectionResolve && _privacyDetectionResolve(); | |
| } else if (_spacPrivacyEnv === "private") { | |
| // User explicitly set SPACE_PRIVACY=private β skip API call entirely | |
| SPACE_IS_PRIVATE = true; | |
| _privacyDetectionDone = true; | |
| console.log("[health-server] Space privacy: private (SPACE_PRIVACY env var override)"); | |
| _privacyDetectionResolve && _privacyDetectionResolve(); | |
| } else { | |
| // 2. Auto-detect via HF API (with fail-secure default) | |
| // Default to private if SPACE_ID is set β gets corrected by API call below. | |
| SPACE_IS_PRIVATE = !!SPACE_ID; | |
| } | |
| async function detectSpacePrivacy() { | |
| // Skip if already resolved via env var | |
| if (_spacPrivacyEnv === "public" || _spacPrivacyEnv === "private") return; | |
| // Skip if not running on HF Spaces | |
| if (!SPACE_ID) { | |
| SPACE_IS_PRIVATE = false; | |
| _privacyDetectionDone = true; | |
| _privacyDetectionResolve(); | |
| return; | |
| } | |
| const token = (process.env.HF_TOKEN || process.env.HUGGINGFACE_HUB_TOKEN || "").trim(); | |
| const reqOptions = { | |
| hostname: "huggingface.co", | |
| path: `/api/spaces/${SPACE_ID}`, | |
| method: "GET", | |
| headers: Object.assign( | |
| { "User-Agent": "HuggingClaw/health-server" }, | |
| token ? { Authorization: `Bearer ${token}` } : {} | |
| ), | |
| }; | |
| // Retry up to 5 times with increasing delay β covers transient failures at boot | |
| const MAX_ATTEMPTS = 5; | |
| let detected = false; | |
| for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { | |
| try { | |
| const result = await new Promise((resolve) => { | |
| const r = https.request(reqOptions, (apiRes) => { | |
| let body = ""; | |
| apiRes.on("data", (chunk) => { body += chunk; }); | |
| apiRes.on("end", () => { | |
| try { | |
| if (apiRes.statusCode === 200) { | |
| const data = JSON.parse(body); | |
| // API confirmed privacy status | |
| SPACE_IS_PRIVATE = data.private === true; | |
| resolve({ ok: true, status: apiRes.statusCode }); | |
| } else if (apiRes.statusCode === 401 || apiRes.statusCode === 403) { | |
| // 401/403 on /api/spaces means the space IS private and our token | |
| // is missing or wrong. Mark as private. | |
| SPACE_IS_PRIVATE = true; | |
| resolve({ ok: true, status: apiRes.statusCode, forcedPrivate: true }); | |
| } else if (apiRes.statusCode === 404) { | |
| // Space not found β shouldn't happen but treat as non-blocking; default stays. | |
| resolve({ ok: false, status: apiRes.statusCode }); | |
| } else { | |
| // Other non-200 β transient; retry | |
| resolve({ ok: false, status: apiRes.statusCode }); | |
| } | |
| } catch { resolve({ ok: false, status: apiRes.statusCode }); } | |
| }); | |
| }); | |
| r.on("error", (err) => resolve({ ok: false, error: err.message })); | |
| r.setTimeout(8000, () => { r.destroy(); resolve({ ok: false, error: "timeout" }); }); | |
| r.end(); | |
| }); | |
| console.log(`[health-server] Privacy detection attempt ${attempt}/${MAX_ATTEMPTS}: status=${result.status || "network-error"} ok=${result.ok}`); | |
| if (result.ok) { detected = true; break; } | |
| } catch (err) { | |
| console.warn(`[health-server] Privacy detection attempt ${attempt} threw: ${err.message}`); | |
| } | |
| const delay = Math.min(2000 * attempt, 10000); // 2s, 4s, 6s, 8s, 10s | |
| if (attempt < MAX_ATTEMPTS) { | |
| await new Promise((r) => setTimeout(r, delay)); | |
| } | |
| } | |
| if (!detected) { | |
| console.warn( | |
| `[health-server] Privacy detection failed after ${MAX_ATTEMPTS} attempts β ` + | |
| `defaulting to ${SPACE_IS_PRIVATE ? "private" : "public"}. ` + | |
| `TIP: Set SPACE_PRIVACY=public (or private) in your Space secrets to skip API detection.` | |
| ); | |
| } else { | |
| console.log(`[health-server] Space privacy detected via HF API: ${SPACE_IS_PRIVATE ? "private" : "public"}`); | |
| } | |
| _privacyDetectionDone = true; | |
| _privacyDetectionResolve(); | |
| } | |
| // Only run API detection if env var override not used | |
| if (_spacPrivacyEnv !== "public" && _spacPrivacyEnv !== "private") { | |
| detectSpacePrivacy(); | |
| // Re-check every 5 minutes so runtime publicβprivate changes are picked up | |
| setInterval(detectSpacePrivacy, 5 * 60 * 1000); | |
| } | |
| 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 < 400); | |
| }); | |
| 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,">").replace(/"/g,"""); | |
| } | |
| function parseCookies(req) { | |
| const h = req.headers.cookie || ""; | |
| return Object.fromEntries(h.split(";").map(c => c.trim().split("=")).filter(p => p.length >= 2).map(([k, ...v]) => [k.trim(), decodeURIComponent(v.join("=").trim())])); | |
| } | |
| // Constant-time comparison using crypto β prevent timing attacks | |
| function timingSafeEqualString(a, b) { | |
| if (!a || !b) return false; | |
| const bufA = Buffer.from(a); | |
| const bufB = Buffer.from(b); | |
| if (bufA.length !== bufB.length) return false; | |
| return crypto.timingSafeEqual(bufA, bufB); | |
| } | |
| function expectedSessionValue() { | |
| if (!GATEWAY_TOKEN) return ""; | |
| return crypto.createHmac("sha256", GATEWAY_TOKEN).update("huggingclaw-session-v1").digest("hex"); | |
| } | |
| 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 match = /^Bearer\s+(.+)$/i.exec(req.headers.authorization || ""); | |
| return match ? match[1] : ""; | |
| } | |
| function isAuthorized(req) { | |
| if (!GATEWAY_TOKEN) return true; | |
| return ( | |
| timingSafeEqualString(getBearerToken(req), GATEWAY_TOKEN) || | |
| timingSafeEqualString(parseCookies(req)[SESSION_COOKIE], expectedSessionValue()) | |
| ); | |
| } | |
| function sanitizeNext(value) { | |
| if (!value || typeof value !== "string") return "/"; | |
| if (!value.startsWith("/") || value.startsWith("//")) return "/"; | |
| return value; | |
| } | |
| function loginUrl(nextPath) { | |
| return `${LOGIN_PATH}?next=${encodeURIComponent(sanitizeNext(nextPath))}`; | |
| } | |
| function requireAuth(req, res) { | |
| if (isAuthorized(req)) return true; | |
| const parsed = parseRequestUrl(req.url); | |
| res.writeHead(302, { Location: loginUrl(parsed.pathname + parsed.search), "Cache-Control": "no-store" }); | |
| res.end(); | |
| return false; | |
| } | |
| function readBody(req) { | |
| return new Promise((resolve) => { | |
| let body = ""; | |
| req.on("data", chunk => { body += chunk; if (body.length > 4096) { body = ""; req.destroy(); } }); | |
| req.on("end", () => resolve(body)); | |
| req.on("error", () => resolve("")); | |
| }); | |
| } | |
| function renderLoginPage(nextPath = "/", error = false) { | |
| const safeNext = sanitizeNext(nextPath); | |
| return `<!doctype html><html lang="en"><head> | |
| <meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/> | |
| <title>HuggingClaw</title> | |
| <style> | |
| :root{color-scheme:dark;--bg:#08080f;--panel:#12111b;--line:#26243a;--text:#f6f4ff;--muted:#7f7a9e;--bad:#fb7185} | |
| *{box-sizing:border-box}body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);padding:24px} | |
| .card{border:1px solid var(--line);background:var(--panel);border-radius:14px;padding:36px 32px;max-width:400px;width:100%;text-align:center} | |
| h1{margin:0 0 8px;font-size:1.4rem} | |
| .sub{color:var(--muted);font-size:.82rem;margin:0 0 24px} | |
| .row{display:flex;gap:8px;margin-top:16px} | |
| input{flex:1;background:#0d0c18;border:1px solid var(--line);border-radius:7px;padding:10px 12px;color:var(--text);font-size:.95rem;outline:none;transition:border-color .15s} | |
| input:focus{border-color:#6366f1} | |
| button{background:#fff;color:#000;border:none;border-radius:7px;padding:10px 20px;font-weight:700;font-size:.95rem;cursor:pointer;transition:opacity .15s;white-space:nowrap} | |
| button:hover{opacity:.85} | |
| .err{color:var(--bad);font-size:.82rem;margin-top:10px} | |
| code{background:#232234;border:1px solid #34324c;border-radius:5px;padding:2px 6px;font-size:.88em} | |
| </style></head><body> | |
| <div class="card"> | |
| <h1>π¦ HuggingClaw</h1> | |
| <p class="sub">Enter your <code>GATEWAY_TOKEN</code> to continue</p> | |
| <form method="post" action="${LOGIN_PATH}"> | |
| <input type="hidden" name="next" value="${escapeHtml(safeNext)}" /> | |
| <div class="row"> | |
| <input type="password" name="token" placeholder="GATEWAY_TOKEN" autofocus autocomplete="current-password" required> | |
| <button type="submit">Unlock</button> | |
| </div> | |
| ${error ? '<p class="err">Invalid token β try again</p>' : ""} | |
| </form> | |
| </div> | |
| </body></html>`; | |
| } | |
| function badge(label, tone = "neutral") { | |
| return `<span class="badge ${tone}">${escapeHtml(label)}</span>`; | |
| } | |
| function tile({ title, value, detail = "", tone = "neutral", meta = "" }) { | |
| return `<article class="tile ${tone}"> | |
| <div class="tile-head"><span class="tile-title">${escapeHtml(title)}</span><span class="tile-dot"></span></div> | |
| <div class="tile-value">${value}</div> | |
| ${detail ? `<div class="tile-detail">${detail}</div>` : ""} | |
| ${meta ? `<div class="tile-meta">${meta}</div>` : ""} | |
| </article>`; | |
| } | |
| function renderDashboard(data) { | |
| const syncStatus = String(data.sync?.status || "unknown"); | |
| const syncTone = ["success","restored","synced","configured"].includes(syncStatus) ? "ok" : syncStatus === "disabled" ? "warn" : "neutral"; | |
| const kaConf = data.keepalive?.configured === true; | |
| const kaStatus = String(data.keepalive?.status || (process.env.CLOUDFLARE_WORKERS_TOKEN ? "pending" : "not configured")); | |
| const kaTone = kaConf ? "ok" : process.env.CLOUDFLARE_WORKERS_TOKEN ? "warn" : "neutral"; | |
| const tiles = [ | |
| tile({ title: "Gateway", value: badge(data.gatewayReady ? "Online" : "Offline", data.gatewayReady ? "ok" : "off"), detail: `OpenClaw on internal port ${GATEWAY_PORT}`, tone: data.gatewayReady ? "ok" : "off" }), | |
| tile({ title: "Model", value: `<code>${escapeHtml(LLM_MODEL)}</code>`, detail: LLM_PROVIDER ? `Provider: ${escapeHtml(LLM_PROVIDER)}` : "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 ? (TELEGRAM_WEBHOOK_URL ? "Webhook" : "Polling") + (process.env.CLOUDFLARE_PROXY_URL ? " via CF proxy" : "") : "Not configured", tone: TELEGRAM_ENABLED ? "ok" : "neutral" }), | |
| ]; | |
| tiles.push( | |
| tile({ title: "Backup", value: badge(syncStatus.toUpperCase(), syncTone), detail: escapeHtml(data.sync?.message || "No status yet"), tone: syncTone, meta: data.sync?.timestamp ? `<span class="local-time" data-iso="${data.sync.timestamp}"></span>` : "" }), | |
| tile({ title: "Keep Awake", value: badge(kaConf ? "CF Cron" : kaStatus.toUpperCase(), kaTone), detail: kaConf ? `Pinging <code>${escapeHtml(data.keepalive?.targetUrl || "/health")}</code>` : 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 <a href="${JUPYTER_BASE}/" style="color:inherit">${JUPYTER_BASE}/</a>`, 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 <code>${escapeHtml(DEVDATA_DATASET_NAME)}</code>` : 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 `<!doctype html><html lang="en"><head> | |
| <meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/> | |
| <title>HuggingClaw</title> | |
| <style> | |
| :root{color-scheme:dark;--bg:#08080f;--panel:#12111b;--line:#26243a;--text:#f6f4ff;--muted:#7f7a9e;--soft:#b8b3d7;--good:#22c55e;--warn:#f5c542;--bad:#fb7185} | |
| *{box-sizing:border-box}body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);font-size:13px} | |
| main{width:min(720px,calc(100% - 32px));margin:0 auto;padding:36px 0 44px} | |
| header{text-align:center;margin-bottom:22px}h1{margin:0;font-size:1.65rem;line-height:1} | |
| .subtitle{margin-top:12px;color:var(--muted);font-size:.72rem;text-transform:uppercase;letter-spacing:.14em;font-weight:800} | |
| .btn-row{display:flex;gap:12px;margin:24px 0 20px} | |
| .hero-action{display:flex;flex:1;min-height:46px;align-items:center;justify-content:center;border-radius:8px;background:#fff;color:#000;text-decoration:none;font-weight:850;font-size:.98rem;transition:opacity .15s} | |
| .hero-action:hover{opacity:.9}.hero-action.terminal{background:#1e1e2e;color:#cdd6f4;border:1px solid #45475a}.hero-action.env{background:#312e81;color:#eef2ff;border:1px solid #6366f1} | |
| .overview{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-bottom:10px} | |
| .tile{border:1px solid var(--line);background:var(--panel);border-radius:11px;padding:18px;min-height:124px;display:flex;flex-direction:column;gap:10px} | |
| .tile.ok{border-color:rgba(34,197,94,.22)}.tile.warn{border-color:rgba(245,197,66,.24)}.tile.off{border-color:rgba(251,113,133,.28)} | |
| .tile-head{display:flex;align-items:center;justify-content:space-between;gap:12px} | |
| .tile-title{color:var(--muted);font-size:.67rem;letter-spacing:.18em;text-transform:uppercase;font-weight:850} | |
| .tile-dot{width:7px;height:7px;border-radius:50%;background:var(--line)} | |
| .tile.ok .tile-dot{background:var(--good)}.tile.warn .tile-dot{background:var(--warn)}.tile.off .tile-dot{background:var(--bad)} | |
| .tile-value{font-size:1.12rem;font-weight:850;overflow-wrap:anywhere}.tile-detail{color:var(--soft);line-height:1.45;font-size:.83rem} | |
| .tile-meta{color:var(--muted);line-height:1.4;font-size:.75rem;margin-top:auto;overflow-wrap:anywhere} | |
| code{background:#232234;border:1px solid #34324c;border-radius:6px;padding:2px 6px;color:var(--text);font-size:.9em} | |
| .badge{display:inline-flex;align-items:center;width:max-content;border:1px solid var(--line);border-radius:999px;padding:5px 10px;font-size:.72rem;font-weight:850;line-height:1;text-transform:uppercase} | |
| .badge.ok{color:var(--good);border-color:rgba(34,197,94,.34);background:rgba(34,197,94,.11)} | |
| .badge.warn{color:var(--warn);border-color:rgba(245,197,66,.34);background:rgba(245,197,66,.11)} | |
| .badge.off{color:var(--bad);border-color:rgba(251,113,133,.34);background:rgba(251,113,133,.11)} | |
| .badge.neutral{color:var(--soft)} | |
| footer{color:var(--muted);text-align:center;font-size:.74rem;margin-top:18px} | |
| @media(max-width:700px){.overview{grid-template-columns:1fr}main{width:min(100% - 22px,720px);padding-top:28px}.btn-row{flex-direction:column}} | |
| </style></head><body><main> | |
| <header><h1>π¦ HuggingClaw</h1><div class="subtitle">OpenClaw Gateway</div></header> | |
| <div class="btn-row"> | |
| <a class="hero-action" data-space-link="app" href="${APP_BASE}/">Open Control UI β</a> | |
| ${JUPYTER_ENABLED ? `<a class="hero-action terminal" data-space-link="terminal" href="${JUPYTER_BASE}/">π» Open Terminal β</a>` : ""} | |
| <a class="hero-action env" data-space-link="env-builder" href="/env-builder">βοΈ Env Builder β</a> | |
| </div> | |
| <section class="overview">${tilesHtml}</section> | |
| <footer>Built by <a href="https://github.com/somratpro" target="_blank" rel="noopener noreferrer" style="color:inherit;text-decoration:none">@somratpro</a>${JUPYTER_ENABLED ? " Β· Terminal by JupyterLab" : ""} Β· Contributions by <a href="https://github.com/anurag008w" target="_blank" rel="noopener noreferrer" style="color:inherit;text-decoration:none">@anurag008w</a></footer> | |
| </main> | |
| <script> | |
| document.querySelectorAll('.local-time').forEach(el=>{const d=new Date(el.getAttribute('data-iso'));if(!isNaN(d))el.textContent='At '+d.toLocaleTimeString()}); | |
| const inEmbeddedApp = (() => { try { return window.top !== window.self; } catch { return true; } })(); | |
| const isDirectHfSpaceHost = /\.hf\.space$/i.test(window.location.hostname); | |
| const HF_SPACE_URL = ${JSON.stringify(HF_SPACE_URL)}; | |
| // Server-side detected value (may be stale if page was cached β see /api/is-private) | |
| let SPACE_IS_PRIVATE = ${JSON.stringify(SPACE_IS_PRIVATE)}; | |
| function applyLinkTargets() { | |
| // Keep hero buttons in-frame for private spaces; open new tab for public spaces | |
| // accessed via the HF iframe or directly at .hf.space. | |
| const openInNewTab = !SPACE_IS_PRIVATE && (inEmbeddedApp || isDirectHfSpaceHost); | |
| document.querySelectorAll('a[data-space-link]').forEach((a) => { | |
| if (openInNewTab) { | |
| a.setAttribute('target', '_blank'); | |
| a.setAttribute('rel', 'noopener noreferrer'); | |
| } else { | |
| a.removeAttribute('target'); | |
| a.removeAttribute('rel'); | |
| } | |
| }); | |
| } | |
| applyLinkTargets(); | |
| // Always re-fetch the live privacy status from the server to handle: | |
| // 1. Startup race condition where server rendered before API detection finished | |
| // 2. Any mismatch between client-rendered value and actual server-side state | |
| // 3. Public spaces where the fail-secure default (private) needs correcting | |
| // Also retries after 4s in case the first fetch raced with a server-side retry. | |
| function syncPrivacy() { | |
| return fetch('/api/is-private', { cache: 'no-store' }) | |
| .then(r => r.json()) | |
| .then(d => { | |
| if (d.isPrivate !== SPACE_IS_PRIVATE) { | |
| SPACE_IS_PRIVATE = d.isPrivate; | |
| applyLinkTargets(); // re-run: adds or removes target="_blank" on buttons | |
| } | |
| return d.isPrivate; | |
| }) | |
| .catch(() => SPACE_IS_PRIVATE); | |
| } | |
| if (isDirectHfSpaceHost) { | |
| // Immediate check on page load | |
| syncPrivacy().then(isPrivate => { | |
| // If space appears private after first check, re-verify after server retries | |
| // complete (server retries up to 3Γ5s = ~15s). This catches the edge case | |
| // where a PUBLIC space returned private due to a transient API failure. | |
| if (isPrivate) { | |
| setTimeout(syncPrivacy, 8000); | |
| setTimeout(syncPrivacy, 16000); | |
| } | |
| }); | |
| } | |
| // Direct .hf.space access outside the HF App iframe has no valid session cookie | |
| // for private spaces β HF CDN returns 404 before the request reaches the container. | |
| // Redirect users to huggingface.co/spaces/... which authenticates them properly. | |
| if (SPACE_IS_PRIVATE && isDirectHfSpaceHost && !inEmbeddedApp && HF_SPACE_URL) { | |
| const notice = document.createElement('div'); | |
| notice.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:#08080f;color:#f6f4ff;font-family:sans-serif;flex-direction:column;gap:16px;z-index:9999'; | |
| notice.innerHTML = '<span style="font-size:1.1rem">π Private Space — Redirecting…</span><a href="' + HF_SPACE_URL + '" style="color:#a5b4fc;font-size:.85rem">Click here if not redirected</a>'; | |
| document.body.appendChild(notice); | |
| setTimeout(() => { window.location.replace(HF_SPACE_URL); }, 300); | |
| } | |
| </script> | |
| </body></html>`; | |
| } | |
| function renderPrivateRedirect(targetUrl) { | |
| const safeUrl = escapeHtml(targetUrl); | |
| return `<!doctype html><html lang="en"><head> | |
| <meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/> | |
| <title>HuggingClaw β Private Space</title> | |
| <style> | |
| :root{color-scheme:dark} | |
| body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center; | |
| font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif; | |
| background:#08080f;color:#f6f4ff;text-align:center;padding:24px} | |
| .card{border:1px solid #26243a;background:#12111b;border-radius:14px;padding:36px 32px;max-width:440px} | |
| h1{margin:0 0 12px;font-size:1.5rem} | |
| p{color:#b8b3d7;line-height:1.6;margin:0 0 24px} | |
| .btn{display:inline-flex;align-items:center;justify-content:center; | |
| background:#fff;color:#000;font-weight:850;font-size:.95rem; | |
| border-radius:8px;padding:12px 28px;text-decoration:none;transition:opacity .15s} | |
| .btn:hover{opacity:.85} | |
| .sub{color:#7f7a9e;font-size:.78rem;margin-top:16px} | |
| </style></head><body> | |
| <div class="card"> | |
| <h1>π Private Space</h1> | |
| <p>This HuggingFace Space is private. You need to be logged in to <strong>huggingface.co</strong> to access it.<br><br>Redirecting you now…</p> | |
| <a class="btn" href="${safeUrl}">Open on Hugging Face β</a> | |
| <div class="sub">Redirecting in 3 seconds…</div> | |
| </div> | |
| <script> | |
| // Only auto-redirect when NOT inside an iframe (e.g. HF App tab embeds this | |
| // page in an iframe; navigating that iframe to huggingface.co is blocked by | |
| // X-Frame-Options and causes "refused to connect" in the browser). | |
| const _inFrame = (() => { try { return window.top !== window.self; } catch { return true; } })(); | |
| if (!_inFrame) { | |
| setTimeout(() => { window.location.replace(${JSON.stringify(targetUrl)}); }, 100); | |
| } | |
| </script> | |
| </body></html>`; | |
| } | |
| function renderEnvBuilder() { | |
| try { | |
| return fs.readFileSync(require("path").join(__dirname, "env-builder.html"), "utf8"); | |
| } catch (exc) { | |
| return `<!doctype html><title>Env Builder unavailable</title><pre>${escapeHtml(exc.message)}</pre>`; | |
| } | |
| } | |
| // ββ 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 || "", | |
| ...(options.extraHeaders || {}), | |
| }; | |
| 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); | |
| // Lightweight endpoint for client-side fallback detection. | |
| // Called by the dashboard JS if it suspects the server-rendered SPACE_IS_PRIVATE | |
| // value was stale (race condition at startup). No auth required β it's not sensitive. | |
| if (pathname === "/api/is-private") { | |
| if (!_privacyDetectionDone) await privacyDetectionReady; | |
| res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" }); | |
| return res.end(JSON.stringify({ isPrivate: SPACE_IS_PRIVATE })); | |
| } | |
| 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() })); | |
| } | |
| // Private space redirect β send users to the authenticated HF Spaces page. | |
| // Works for both direct .hf.space links AND programmatic shares. | |
| if (pathname === "/hf-redirect" || pathname === "/hf-redirect/") { | |
| if (HF_SPACE_URL) { | |
| res.writeHead(302, { Location: HF_SPACE_URL, "Cache-Control": "no-store" }); | |
| return res.end(); | |
| } | |
| res.writeHead(404, { "Content-Type": "text/plain" }); | |
| return res.end("SPACE_ID not configured."); | |
| } | |
| // ββ Private Space Guard (server-side) ββ | |
| // Triggers automatically when SPACE_IS_PRIVATE=true (detected via HF API at startup). | |
| // Only intercepts browser navigation (Accept: text/html) β API calls, assets, | |
| // and WebSocket upgrades pass through untouched. | |
| // /health and /status are always exempt so uptime monitors keep working. | |
| const isHtmlRequest = (req.headers.accept || "").includes("text/html"); | |
| // RACE CONDITION FIX: Wait for privacy detection to finish BEFORE computing | |
| // isDirectHfSpaceRequest. Previously this const was computed immediately with | |
| // the fail-secure default (SPACE_IS_PRIVATE=true), causing private redirects | |
| // even when the space is actually public or the owner is accessing via HF App. | |
| // After the very first HTML request, _privacyDetectionDone=true so no delay. | |
| let privacyWaitTimedOut = false; | |
| if (isHtmlRequest && !_privacyDetectionDone) { | |
| const waitResult = await Promise.race([ | |
| privacyDetectionReady.then(() => "detected"), | |
| new Promise((resolve) => setTimeout(() => resolve("timeout"), PRIVACY_DETECTION_WAIT_MS)), | |
| ]); | |
| privacyWaitTimedOut = waitResult == "timeout"; | |
| } | |
| // In-app navigation (clicking links within the HF iframe) sends a Referer | |
| // from the same .hf.space origin β don't redirect those, only redirect | |
| // fresh direct browser access that has no same-origin referer. | |
| const referer = req.headers.referer || req.headers.referrer || ""; | |
| const isSameOriginNav = !!(referer && typeof req.headers.host === "string" && | |
| referer.startsWith(`https://${req.headers.host}`)); | |
| // When HF App embeds the space in an iframe, the initial request has | |
| // Referer: https://huggingface.co/spaces/... (NOT .hf.space). | |
| // HF handles authentication itself β if the user is not logged in, HF | |
| // redirects them before the iframe ever loads. So a huggingface.co referer | |
| // means the user is already authenticated; skip the private redirect. | |
| const isFromHFApp = !!(referer && ( | |
| referer.startsWith("https://huggingface.co") || | |
| referer.startsWith("https://hf.co") | |
| )); | |
| // NOTE: computed AFTER detection is awaited above β always uses real value. | |
| const isDirectHfSpaceRequest = SPACE_IS_PRIVATE && | |
| !privacyWaitTimedOut && | |
| HF_SPACE_URL && | |
| isHtmlRequest && | |
| typeof req.headers.host === "string" && | |
| req.headers.host.endsWith(".hf.space") && | |
| !isSameOriginNav && | |
| !isFromHFApp; | |
| if (pathname === LOGIN_PATH) { | |
| if (isAuthorized(req)) { | |
| const parsed = parseRequestUrl(req.url); | |
| const next = sanitizeNext(parsed.searchParams.get("next") || "/"); | |
| res.writeHead(302, { Location: next, "Cache-Control": "no-store" }); | |
| return res.end(); | |
| } | |
| if (req.method === "GET") { | |
| const parsed = parseRequestUrl(req.url); | |
| const next = sanitizeNext(parsed.searchParams.get("next") || "/"); | |
| res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" }); | |
| return res.end(renderLoginPage(next, false)); | |
| } | |
| if (req.method === "POST") { | |
| const body = await readBody(req); | |
| const params = new URLSearchParams(body); | |
| const submittedToken = params.get("token") || ""; | |
| const next = sanitizeNext(params.get("next") || "/"); | |
| if (!GATEWAY_TOKEN || timingSafeEqualString(submittedToken, GATEWAY_TOKEN)) { | |
| res.writeHead(302, { Location: next, "Set-Cookie": buildSessionCookie(req), "Cache-Control": "no-store" }); | |
| return res.end(); | |
| } | |
| res.writeHead(401, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" }); | |
| return res.end(renderLoginPage(next, true)); | |
| } | |
| res.writeHead(405, { Allow: "GET, POST" }); | |
| return res.end("Method Not Allowed"); | |
| } | |
| if (pathname === "/logout") { | |
| res.writeHead(302, { Location: LOGIN_PATH, "Set-Cookie": `${SESSION_COOKIE}=; Path=/; HttpOnly; Max-Age=0`, "Cache-Control": "no-store" }); | |
| return res.end(); | |
| } | |
| if (pathname === "/env-builder" || pathname === "/env-builder/") { | |
| if (isDirectHfSpaceRequest) { | |
| res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" }); | |
| return res.end(renderPrivateRedirect(HF_SPACE_URL)); | |
| } | |
| if (!requireAuth(req, res)) return; | |
| res.writeHead(200, { "Content-Type": "text/html" }); | |
| return res.end(renderEnvBuilder()); | |
| } | |
| if (pathname === "/env-builder.js") { | |
| if (!requireAuth(req, res)) return; | |
| try { | |
| const js = fs.readFileSync(require("path").join(__dirname, "env-builder.js"), "utf8"); | |
| res.writeHead(200, { "Content-Type": "application/javascript", "Cache-Control": "no-store" }); | |
| return res.end(js); | |
| } catch (exc) { | |
| res.writeHead(404, { "Content-Type": "text/plain" }); | |
| return res.end(`env-builder.js not found: ${exc.message}`); | |
| } | |
| } | |
| if (pathname === "/" || pathname === "/dashboard") { | |
| // Detection already awaited above (in the isHtmlRequest guard) β no extra wait needed. | |
| if (isDirectHfSpaceRequest) { | |
| res.writeHead(200, { "Content-Type": "text/html" }); | |
| return res.end(renderPrivateRedirect(HF_SPACE_URL)); | |
| } | |
| 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. Remove DEV_MODE=false to re-enable." })); | |
| } | |
| if (isDirectHfSpaceRequest) { | |
| res.writeHead(200, { "Content-Type": "text/html" }); | |
| return res.end(renderPrivateRedirect(HF_SPACE_URL)); | |
| } | |
| if (!requireAuth(req, res)) return; | |
| // Inject the Jupyter token so JupyterLab skips its own login screen. | |
| // Mirror start.sh logic: JUPYTER_TOKEN falls back to GATEWAY_TOKEN when | |
| // unset or still the insecure default β that's what Jupyter was started with. | |
| const rawJupyterToken = (process.env.JUPYTER_TOKEN || "").trim(); | |
| const jToken = (!rawJupyterToken || rawJupyterToken === "huggingface") ? GATEWAY_TOKEN : rawJupyterToken; | |
| 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, | |
| extraHeaders: jToken ? { authorization: `token ${jToken}` } : {}, | |
| }); | |
| } | |
| // 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 + "/")) { | |
| if (isDirectHfSpaceRequest) { | |
| res.writeHead(200, { "Content-Type": "text/html" }); | |
| return res.end(renderPrivateRedirect(HF_SPACE_URL)); | |
| } | |
| return proxyHTTP(req, res, GATEWAY_HOST, GATEWAY_PORT, { | |
| publicPrefix: APP_BASE, | |
| stripPrefix: APP_BASE, | |
| retryWithoutPrefixOn404: true, | |
| }); | |
| } | |
| // Favicon β serve a minimal inline SVG so browsers don't proxy to the gateway | |
| if (pathname === "/favicon.ico" || pathname === "/favicon.svg") { | |
| const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">π¦</text></svg>'; | |
| res.writeHead(200, { "Content-Type": "image/svg+xml", "Cache-Control": "public, max-age=86400" }); | |
| return res.end(svg); | |
| } | |
| // OpenClaw gateway API/static fallback (everything else) | |
| if (isDirectHfSpaceRequest) { | |
| res.writeHead(200, { "Content-Type": "text/html" }); | |
| return res.end(renderPrivateRedirect(HF_SPACE_URL)); | |
| } | |
| 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"}`), | |
| ); | |