Spaces:
Paused
Paused
| import { spawn, execFile } from "child_process"; | |
| import { createHash } from "crypto"; | |
| 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_API_URL = | |
| "https://api.github.com/repos/cloudflare/cloudflared/releases/latest"; | |
| 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"; | |
| }; | |
| type ResolvedAssetSpec = AssetSpec & { | |
| downloadUrl: string; | |
| expectedSha256: 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, AssetSpec>> = { | |
| 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; | |
| } | |
| export function getSha256FromGitHubDigest(digest: string): string | null { | |
| const prefix = "sha256:"; | |
| if (!digest.toLowerCase().startsWith(prefix)) return null; | |
| const value = digest.slice(prefix.length); | |
| if (value.length !== 64) return null; | |
| for (const char of value) { | |
| const code = char.charCodeAt(0); | |
| const digit = code >= 48 && code <= 57; | |
| const lowerHex = code >= 97 && code <= 102; | |
| const upperHex = code >= 65 && code <= 70; | |
| if (!digit && !lowerHex && !upperHex) return null; | |
| } | |
| return value.toLowerCase(); | |
| } | |
| export function verifyCloudflaredDownloadDigest( | |
| buffer: Buffer, | |
| expectedSha256: string, | |
| assetName = "cloudflared" | |
| ): void { | |
| const actualSha256 = createHash("sha256").update(buffer).digest("hex"); | |
| if (actualSha256 !== expectedSha256.toLowerCase()) { | |
| throw new Error( | |
| `cloudflared download checksum mismatch for ${assetName}: expected ${expectedSha256}, got ${actualSha256}` | |
| ); | |
| } | |
| } | |
| async function resolveCloudflaredDownloadSpec(spec: AssetSpec): Promise<ResolvedAssetSpec> { | |
| const response = await proxyFetch(CLOUDFLARED_RELEASE_API_URL, { | |
| headers: { Accept: "application/vnd.github+json" }, | |
| redirect: "follow", | |
| }); | |
| if (!response.ok) { | |
| throw new Error( | |
| `Failed to resolve cloudflared release metadata with status ${response.status}` | |
| ); | |
| } | |
| const release = (await response.json()) as { assets?: unknown }; | |
| const assets = Array.isArray(release.assets) ? release.assets : []; | |
| const asset = assets | |
| .map((entry) => | |
| entry && typeof entry === "object" ? (entry as Record<string, unknown>) : null | |
| ) | |
| .find((entry) => entry?.name === spec.assetName); | |
| if (!asset) { | |
| throw new Error(`cloudflared release asset not found: ${spec.assetName}`); | |
| } | |
| const downloadUrl = | |
| typeof asset.browser_download_url === "string" ? asset.browser_download_url : ""; | |
| const digest = typeof asset.digest === "string" ? asset.digest : ""; | |
| const expectedSha256 = getSha256FromGitHubDigest(digest); | |
| if (!downloadUrl || !expectedSha256) { | |
| throw new Error(`cloudflared release asset ${spec.assetName} is missing a sha256 digest`); | |
| } | |
| return { | |
| ...spec, | |
| downloadUrl, | |
| expectedSha256, | |
| }; | |
| } | |
| 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, | |
| expectedSha256: string, | |
| assetName: 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()); | |
| verifyCloudflaredDownloadDigest(buffer, expectedSha256, assetName); | |
| 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 { | |
| const downloadSpec = await resolveCloudflaredDownloadSpec(spec); | |
| await downloadToFile( | |
| downloadSpec.downloadUrl, | |
| tempDownloadPath, | |
| downloadSpec.expectedSha256, | |
| downloadSpec.assetName | |
| ); | |
| 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(); | |
| } | |