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 ""; } }