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
JavaScript
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