openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
203 lines (202 loc) • 8.29 kB
JavaScript
import { m as clampTimerTimeoutMs, w as parseStrictPositiveInteger } from "./number-coercion-CLj0HTDM.js";
import { n as isTruthyEnvValue } from "./env-M3R40TOb.js";
import { n as parseTcpPort } from "./tcp-port-C3gLZtJi.js";
import { n as runExec } from "./exec-BIE-3oLG.js";
import { n as pickPrimaryTailnetIPv4, r as pickPrimaryTailnetIPv6 } from "./tailnet-CVdlmJeO.js";
import { r as getTailnetHostname } from "./tailscale-Boe4Jx-9.js";
import { a as resolveWideAreaDiscoveryDomain, o as writeWideAreaGatewayZone } from "./widearea-dns-rDnHQdmm.js";
import fs from "node:fs";
import path from "node:path";
//#region src/gateway/server-discovery.ts
/** Formats the Bonjour instance name while preserving user-provided OpenClaw names. */
function formatBonjourInstanceName(displayName) {
const trimmed = displayName.trim();
if (!trimmed) return "OpenClaw";
if (/openclaw/i.test(trimmed)) return trimmed;
return `${trimmed} (OpenClaw)`;
}
/** Resolves the CLI path advertised to Bonjour clients, preferring explicit env config. */
function resolveBonjourCliPath(opts = {}) {
const envPath = (opts.env ?? process.env).OPENCLAW_CLI_PATH?.trim();
if (envPath) return envPath;
const statSync = opts.statSync ?? fs.statSync;
const isFile = (candidate) => {
try {
return statSync(candidate).isFile();
} catch {
return false;
}
};
const execPath = opts.execPath ?? process.execPath;
const execDir = path.dirname(execPath);
const siblingCli = path.join(execDir, "openclaw");
if (isFile(siblingCli)) return siblingCli;
const argvPath = (opts.argv ?? process.argv)[1];
if (argvPath && isFile(argvPath)) return argvPath;
const cwd = opts.cwd ?? process.cwd();
const distCli = path.join(cwd, "dist", "index.js");
if (isFile(distCli)) return distCli;
const binCli = path.join(cwd, "bin", "openclaw");
if (isFile(binCli)) return binCli;
}
/** Resolves a Tailnet DNS hint from env or the local tailscale CLI when enabled. */
async function resolveTailnetDnsHint(opts) {
const envRaw = (opts?.env ?? process.env).OPENCLAW_TAILNET_DNS?.trim();
const envValue = envRaw && envRaw.length > 0 ? envRaw.replace(/\.$/, "") : "";
if (envValue) return envValue;
if (opts?.enabled === false) return;
const exec = opts?.exec ?? ((command, args) => runExec(command, args, {
timeoutMs: 1500,
maxBuffer: 2e5
}));
try {
return await getTailnetHostname(exec);
} catch {
return;
}
}
//#endregion
//#region src/gateway/server-discovery-runtime.ts
/** One owner replaces local advertisements and keeps wide-area TXT policy in sync. */
async function startGatewayDiscovery(params) {
let mode = params.discovery?.mdns?.mode ?? "minimal";
const wideAreaDomain = params.discovery?.wideArea?.domain;
let services = params.gatewayDiscoveryServices ?? [];
let claim = params.pluginRuntimeClaim;
let current;
let closed = false;
let pending = Promise.resolve();
let cleanup = Promise.resolve();
const isCurrent = (generation) => !closed && current === generation;
const stopService = (stop) => {
cleanup = cleanup.then(stop).catch((err) => {
params.logDiscovery.warn(`gateway discovery stop failed: ${String(err)}`);
});
return cleanup;
};
const stopGeneration = async (generation) => {
for (const stop of generation?.stops.splice(0).toReversed() ?? []) stopService(stop);
let drained;
do {
drained = cleanup;
await drained;
} while (drained !== cleanup);
};
const enqueue = (operation) => {
const result = pending.then(operation);
pending = result.catch(() => {});
return result;
};
const waitForClaim = (generation) => {
if (generation.waiting) return;
generation.waiting = true;
generation.claim.waitForUnblocked().then((accepted) => enqueue(async () => {
generation.waiting = false;
if (isCurrent(generation)) await (accepted ? advertise(generation) : stopGeneration(generation));
})).catch((err) => params.logDiscovery.warn(`gateway discovery refresh failed: ${String(err)}`));
};
const advertise = async (generation) => {
if (!isCurrent(generation)) return;
const localEnabled = generation.mode !== "off" && !isTruthyEnvValue(process.env.OPENCLAW_DISABLE_BONJOUR) && !process.env.VITEST;
const minimal = generation.mode !== "full";
const tailnetDns = localEnabled || wideAreaDomain?.trim() ? await resolveTailnetDnsHint({ enabled: params.tailscaleMode !== "off" }) : void 0;
if (!isCurrent(generation)) return;
const context = {
machineDisplayName: params.machineDisplayName,
gatewayPort: params.port,
gatewayTlsEnabled: params.gatewayTls?.enabled ?? false,
gatewayTlsFingerprintSha256: params.gatewayTls?.fingerprintSha256,
gatewayDirectReachable: params.gatewayDirectReachable === true,
sshPort: minimal ? void 0 : parseTcpPort(process.env.OPENCLAW_SSH_PORT) ?? void 0,
tailnetDns,
cliPath: minimal ? void 0 : resolveBonjourCliPath(),
minimal
};
if (wideAreaDomain?.trim()) {
const domain = resolveWideAreaDiscoveryDomain({ configDomain: wideAreaDomain });
const tailnetIPv4 = pickPrimaryTailnetIPv4();
if (!domain) params.logDiscovery.warn("wide-area discovery was requested without a domain; set discovery.wideArea.domain to enable unicast DNS-SD");
else if (!tailnetIPv4) params.logDiscovery.warn("discovery.wideArea.domain is set, but no Tailscale IPv4 address was found; skipping unicast DNS-SD zone update");
else try {
const result = await writeWideAreaGatewayZone({
...context,
domain,
displayName: formatBonjourInstanceName(params.machineDisplayName),
tailnetIPv4,
tailnetIPv6: pickPrimaryTailnetIPv6() ?? void 0
});
params.logDiscovery.info(`wide-area DNS-SD ${result.changed ? "updated" : "unchanged"} (${domain} → ${result.zonePath})`);
} catch (err) {
params.logDiscovery.warn(`wide-area discovery update failed: ${String(err)}`);
}
}
const advertiseTimeoutMs = clampTimerTimeoutMs(parseStrictPositiveInteger(process.env.OPENCLAW_GATEWAY_DISCOVERY_ADVERTISE_TIMEOUT_MS?.trim())) ?? 5e3;
for (;;) {
const drained = cleanup;
await drained;
if (drained !== cleanup) continue;
if (!localEnabled || !isCurrent(generation)) return;
if (!generation.claim.isCurrent()) {
waitForClaim(generation);
return;
}
const next = generation.services.next();
if (next.done) return;
const entry = next.value;
let timedOut = false;
let timer;
const started = (async () => entry.service.advertise(context))().then(async (handle) => {
if (handle?.stop) {
if (isCurrent(generation)) {
generation.stops.push(handle.stop);
if (!generation.claim.isCurrent()) waitForClaim(generation);
} else await stopService(handle.stop);
}
if (timedOut) params.logDiscovery.warn(`gateway discovery service completed after startup timeout (${entry.service.id}, plugin=${entry.pluginId})`);
}).catch((err) => {
params.logDiscovery.warn(`gateway discovery service failed${timedOut ? " after startup timeout" : ""} (${entry.service.id}, plugin=${entry.pluginId}): ${String(err)}`);
});
await Promise.race([started, new Promise((resolve) => {
timer = setTimeout(() => {
timedOut = true;
params.logDiscovery.warn(`gateway discovery service timed out after ${advertiseTimeoutMs}ms (${entry.service.id}, plugin=${entry.pluginId}); continuing startup`);
resolve();
}, advertiseTimeoutMs);
timer.unref?.();
})]);
clearTimeout(timer);
}
};
const update = (next, nextClaim = claim) => {
const nextMode = "mdnsMode" in next ? next.mdnsMode ?? "minimal" : mode;
const nextServices = next.gatewayDiscoveryServices ?? services;
if (closed || current && mode === nextMode && services === nextServices && claim === nextClaim) return Promise.resolve();
mode = nextMode;
services = nextServices;
claim = nextClaim;
const previous = current;
const generation = current = {
mode,
services: services.values(),
stops: [],
claim,
waiting: false
};
return enqueue(async () => {
await stopGeneration(previous);
await advertise(generation);
});
};
await update({});
return {
update,
stop: () => {
closed = true;
const previous = current;
current = void 0;
return enqueue(() => stopGeneration(previous));
}
};
}
//#endregion
export { startGatewayDiscovery };