mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
feat(headroom): add proxy lifecycle management + dashboard UI
Build on the optional Headroom Token Saver from Carmelo Campos
(PR: feat: add optional Headroom token saver). Add managed start/stop
of the local headroom proxy from the dashboard, install detection,
status probing, and a simplified Token Saver UI.
- detect headroom CLI + python>=3.10, probe proxy /health
- spawn/stop proxy as a detached, pid-tracked process
- /api/headroom/{status,start,stop} routes, gated local-only in dashboardGuard
- one-click Start/Stop Headroom modal, no manual config needed
- claude<->openai shape conversion for /v1/compress via 9router translators
Thanks to Carmelo Campos (@carmelogunsroses) for the original Headroom integration.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -34,8 +34,13 @@ const DEFAULT_SETTINGS = {
|
||||
mitmRouterBaseUrl: DEFAULT_MITM_ROUTER_BASE,
|
||||
dnsToolEnabled: {},
|
||||
rtkEnabled: true,
|
||||
headroomEnabled: false,
|
||||
headroomUrl: "http://localhost:8787",
|
||||
headroomCompressUserMessages: false,
|
||||
cavemanEnabled: false,
|
||||
cavemanLevel: "full",
|
||||
ponytailEnabled: false,
|
||||
ponytailLevel: "full",
|
||||
};
|
||||
|
||||
async function readRaw() {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { execSync } from "child_process";
|
||||
|
||||
const EXTENDED_PATH = `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:${process.env.PATH || ""}`;
|
||||
const PYTHON_CANDIDATES = ["python3.13", "python3.12", "python3.11", "python3.10", "python3"];
|
||||
const MIN_VERSION = [3, 10];
|
||||
const HEADROOM_HEALTH_TIMEOUT_MS = 1500;
|
||||
|
||||
// Detect whether the headroom CLI is installed and where its binary lives.
|
||||
export function findHeadroomBinary() {
|
||||
try {
|
||||
const path = execSync("which headroom", {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
windowsHide: true,
|
||||
env: { ...process.env, PATH: EXTENDED_PATH },
|
||||
}).toString().trim();
|
||||
return path || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Find a Python interpreter >= 3.10 (headroom-ai requires it). Returns null if none.
|
||||
export function findPython310() {
|
||||
for (const candidate of PYTHON_CANDIDATES) {
|
||||
try {
|
||||
const ver = execSync(`${candidate} --version`, {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
windowsHide: true,
|
||||
env: { ...process.env, PATH: EXTENDED_PATH },
|
||||
}).toString().trim();
|
||||
const match = ver.match(/(\d+)\.(\d+)/);
|
||||
if (!match) continue;
|
||||
const [major, minor] = [parseInt(match[1], 10), parseInt(match[2], 10)];
|
||||
if (major > MIN_VERSION[0] || (major === MIN_VERSION[0] && minor >= MIN_VERSION[1])) {
|
||||
return candidate;
|
||||
}
|
||||
} catch {
|
||||
// candidate not present, try next
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Probe whether a Headroom proxy is reachable at the given URL by hitting /health.
|
||||
export async function probeProxyRunning(url) {
|
||||
if (!url) return false;
|
||||
const base = String(url).replace(/\/$/, "");
|
||||
try {
|
||||
const res = await fetch(`${base}/health`, { signal: AbortSignal.timeout(HEADROOM_HEALTH_TIMEOUT_MS) });
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate status for the dashboard: installed, running, python interpreter.
|
||||
export async function getHeadroomStatus(url) {
|
||||
const path = findHeadroomBinary();
|
||||
const python = findPython310();
|
||||
const installed = Boolean(path);
|
||||
const running = installed ? await probeProxyRunning(url) : false;
|
||||
return { installed, path, running, python };
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { spawn } from "child_process";
|
||||
import { DATA_DIR } from "@/lib/dataDir.js";
|
||||
import { findHeadroomBinary } from "./detect.js";
|
||||
|
||||
const HEADROOM_DIR = path.join(DATA_DIR, "headroom");
|
||||
const PID_FILE = path.join(HEADROOM_DIR, "proxy.pid");
|
||||
const LOG_FILE = path.join(HEADROOM_DIR, "proxy.log");
|
||||
const DEFAULT_PORT = 8787;
|
||||
const STARTUP_TIMEOUT_MS = 8000;
|
||||
|
||||
function ensureDir() {
|
||||
if (!fs.existsSync(HEADROOM_DIR)) fs.mkdirSync(HEADROOM_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function readPid() {
|
||||
try {
|
||||
if (fs.existsSync(PID_FILE)) return parseInt(fs.readFileSync(PID_FILE, "utf8"), 10);
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
function writePid(pid) {
|
||||
ensureDir();
|
||||
fs.writeFileSync(PID_FILE, String(pid));
|
||||
}
|
||||
|
||||
function clearPid() {
|
||||
try { if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// process.kill throws if pid is dead — use this to probe.
|
||||
export function isPidAlive(pid) {
|
||||
if (!pid || typeof pid !== "number") return false;
|
||||
try { process.kill(pid, 0); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
export function getManagedPid() {
|
||||
const pid = readPid();
|
||||
return pid && isPidAlive(pid) ? pid : null;
|
||||
}
|
||||
|
||||
export async function startHeadroomProxy({ port = DEFAULT_PORT } = {}) {
|
||||
const safePort = Number(port) > 0 && Number(port) < 65536 ? Number(port) : DEFAULT_PORT;
|
||||
const binary = findHeadroomBinary();
|
||||
if (!binary) {
|
||||
const err = new Error("Headroom CLI not installed");
|
||||
err.code = "NOT_INSTALLED";
|
||||
throw err;
|
||||
}
|
||||
|
||||
const existing = getManagedPid();
|
||||
if (existing) return { pid: existing, alreadyRunning: true };
|
||||
|
||||
ensureDir();
|
||||
// spawn stdio requires fd numbers, not WriteStream objects.
|
||||
const outFd = fs.openSync(LOG_FILE, "a");
|
||||
|
||||
const child = spawn(binary, ["proxy", "--port", String(safePort)], {
|
||||
stdio: ["ignore", outFd, outFd],
|
||||
detached: true,
|
||||
windowsHide: true,
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
if (!child.pid) {
|
||||
fs.closeSync(outFd);
|
||||
const err = new Error("Failed to spawn headroom proxy");
|
||||
err.code = "SPAWN_FAILED";
|
||||
throw err;
|
||||
}
|
||||
|
||||
child.unref();
|
||||
writePid(child.pid);
|
||||
|
||||
// Wait until the process either stays alive briefly (success) or exits fast (failure).
|
||||
await new Promise((resolve, reject) => {
|
||||
const startupTimer = setTimeout(() => {
|
||||
if (isPidAlive(child.pid)) resolve();
|
||||
else reject(new Error("headroom proxy exited during startup — see proxy.log"));
|
||||
}, STARTUP_TIMEOUT_MS);
|
||||
|
||||
child.once("exit", (code) => {
|
||||
clearTimeout(startupTimer);
|
||||
clearPid();
|
||||
fs.closeSync(outFd);
|
||||
const e = new Error(`headroom proxy exited early (code=${code}) — see proxy.log`);
|
||||
e.code = "EARLY_EXIT";
|
||||
reject(e);
|
||||
});
|
||||
});
|
||||
|
||||
// Close parent's copy of the fd; child retains its own after unref.
|
||||
fs.closeSync(outFd);
|
||||
|
||||
return { pid: child.pid, alreadyRunning: false };
|
||||
}
|
||||
|
||||
export function stopHeadroomProxy() {
|
||||
const pid = getManagedPid();
|
||||
if (!pid) return { stopped: false, reason: "not_running" };
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
// Give it a moment, then force if still alive.
|
||||
setTimeout(() => {
|
||||
if (isPidAlive(pid)) {
|
||||
try { process.kill(pid, "SIGKILL"); } catch { /* already gone */ }
|
||||
}
|
||||
}, 2000);
|
||||
clearPid();
|
||||
return { stopped: true, pid };
|
||||
} catch (e) {
|
||||
clearPid();
|
||||
const err = new Error(`Failed to stop headroom proxy: ${e.message}`);
|
||||
err.code = "STOP_FAILED";
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function getHeadroomLogTail(maxLines = 200) {
|
||||
try {
|
||||
if (!fs.existsSync(LOG_FILE)) return "";
|
||||
const content = fs.readFileSync(LOG_FILE, "utf8");
|
||||
const lines = content.split(/\r?\n/).filter(Boolean);
|
||||
return lines.slice(-maxLines).join("\n");
|
||||
} catch { return ""; }
|
||||
}
|
||||
Reference in New Issue
Block a user