mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 23:02:10 +03:00
Move chat pipeline validation, circuit breaker execution, proxy resolution, logging, and session header handling into dedicated helpers to keep the SSE handler smaller and easier to verify. Also fix shared API option precedence, rebuild skill version caches after deletions, ignore api.trycloudflare.com false positives, and add rate-limit manager test flush/reset hooks for deterministic coverage. Expand integration and unit coverage across chat routing, auth, cloud sync, skills, executors, streaming, DB helpers, proxy handling, and provider/model utilities.
829 lines
22 KiB
TypeScript
829 lines
22 KiB
TypeScript
import { spawn, execFile } from "child_process";
|
|
import { promisify } from "util";
|
|
import fs from "fs/promises";
|
|
import fsSync from "fs";
|
|
import path from "path";
|
|
import proxyFetch from "@omniroute/open-sse/utils/proxyFetch.ts";
|
|
import { resolveDataDir } from "@/lib/dataPaths";
|
|
import { getRuntimePorts } from "@/lib/runtime/ports";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
const CLOUDFLARED_RELEASE_BASE =
|
|
"https://github.com/cloudflare/cloudflared/releases/latest/download";
|
|
const START_TIMEOUT_MS = 30000;
|
|
const STOP_TIMEOUT_MS = 5000;
|
|
const GENERIC_EXIT_ERROR_PREFIX = "cloudflared exited";
|
|
const DEFAULT_CERT_FILE_CANDIDATES = [
|
|
"/etc/ssl/certs/ca-certificates.crt",
|
|
"/etc/pki/tls/certs/ca-bundle.crt",
|
|
"/etc/ssl/cert.pem",
|
|
"/private/etc/ssl/cert.pem",
|
|
] as const;
|
|
const DEFAULT_CERT_DIR_CANDIDATES = [
|
|
"/etc/ssl/certs",
|
|
"/etc/pki/tls/certs",
|
|
"/system/etc/security/cacerts",
|
|
] as const;
|
|
|
|
type CloudflaredInstallSource = "managed" | "path" | "env";
|
|
type TunnelPhase = "unsupported" | "not_installed" | "stopped" | "starting" | "running" | "error";
|
|
|
|
type AssetSpec = {
|
|
assetName: string;
|
|
binaryName: string;
|
|
archive: "none" | "tgz";
|
|
downloadUrl: string;
|
|
};
|
|
|
|
type CloudflaredRuntimeDirs = {
|
|
runtimeRoot: string;
|
|
homeDir: string;
|
|
configDir: string;
|
|
cacheDir: string;
|
|
dataDir: string;
|
|
tempDir: string;
|
|
userProfileDir: string;
|
|
appDataDir: string;
|
|
localAppDataDir: string;
|
|
};
|
|
|
|
type BinaryResolution = {
|
|
binaryPath: string | null;
|
|
source: CloudflaredInstallSource | null;
|
|
managed: boolean;
|
|
};
|
|
|
|
type PersistedTunnelState = {
|
|
binaryPath?: string | null;
|
|
installSource?: CloudflaredInstallSource | null;
|
|
ownerPid?: number | null;
|
|
pid?: number | null;
|
|
publicUrl?: string | null;
|
|
apiUrl?: string | null;
|
|
targetUrl?: string | null;
|
|
status?: TunnelPhase;
|
|
lastError?: string | null;
|
|
startedAt?: string | null;
|
|
installedAt?: string | null;
|
|
};
|
|
|
|
export type CloudflaredTunnelStatus = {
|
|
supported: boolean;
|
|
installed: boolean;
|
|
managedInstall: boolean;
|
|
installSource: CloudflaredInstallSource | null;
|
|
binaryPath: string | null;
|
|
running: boolean;
|
|
pid: number | null;
|
|
publicUrl: string | null;
|
|
apiUrl: string | null;
|
|
targetUrl: string;
|
|
phase: TunnelPhase;
|
|
lastError: string | null;
|
|
logPath: string;
|
|
};
|
|
|
|
const CLOUDFLARED_SAFE_ENV_KEYS = [
|
|
"PATH",
|
|
"HOME",
|
|
"USERPROFILE",
|
|
"APPDATA",
|
|
"LOCALAPPDATA",
|
|
"PROGRAMDATA",
|
|
"ProgramData",
|
|
"SYSTEMROOT",
|
|
"SystemRoot",
|
|
"WINDIR",
|
|
"ComSpec",
|
|
"COMSPEC",
|
|
"PATHEXT",
|
|
"TMPDIR",
|
|
"TMP",
|
|
"TEMP",
|
|
"USER",
|
|
"USERNAME",
|
|
"LOGNAME",
|
|
"SHELL",
|
|
"LANG",
|
|
"LC_ALL",
|
|
"LC_CTYPE",
|
|
"SSL_CERT_FILE",
|
|
"SSL_CERT_DIR",
|
|
"NODE_EXTRA_CA_CERTS",
|
|
"XDG_CONFIG_HOME",
|
|
"XDG_CACHE_HOME",
|
|
"XDG_DATA_HOME",
|
|
"XDG_RUNTIME_DIR",
|
|
"HTTP_PROXY",
|
|
"HTTPS_PROXY",
|
|
"ALL_PROXY",
|
|
"NO_PROXY",
|
|
"http_proxy",
|
|
"https_proxy",
|
|
"all_proxy",
|
|
"no_proxy",
|
|
] as const;
|
|
|
|
let tunnelProcess: ReturnType<typeof spawn> | null = null;
|
|
let tunnelPid: number | null = null;
|
|
let installPromise: Promise<string> | null = null;
|
|
let startPromise: Promise<CloudflaredTunnelStatus> | null = null;
|
|
const NON_ACTIONABLE_CLOUDFLARED_WARNING_PATTERNS = [
|
|
/failed to sufficiently increase receive buffer size/i,
|
|
] as const;
|
|
|
|
function getTunnelDir() {
|
|
return path.join(resolveDataDir(), "cloudflared");
|
|
}
|
|
|
|
function getManagedBinaryPath(platform = process.platform) {
|
|
return path.join(getTunnelDir(), "bin", platform === "win32" ? "cloudflared.exe" : "cloudflared");
|
|
}
|
|
|
|
function getStateFilePath() {
|
|
return path.join(getTunnelDir(), "quick-tunnel-state.json");
|
|
}
|
|
|
|
function getPidFilePath() {
|
|
return path.join(getTunnelDir(), ".quick-tunnel.pid");
|
|
}
|
|
|
|
function getLogFilePath() {
|
|
return path.join(getTunnelDir(), "quick-tunnel.log");
|
|
}
|
|
|
|
export function getCloudflaredRuntimeDirs(): CloudflaredRuntimeDirs {
|
|
const runtimeRoot = path.join(getTunnelDir(), "runtime");
|
|
const homeDir = path.join(runtimeRoot, "home");
|
|
const userProfileDir = path.join(runtimeRoot, "userprofile");
|
|
|
|
return {
|
|
runtimeRoot,
|
|
homeDir,
|
|
configDir: path.join(runtimeRoot, "config"),
|
|
cacheDir: path.join(runtimeRoot, "cache"),
|
|
dataDir: path.join(runtimeRoot, "data"),
|
|
tempDir: path.join(runtimeRoot, "tmp"),
|
|
userProfileDir,
|
|
appDataDir: path.join(userProfileDir, "AppData", "Roaming"),
|
|
localAppDataDir: path.join(userProfileDir, "AppData", "Local"),
|
|
};
|
|
}
|
|
|
|
function getLocalTargetUrl() {
|
|
const { apiPort } = getRuntimePorts();
|
|
return `http://127.0.0.1:${apiPort}`;
|
|
}
|
|
|
|
function getTunnelApiUrl(publicUrl: string | null) {
|
|
return publicUrl ? `${publicUrl.replace(/\/$/, "")}/v1` : null;
|
|
}
|
|
|
|
async function ensureTunnelDir() {
|
|
await fs.mkdir(path.join(getTunnelDir(), "bin"), { recursive: true });
|
|
}
|
|
|
|
async function ensureTunnelRuntimeDirs() {
|
|
const runtimeDirs = getCloudflaredRuntimeDirs();
|
|
await Promise.all(
|
|
Object.values(runtimeDirs).map((dirPath) => fs.mkdir(dirPath, { recursive: true }))
|
|
);
|
|
}
|
|
|
|
async function readStateFile(): Promise<PersistedTunnelState> {
|
|
try {
|
|
const content = await fs.readFile(getStateFilePath(), "utf8");
|
|
return JSON.parse(content) as PersistedTunnelState;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
async function writeStateFile(state: PersistedTunnelState) {
|
|
await ensureTunnelDir();
|
|
await fs.writeFile(getStateFilePath(), JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
}
|
|
|
|
async function updateStateFile(patch: PersistedTunnelState) {
|
|
const current = await readStateFile();
|
|
await writeStateFile({ ...current, ...patch });
|
|
}
|
|
|
|
async function clearPidFile() {
|
|
try {
|
|
await fs.unlink(getPidFilePath());
|
|
} catch {
|
|
// Ignore missing/stale pid files.
|
|
}
|
|
}
|
|
|
|
async function writePidFile(pid: number) {
|
|
await ensureTunnelDir();
|
|
await fs.writeFile(getPidFilePath(), String(pid), "utf8");
|
|
}
|
|
|
|
async function readPidFile() {
|
|
try {
|
|
const content = await fs.readFile(getPidFilePath(), "utf8");
|
|
const pid = Number.parseInt(content.trim(), 10);
|
|
return Number.isFinite(pid) ? pid : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isProcessAlive(pid: number | null) {
|
|
if (!pid || pid <= 0) return false;
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function appendTunnelLog(source: "stdout" | "stderr", message: string) {
|
|
await ensureTunnelDir();
|
|
const timestamp = new Date().toISOString();
|
|
await fs.appendFile(getLogFilePath(), `[${timestamp}] [${source}] ${message}\n`, "utf8");
|
|
}
|
|
|
|
export function extractTryCloudflareUrl(text: string) {
|
|
const match = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com\b/i);
|
|
if (!match) return null;
|
|
|
|
try {
|
|
const hostname = new URL(match[0]).hostname.toLowerCase();
|
|
if (hostname === "api.trycloudflare.com") return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
return match[0];
|
|
}
|
|
|
|
function normalizeCloudflaredLogLine(line: string) {
|
|
return line
|
|
.trim()
|
|
.replace(/^\d{4}-\d{2}-\d{2}T\S+\s+(?:INF|WRN|ERR)\s+/i, "")
|
|
.trim();
|
|
}
|
|
|
|
export function extractCloudflaredErrorMessage(text: string) {
|
|
const lines = String(text || "")
|
|
.split(/\r?\n/)
|
|
.map(normalizeCloudflaredLogLine)
|
|
.filter(Boolean);
|
|
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
if (NON_ACTIONABLE_CLOUDFLARED_WARNING_PATTERNS.some((pattern) => pattern.test(lines[i]))) {
|
|
continue;
|
|
}
|
|
if (/(?:\berror\b|\bfailed\b|\btls:\b|\bx509\b|\bcertificate\b)/i.test(lines[i])) {
|
|
return lines[i];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function isSpecificCloudflaredError(error: string | null | undefined) {
|
|
return !!error && !error.startsWith(GENERIC_EXIT_ERROR_PREFIX);
|
|
}
|
|
|
|
function getGenericExitError(code: number | null, signal: NodeJS.Signals | null) {
|
|
return `cloudflared exited unexpectedly (${code ?? "signal"}${signal ? `/${signal}` : ""})`;
|
|
}
|
|
|
|
export function getDefaultCloudflaredCertEnv(
|
|
existsSync: (candidate: string) => boolean = fsSync.existsSync,
|
|
certFileCandidates: readonly string[] = DEFAULT_CERT_FILE_CANDIDATES,
|
|
certDirCandidates: readonly string[] = DEFAULT_CERT_DIR_CANDIDATES
|
|
) {
|
|
const certEnv: NodeJS.ProcessEnv = {};
|
|
const certFile = certFileCandidates.find((candidate) => existsSync(candidate));
|
|
const certDir = certDirCandidates.find((candidate) => existsSync(candidate));
|
|
|
|
if (certFile) certEnv.SSL_CERT_FILE = certFile;
|
|
if (certDir) certEnv.SSL_CERT_DIR = certDir;
|
|
|
|
return certEnv;
|
|
}
|
|
|
|
export function buildCloudflaredChildEnv(
|
|
sourceEnv: NodeJS.ProcessEnv = process.env,
|
|
runtimeDirs: CloudflaredRuntimeDirs = getCloudflaredRuntimeDirs(),
|
|
defaultCertEnv: NodeJS.ProcessEnv = getDefaultCloudflaredCertEnv()
|
|
): NodeJS.ProcessEnv {
|
|
const childEnv: NodeJS.ProcessEnv = {};
|
|
|
|
for (const key of CLOUDFLARED_SAFE_ENV_KEYS) {
|
|
const value = sourceEnv[key];
|
|
if (typeof value === "string" && value.length > 0) {
|
|
childEnv[key] = value;
|
|
}
|
|
}
|
|
|
|
childEnv.HOME = runtimeDirs.homeDir;
|
|
childEnv.XDG_CONFIG_HOME = runtimeDirs.configDir;
|
|
childEnv.XDG_CACHE_HOME = runtimeDirs.cacheDir;
|
|
childEnv.XDG_DATA_HOME = runtimeDirs.dataDir;
|
|
childEnv.USERPROFILE = runtimeDirs.userProfileDir;
|
|
childEnv.APPDATA = runtimeDirs.appDataDir;
|
|
childEnv.LOCALAPPDATA = runtimeDirs.localAppDataDir;
|
|
|
|
if (!childEnv.TMPDIR) childEnv.TMPDIR = runtimeDirs.tempDir;
|
|
if (!childEnv.TMP) childEnv.TMP = runtimeDirs.tempDir;
|
|
if (!childEnv.TEMP) childEnv.TEMP = runtimeDirs.tempDir;
|
|
if (!childEnv.SSL_CERT_FILE && defaultCertEnv.SSL_CERT_FILE) {
|
|
childEnv.SSL_CERT_FILE = defaultCertEnv.SSL_CERT_FILE;
|
|
}
|
|
if (!childEnv.SSL_CERT_DIR && defaultCertEnv.SSL_CERT_DIR) {
|
|
childEnv.SSL_CERT_DIR = defaultCertEnv.SSL_CERT_DIR;
|
|
}
|
|
|
|
const requestedProtocol = String(
|
|
sourceEnv.CLOUDFLARED_PROTOCOL || sourceEnv.TUNNEL_TRANSPORT_PROTOCOL || "http2"
|
|
)
|
|
.trim()
|
|
.toLowerCase();
|
|
const protocol =
|
|
requestedProtocol === "quic" || requestedProtocol === "auto" ? requestedProtocol : "http2";
|
|
|
|
if (protocol !== "auto") {
|
|
childEnv.TUNNEL_TRANSPORT_PROTOCOL = protocol;
|
|
}
|
|
|
|
return childEnv;
|
|
}
|
|
|
|
export function getCloudflaredStartArgs(targetUrl: string) {
|
|
return ["tunnel", "--url", targetUrl, "--no-autoupdate"];
|
|
}
|
|
|
|
function isStateOwnedByCurrentProcess(state: PersistedTunnelState) {
|
|
return !!state.ownerPid && state.ownerPid === process.pid;
|
|
}
|
|
|
|
function hasTransientRuntimeState(state: PersistedTunnelState) {
|
|
return !!(
|
|
state.ownerPid ||
|
|
state.pid ||
|
|
state.publicUrl ||
|
|
state.apiUrl ||
|
|
state.startedAt ||
|
|
state.status === "running" ||
|
|
state.status === "starting" ||
|
|
state.status === "error"
|
|
);
|
|
}
|
|
|
|
function buildStoppedState(
|
|
state: PersistedTunnelState,
|
|
binaryResolved: boolean,
|
|
targetUrl = getLocalTargetUrl()
|
|
): PersistedTunnelState {
|
|
return {
|
|
...state,
|
|
ownerPid: null,
|
|
pid: null,
|
|
publicUrl: null,
|
|
apiUrl: null,
|
|
targetUrl,
|
|
status: binaryResolved ? "stopped" : "not_installed",
|
|
lastError: null,
|
|
startedAt: null,
|
|
};
|
|
}
|
|
|
|
export function getCloudflaredAssetSpec(
|
|
platform = process.platform,
|
|
arch = process.arch
|
|
): AssetSpec | null {
|
|
const matrix: Record<string, Record<string, Omit<AssetSpec, "downloadUrl">>> = {
|
|
linux: {
|
|
x64: {
|
|
assetName: "cloudflared-linux-amd64",
|
|
binaryName: "cloudflared",
|
|
archive: "none",
|
|
},
|
|
arm64: {
|
|
assetName: "cloudflared-linux-arm64",
|
|
binaryName: "cloudflared",
|
|
archive: "none",
|
|
},
|
|
},
|
|
darwin: {
|
|
x64: {
|
|
assetName: "cloudflared-darwin-amd64.tgz",
|
|
binaryName: "cloudflared",
|
|
archive: "tgz",
|
|
},
|
|
arm64: {
|
|
assetName: "cloudflared-darwin-arm64.tgz",
|
|
binaryName: "cloudflared",
|
|
archive: "tgz",
|
|
},
|
|
},
|
|
win32: {
|
|
x64: {
|
|
assetName: "cloudflared-windows-amd64.exe",
|
|
binaryName: "cloudflared.exe",
|
|
archive: "none",
|
|
},
|
|
arm64: {
|
|
assetName: "cloudflared-windows-arm64.exe",
|
|
binaryName: "cloudflared.exe",
|
|
archive: "none",
|
|
},
|
|
},
|
|
};
|
|
|
|
const spec = matrix[platform]?.[arch];
|
|
if (!spec) return null;
|
|
|
|
return {
|
|
...spec,
|
|
downloadUrl: `${CLOUDFLARED_RELEASE_BASE}/${spec.assetName}`,
|
|
};
|
|
}
|
|
|
|
async function resolvePathCommand(command: string) {
|
|
const lookupCommand = process.platform === "win32" ? "where" : "which";
|
|
const args = [command];
|
|
|
|
try {
|
|
const { stdout } = await execFileAsync(lookupCommand, args, { timeout: 3000 });
|
|
const first = stdout
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.find(Boolean);
|
|
return first || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function resolveBinary(): Promise<BinaryResolution> {
|
|
const envPath = String(process.env.CLOUDFLARED_BIN || "").trim();
|
|
if (envPath && fsSync.existsSync(envPath)) {
|
|
return { binaryPath: envPath, source: "env", managed: false };
|
|
}
|
|
|
|
const managedPath = getManagedBinaryPath();
|
|
if (fsSync.existsSync(managedPath)) {
|
|
return { binaryPath: managedPath, source: "managed", managed: true };
|
|
}
|
|
|
|
const pathBinary = await resolvePathCommand("cloudflared");
|
|
if (pathBinary) {
|
|
return { binaryPath: pathBinary, source: "path", managed: false };
|
|
}
|
|
|
|
return { binaryPath: null, source: null, managed: false };
|
|
}
|
|
|
|
async function extractArchive(archivePath: string, destinationDir: string) {
|
|
await execFileAsync("tar", ["-xzf", archivePath, "-C", destinationDir], { timeout: 15000 });
|
|
}
|
|
|
|
async function downloadToFile(url: string, destinationPath: string) {
|
|
const response = await proxyFetch(url, { redirect: "follow" });
|
|
if (!response.ok) {
|
|
throw new Error(`Download failed with status ${response.status}`);
|
|
}
|
|
|
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
await fs.writeFile(destinationPath, buffer);
|
|
}
|
|
|
|
async function ensureExecutable(binaryPath: string) {
|
|
if (process.platform !== "win32") {
|
|
await fs.chmod(binaryPath, 0o755);
|
|
}
|
|
}
|
|
|
|
async function installManagedBinary() {
|
|
if (installPromise) return installPromise;
|
|
|
|
installPromise = (async () => {
|
|
const spec = getCloudflaredAssetSpec();
|
|
if (!spec) {
|
|
throw new Error(
|
|
`Unsupported platform for managed cloudflared install: ${process.platform}/${process.arch}`
|
|
);
|
|
}
|
|
|
|
await ensureTunnelDir();
|
|
const managedBinaryPath = getManagedBinaryPath();
|
|
const tempDownloadPath = path.join(getTunnelDir(), `${spec.assetName}.download`);
|
|
|
|
await updateStateFile({
|
|
status: "starting",
|
|
lastError: null,
|
|
});
|
|
|
|
try {
|
|
await downloadToFile(spec.downloadUrl, tempDownloadPath);
|
|
|
|
if (spec.archive === "tgz") {
|
|
await extractArchive(tempDownloadPath, path.dirname(managedBinaryPath));
|
|
} else {
|
|
await fs.rename(tempDownloadPath, managedBinaryPath);
|
|
}
|
|
|
|
await ensureExecutable(managedBinaryPath);
|
|
await updateStateFile({
|
|
binaryPath: managedBinaryPath,
|
|
installSource: "managed",
|
|
installedAt: new Date().toISOString(),
|
|
lastError: null,
|
|
});
|
|
|
|
return managedBinaryPath;
|
|
} finally {
|
|
try {
|
|
await fs.unlink(tempDownloadPath);
|
|
} catch {
|
|
// Ignore temp cleanup issues.
|
|
}
|
|
installPromise = null;
|
|
}
|
|
})();
|
|
|
|
return installPromise;
|
|
}
|
|
|
|
async function ensureBinary() {
|
|
const resolved = await resolveBinary();
|
|
if (resolved.binaryPath) {
|
|
return resolved;
|
|
}
|
|
|
|
const binaryPath = await installManagedBinary();
|
|
return {
|
|
binaryPath,
|
|
source: "managed" as const,
|
|
managed: true,
|
|
};
|
|
}
|
|
|
|
async function finalizeProcessExit(code: number | null, signal: NodeJS.Signals | null) {
|
|
const currentState = await readStateFile();
|
|
const lastError =
|
|
code === 0 || signal === "SIGTERM" || signal === "SIGINT"
|
|
? null
|
|
: isSpecificCloudflaredError(currentState.lastError)
|
|
? currentState.lastError
|
|
: getGenericExitError(code, signal);
|
|
|
|
tunnelProcess = null;
|
|
tunnelPid = null;
|
|
await clearPidFile();
|
|
await writeStateFile({
|
|
...currentState,
|
|
pid: null,
|
|
publicUrl: null,
|
|
apiUrl: null,
|
|
status: lastError ? "error" : "stopped",
|
|
lastError,
|
|
});
|
|
}
|
|
|
|
async function killPid(pid: number) {
|
|
process.kill(pid, "SIGTERM");
|
|
const start = Date.now();
|
|
while (Date.now() - start < STOP_TIMEOUT_MS) {
|
|
if (!isProcessAlive(pid)) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
}
|
|
|
|
if (isProcessAlive(pid)) {
|
|
process.kill(pid, "SIGKILL");
|
|
}
|
|
}
|
|
|
|
async function stopExistingTunnel() {
|
|
if (tunnelProcess && tunnelPid && !tunnelProcess.killed) {
|
|
const pid = tunnelPid;
|
|
tunnelProcess.kill("SIGTERM");
|
|
await killPid(pid);
|
|
return;
|
|
}
|
|
|
|
const state = await readStateFile();
|
|
if (!isStateOwnedByCurrentProcess(state)) {
|
|
await clearPidFile();
|
|
return;
|
|
}
|
|
|
|
const pid = await readPidFile();
|
|
if (pid && isProcessAlive(pid)) {
|
|
await killPid(pid);
|
|
}
|
|
}
|
|
|
|
export async function getCloudflaredTunnelStatus(): Promise<CloudflaredTunnelStatus> {
|
|
const state = await readStateFile();
|
|
const resolved = await resolveBinary();
|
|
const pidFromState =
|
|
tunnelPid || (isStateOwnedByCurrentProcess(state) ? state.pid || (await readPidFile()) : null);
|
|
const running = isProcessAlive(pidFromState);
|
|
const needsColdStartReset =
|
|
!running && !isStateOwnedByCurrentProcess(state) && hasTransientRuntimeState(state);
|
|
const effectiveState = needsColdStartReset
|
|
? buildStoppedState(state, !!resolved.binaryPath)
|
|
: state;
|
|
|
|
if (needsColdStartReset) {
|
|
await writeStateFile(effectiveState);
|
|
}
|
|
|
|
const publicUrl = running ? effectiveState.publicUrl || null : null;
|
|
const phase =
|
|
!getCloudflaredAssetSpec() && !resolved.binaryPath
|
|
? "unsupported"
|
|
: running
|
|
? publicUrl
|
|
? "running"
|
|
: "starting"
|
|
: resolved.binaryPath
|
|
? effectiveState.lastError
|
|
? "error"
|
|
: "stopped"
|
|
: "not_installed";
|
|
|
|
if (!running && state.pid) {
|
|
await clearPidFile();
|
|
}
|
|
|
|
return {
|
|
supported: !!(getCloudflaredAssetSpec() || resolved.binaryPath),
|
|
installed: !!resolved.binaryPath,
|
|
managedInstall: resolved.managed,
|
|
installSource: resolved.source,
|
|
binaryPath: resolved.binaryPath,
|
|
running,
|
|
pid: running ? pidFromState : null,
|
|
publicUrl,
|
|
apiUrl: publicUrl ? getTunnelApiUrl(publicUrl) : null,
|
|
targetUrl: effectiveState.targetUrl || getLocalTargetUrl(),
|
|
phase,
|
|
lastError: running ? null : effectiveState.lastError || null,
|
|
logPath: getLogFilePath(),
|
|
};
|
|
}
|
|
|
|
export async function startCloudflaredTunnel(): Promise<CloudflaredTunnelStatus> {
|
|
const current = await getCloudflaredTunnelStatus();
|
|
if (current.running) return current;
|
|
if (startPromise) return startPromise;
|
|
|
|
startPromise = (async () => {
|
|
const spec = getCloudflaredAssetSpec();
|
|
if (!spec && !(await resolveBinary()).binaryPath) {
|
|
throw new Error(
|
|
`Unsupported platform for cloudflared tunnel: ${process.platform}/${process.arch}`
|
|
);
|
|
}
|
|
|
|
const binary = await ensureBinary();
|
|
const targetUrl = getLocalTargetUrl();
|
|
|
|
await stopExistingTunnel();
|
|
await ensureTunnelDir();
|
|
await ensureTunnelRuntimeDirs();
|
|
await fs.writeFile(getLogFilePath(), "", "utf8");
|
|
|
|
await writeStateFile({
|
|
binaryPath: binary.binaryPath,
|
|
installSource: binary.source,
|
|
ownerPid: process.pid,
|
|
pid: null,
|
|
publicUrl: null,
|
|
apiUrl: null,
|
|
targetUrl,
|
|
status: "starting",
|
|
lastError: null,
|
|
startedAt: new Date().toISOString(),
|
|
});
|
|
|
|
const child = spawn(binary.binaryPath as string, getCloudflaredStartArgs(targetUrl), {
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
env: buildCloudflaredChildEnv(),
|
|
});
|
|
|
|
tunnelProcess = child;
|
|
tunnelPid = child.pid ?? null;
|
|
|
|
if (!child.pid) {
|
|
throw new Error("cloudflared failed to start");
|
|
}
|
|
|
|
await writePidFile(child.pid);
|
|
await updateStateFile({ pid: child.pid, status: "starting" });
|
|
|
|
const ready = await new Promise<CloudflaredTunnelStatus>((resolve, reject) => {
|
|
let settled = false;
|
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const settle = (handler: () => void) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (timeout) clearTimeout(timeout);
|
|
handler();
|
|
};
|
|
|
|
const handleOutput = async (source: "stdout" | "stderr", chunk: Buffer) => {
|
|
const text = chunk.toString("utf8").trim();
|
|
if (!text) return;
|
|
|
|
await appendTunnelLog(source, text);
|
|
const errorMessage = source === "stderr" ? extractCloudflaredErrorMessage(text) : null;
|
|
if (errorMessage) {
|
|
await updateStateFile({
|
|
ownerPid: process.pid,
|
|
pid: child.pid,
|
|
status: "error",
|
|
lastError: errorMessage,
|
|
});
|
|
}
|
|
const url = extractTryCloudflareUrl(text);
|
|
if (!url) return;
|
|
|
|
const apiUrl = getTunnelApiUrl(url);
|
|
await updateStateFile({
|
|
ownerPid: process.pid,
|
|
pid: child.pid,
|
|
publicUrl: url,
|
|
apiUrl,
|
|
status: "running",
|
|
lastError: null,
|
|
});
|
|
|
|
const status = await getCloudflaredTunnelStatus();
|
|
settle(() => resolve(status));
|
|
};
|
|
|
|
child.stdout.on("data", (chunk: Buffer) => {
|
|
void handleOutput("stdout", chunk);
|
|
});
|
|
child.stderr.on("data", (chunk: Buffer) => {
|
|
void handleOutput("stderr", chunk);
|
|
});
|
|
|
|
child.once("exit", (code, signal) => {
|
|
void finalizeProcessExit(code, signal);
|
|
settle(() =>
|
|
reject(
|
|
new Error(
|
|
`cloudflared exited before tunnel URL was ready (${code ?? "signal"}${signal ? `/${signal}` : ""})`
|
|
)
|
|
)
|
|
);
|
|
});
|
|
|
|
timeout = setTimeout(async () => {
|
|
await stopExistingTunnel();
|
|
settle(() => reject(new Error("Timed out while waiting for Cloudflare tunnel URL")));
|
|
}, START_TIMEOUT_MS);
|
|
});
|
|
|
|
return ready;
|
|
})();
|
|
|
|
try {
|
|
return await startPromise;
|
|
} catch (error) {
|
|
const currentState = await readStateFile();
|
|
const message = isSpecificCloudflaredError(currentState.lastError)
|
|
? currentState.lastError
|
|
: error instanceof Error
|
|
? error.message
|
|
: "Failed to start cloudflared tunnel";
|
|
|
|
await updateStateFile({
|
|
ownerPid: process.pid,
|
|
status: "error",
|
|
lastError: message,
|
|
});
|
|
throw new Error(message);
|
|
} finally {
|
|
startPromise = null;
|
|
}
|
|
}
|
|
|
|
export async function stopCloudflaredTunnel() {
|
|
await stopExistingTunnel();
|
|
const current = await readStateFile();
|
|
await writeStateFile({
|
|
...buildStoppedState(current, !!(await resolveBinary()).binaryPath),
|
|
ownerPid: null,
|
|
});
|
|
tunnelProcess = null;
|
|
tunnelPid = null;
|
|
await clearPidFile();
|
|
return getCloudflaredTunnelStatus();
|
|
}
|