Files
9router/src/lib/headroom/process.js
T
KunN-21 f1f9d27061 feat(headroom): add extras detection and install UI (#2403)
- add Headroom extras status + install endpoints
- show Headroom version + code/ml extras in Token Saver UI
- fix Windows interpreter selection to read from env with headroom-ai
2026-07-10 16:05:04 +07:00

178 lines
5.7 KiB
JavaScript

import fs from "fs";
import path from "path";
import { spawn } from "child_process";
import { DATA_DIR } from "@/lib/dataDir.js";
import { findHeadroomBinary, findPython310, HEADROOM_COMPRESSION_EXTRAS, getInstalledHeadroomExtras } 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 ""; }
}
// Install (or upgrade) headroom-ai with the requested compression extras.
// `extras` is a whitelist from HEADROOM_COMPRESSION_EXTRAS — anything else
// is rejected to keep the install surface predictable. Always installs the
// `proxy` base + whatever extras the user picked, regardless of what is
// already present.
export async function installHeadroomExtras(extras = []) {
const requested = Array.isArray(extras) ? extras.filter((e) => HEADROOM_COMPRESSION_EXTRAS.includes(e)) : [];
const py = findPython310();
if (!py) {
const err = new Error("Python >= 3.10 not found");
err.code = "NO_PYTHON";
throw err;
}
if (!findHeadroomBinary()) {
const err = new Error("headroom-ai not installed (run `pip install headroom-ai[proxy]` first)");
err.code = "NOT_INSTALLED";
throw err;
}
// pip install string is built from a closed set (HEADROOM_COMPRESSION_EXTRAS),
// so it cannot be poisoned by caller input — the comma-list is a fixed
// ['proxy', ...requested]. No shell interpolation.
const extrasList = ["proxy", ...requested].join(",");
const spec = `headroom-ai[${extrasList}]`;
const args = ["-m", "pip", "install", "--upgrade", spec];
ensureDir();
const outFd = fs.openSync(path.join(HEADROOM_DIR, "install.log"), "a");
const child = spawn(py, args, {
stdio: ["ignore", outFd, outFd],
windowsHide: true,
env: { ...process.env },
});
return new Promise((resolve, reject) => {
child.once("error", (e) => { fs.closeSync(outFd); reject(e); });
child.once("exit", (code) => {
fs.closeSync(outFd);
if (code === 0) {
const status = getInstalledHeadroomExtras(py);
resolve({ success: true, code, spec, extras: requested, ...status });
} else {
const err = new Error(`pip install exited with code=${code} — see headroom/install.log`);
err.code = "INSTALL_FAILED";
reject(err);
}
});
});
}