UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

171 lines • 6.78 kB
import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { requireOptionalDep } from "../utils/optional-dep.js"; import { logger } from "../utils/logger.js"; async function loadCloudflared() { return requireOptionalDep("cloudflared", { feature: "Cloudflare quick tunnels (experimental agent panel)", installHint: "npm install cloudflared", }); } // --------------------------------------------------------------------------- // Cloudflared quick-tunnel helper. // // The binary-ensure + `Tunnel.quick(...)` mechanic is adapted (nearly verbatim) // from the MIT-licensed Ungate project's tunnel-manager: // https://github.com/orchidfiles/ungate // apps/extension/src/tunnel-manager.ts // // This is part of the experimental embedded-agent-panel POC (see // design/embedded-agent-panel.md). It is NOT wired into the default MCP // stdio/HTTP startup path — it is only reached behind the // COMFYUI_MCP_AGENT_POC flag via src/experimental/agent-poc.ts. // --------------------------------------------------------------------------- // Where we cache a downloaded cloudflared binary when the bundled `bin` is // missing (mirrors Ungate's `~/.ungate/bin` location). const CLOUDFLARED_BIN_DIR = path.join(os.homedir(), ".comfyui-mcp", "bin"); function getCloudflaredBinPath() { return path.join(CLOUDFLARED_BIN_DIR, process.platform === "win32" ? "cloudflared.exe" : "cloudflared"); } // Quick tunnels do not use a config file. Pointing --config at the platform // null device prevents cloudflared from picking up an ambient config. function getCloudflaredConfigArg() { return process.platform === "win32" ? "NUL" : "/dev/null"; } /** * Ensure a cloudflared binary is available for the `cloudflared` package to * spawn. If the bundled `bin` exists we use it as-is; otherwise we reuse a * previously-downloaded binary in CLOUDFLARED_BIN_DIR, or download one. * * Ported from Ungate's `ensureBinary` (simplified — no legacy-path rename). */ async function ensureBinary() { const { bin, install, use } = await loadCloudflared(); if (fs.existsSync(bin)) { return; } const userBinPath = getCloudflaredBinPath(); if (fs.existsSync(userBinPath)) { use(userBinPath); return; } logger.info("[tunnel] Downloading cloudflared binary..."); fs.mkdirSync(CLOUDFLARED_BIN_DIR, { recursive: true }); const installedPath = await install(userBinPath); use(installedPath); logger.info("[tunnel] cloudflared installed successfully"); } /** * Start a Cloudflare quick tunnel that exposes the local `<host>:<port>` on a * public HTTPS URL. Resolves once the `url` event fires; rejects if the * cloudflared process errors or exits before becoming ready. * * @param port Local port to expose (e.g. the POC chat server's port). * @param host Local host to point cloudflared at (default "localhost"). Pass * "127.0.0.1" to avoid a localhost→::1 resolution when the origin is bound * IPv4-only (the loopback UI bridge is). */ export async function startQuickTunnel(port, host = "localhost") { const state = { status: "starting", url: null, error: null }; await ensureBinary(); const { Tunnel } = await loadCloudflared(); // A wildcard is a bind address, not a useful origin for cloudflared. In // particular, `localhost` can resolve to IPv6 while a 0.0.0.0 server is // listening only on IPv4, so always route wildcard binds through IPv4 // loopback. const originHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; const t = Tunnel.quick(`http://${originHost}:${port}`, { "--config": getCloudflaredConfigArg(), "--edge-ip-version": "4", }); return await new Promise((resolve, reject) => { let settled = false; // Detach all listeners so nothing leaks once we've settled the promise // (especially on the pre-ready failure paths, where the caller never gets // a handle to call stop()). const teardownListeners = () => { t.off("url", onUrl); t.off("stderr", onStderr); t.off("error", onError); t.off("exit", onExit); }; const stop = () => { try { t.stop(); } catch { // Process already gone — fine. } teardownListeners(); state.status = "stopped"; state.url = null; state.error = null; }; function onUrl(url) { logger.info(`[tunnel] URL: ${url}`); state.status = "running"; state.url = url; state.error = null; if (!settled) { settled = true; resolve({ url, getState: () => ({ ...state }), stop, }); } } function onStderr(data) { for (const line of data.split("\n")) { if (line.trim()) logger.info(`[tunnel] ${line}`); } } function onError(err) { const message = err.message; logger.error(`[tunnel] error: ${message}`); state.status = "error"; state.url = null; state.error = message; if (!settled) { settled = true; // Pre-ready failure: stop the process and detach listeners so a // cloudflared that errors without exiting can't leak. try { t.stop(); } catch { // Already gone — fine. } teardownListeners(); reject(err); } } function onExit(code, signal) { logger.warn(`[tunnel] exited code=${code} signal=${signal}`); // An exit before the `url` event means the tunnel never came up. if (!settled) { settled = true; state.status = "error"; state.url = null; state.error = `cloudflared exited before tunnel was ready (code=${code})`; teardownListeners(); reject(new Error(state.error)); } else if (state.status !== "stopped") { state.status = "stopped"; state.url = null; } } t.on("url", onUrl); t.on("stderr", onStderr); t.on("error", onError); t.on("exit", onExit); }); } //# sourceMappingURL=tunnel.js.map