Spaces:
Runtime error
Runtime error
File size: 4,918 Bytes
cd8bd0a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 | /**
* Local headroom-proxy lifecycle management.
*
* Ported from upstream 9router @ b55cf36d (Cursor / decolua).
*
* Spawns the local `headroom proxy --port <port>` as a detached process,
* tracks its PID in `<DATA_DIR>/headroom/proxy.pid`, and exposes start/stop/
* status helpers. Only invoked behind the LOCAL_ONLY route-guard tier
* (Hard Rules #15 + #17): a tunneled JWT cannot reach the start/stop routes.
*/
import fs from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
import { DATA_DIR } from "@/lib/db/core";
import { findHeadroomBinary } from "./detect";
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;
const STOP_GRACE_MS = 2000;
export interface StartResult {
pid: number;
alreadyRunning: boolean;
}
export interface StopResult {
stopped: boolean;
pid?: number;
reason?: string;
}
export class HeadroomError extends Error {
code: string;
constructor(message: string, code: string) {
super(message);
this.code = code;
}
}
function ensureDir(): void {
if (!fs.existsSync(HEADROOM_DIR)) fs.mkdirSync(HEADROOM_DIR, { recursive: true });
}
function readPid(): number | null {
try {
if (!fs.existsSync(PID_FILE)) return null;
const raw = fs.readFileSync(PID_FILE, "utf8").trim();
const pid = parseInt(raw, 10);
return Number.isFinite(pid) && pid > 0 ? pid : null;
} catch {
return null;
}
}
function writePid(pid: number): void {
ensureDir();
fs.writeFileSync(PID_FILE, String(pid));
}
function clearPid(): void {
try {
if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
} catch {
// ignore
}
}
export function isPidAlive(pid: number | null | undefined): boolean {
if (!pid || typeof pid !== "number") return false;
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
export function getManagedPid(): number | null {
const pid = readPid();
return pid && isPidAlive(pid) ? pid : null;
}
function safePort(port: unknown): number {
const n = Number(port);
return Number.isFinite(n) && n > 0 && n < 65536 ? n : DEFAULT_PORT;
}
export async function startHeadroomProxy(
opts: { port?: number } = {}
): Promise<StartResult> {
const binary = findHeadroomBinary();
if (!binary) {
throw new HeadroomError("Headroom CLI not installed", "NOT_INSTALLED");
}
const existing = getManagedPid();
if (existing) return { pid: existing, alreadyRunning: true };
ensureDir();
const outFd = fs.openSync(LOG_FILE, "a");
// spawn (no shell) with array args — argv is passed directly to the
// headroom binary, so no shell-metacharacter handling is needed.
const child = spawn(binary, ["proxy", "--port", String(safePort(opts.port))], {
stdio: ["ignore", outFd, outFd],
detached: true,
windowsHide: true,
env: { ...process.env },
});
if (!child.pid) {
fs.closeSync(outFd);
throw new HeadroomError("Failed to spawn headroom proxy", "SPAWN_FAILED");
}
child.unref();
writePid(child.pid);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
if (isPidAlive(child.pid!)) resolve();
else
reject(
new HeadroomError(
"headroom proxy exited during startup — see proxy.log",
"EARLY_EXIT"
)
);
}, STARTUP_TIMEOUT_MS);
child.once("exit", (code) => {
clearTimeout(timer);
clearPid();
try {
fs.closeSync(outFd);
} catch {
// already closed
}
reject(
new HeadroomError(
`headroom proxy exited early (code=${code}) — see proxy.log`,
"EARLY_EXIT"
)
);
});
});
try {
fs.closeSync(outFd);
} catch {
// already closed
}
return { pid: child.pid, alreadyRunning: false };
}
export function stopHeadroomProxy(): StopResult {
const pid = getManagedPid();
if (!pid) return { stopped: false, reason: "not_running" };
try {
process.kill(pid, "SIGTERM");
setTimeout(() => {
if (isPidAlive(pid)) {
try {
process.kill(pid, "SIGKILL");
} catch {
// already gone
}
}
}, STOP_GRACE_MS);
clearPid();
return { stopped: true, pid };
} catch (e) {
clearPid();
const msg = e instanceof Error ? e.message : String(e);
throw new HeadroomError(`Failed to stop headroom proxy: ${msg}`, "STOP_FAILED");
}
}
export function getHeadroomLogTail(maxLines = 200): string {
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 "";
}
}
|