feat(discovery): bonjour beacons + bridge presence

This commit is contained in:
Peter Steinberger
2025-12-13 04:28:12 +00:00
parent 3ee0e041fa
commit 1f37d94f9e
49 changed files with 1182 additions and 320 deletions

View File

@@ -12,6 +12,28 @@ import { emitHeartbeatEvent } from "../infra/heartbeat-events.js";
import { PROTOCOL_VERSION } from "./protocol/index.js";
import { startGatewayServer } from "./server.js";
type BridgeClientInfo = {
nodeId: string;
displayName?: string;
platform?: string;
version?: string;
remoteIp?: string;
};
type BridgeStartOpts = {
onAuthenticated?: (node: BridgeClientInfo) => Promise<void> | void;
onDisconnected?: (node: BridgeClientInfo) => Promise<void> | void;
onPairRequested?: (request: unknown) => Promise<void> | void;
};
const bridgeStartCalls = vi.hoisted(() => [] as BridgeStartOpts[]);
vi.mock("../infra/bridge/server.js", () => ({
startNodeBridgeServer: vi.fn(async (opts: BridgeStartOpts) => {
bridgeStartCalls.push(opts);
return { port: 0, close: async () => {} };
}),
}));
let testSessionStorePath: string | undefined;
let testAllowFrom: string[] | undefined;
let testCronStorePath: string | undefined;
@@ -324,6 +346,75 @@ describe("gateway server", () => {
}
});
test("emits presence updates for bridge connect/disconnect", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
const prevHome = process.env.HOME;
process.env.HOME = homeDir;
try {
const before = bridgeStartCalls.length;
const { server, ws } = await startServerWithClient();
try {
await connectOk(ws);
const bridgeCall = bridgeStartCalls[before];
expect(bridgeCall).toBeTruthy();
const waitPresenceReason = async (reason: string) => {
await onceMessage(
ws,
(o) => {
if (o.type !== "event" || o.event !== "presence") return false;
const payload = o.payload as { presence?: unknown } | null;
const list = payload?.presence;
if (!Array.isArray(list)) return false;
return list.some(
(p) =>
typeof p === "object" &&
p !== null &&
(p as { instanceId?: unknown }).instanceId === "iris-1" &&
(p as { reason?: unknown }).reason === reason,
);
},
3000,
);
};
const presenceConnectedP = waitPresenceReason("iris-connected");
await bridgeCall?.onAuthenticated?.({
nodeId: "iris-1",
displayName: "Iris",
platform: "ios",
version: "1.0",
remoteIp: "10.0.0.10",
});
await presenceConnectedP;
const presenceDisconnectedP = waitPresenceReason("iris-disconnected");
await bridgeCall?.onDisconnected?.({
nodeId: "iris-1",
displayName: "Iris",
platform: "ios",
version: "1.0",
remoteIp: "10.0.0.10",
});
await presenceDisconnectedP;
} finally {
try {
ws.close();
} catch {
/* ignore */
}
await server.close();
await fs.rm(homeDir, { recursive: true, force: true });
}
} finally {
if (prevHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
}
});
test("supports cron.add and cron.list", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-cron-"));
testCronStorePath = path.join(dir, "cron.json");

View File

@@ -666,7 +666,66 @@ export async function startGatewayServer(
const started = await startNodeBridgeServer({
host: bridgeHost,
port: bridgePort,
onAuthenticated: (node) => {
const host = node.displayName?.trim() || node.nodeId;
const ip = node.remoteIp?.trim();
const version = node.version?.trim() || "unknown";
const text = `Node: ${host}${ip ? ` (${ip})` : ""} · app ${version} · last input 0s ago · mode remote · reason iris-connected`;
upsertPresence(node.nodeId, {
host,
ip,
version,
mode: "remote",
reason: "iris-connected",
lastInputSeconds: 0,
instanceId: node.nodeId,
text,
});
presenceVersion += 1;
broadcast(
"presence",
{ presence: listSystemPresence() },
{
dropIfSlow: true,
stateVersion: {
presence: presenceVersion,
health: healthVersion,
},
},
);
},
onDisconnected: (node) => {
const host = node.displayName?.trim() || node.nodeId;
const ip = node.remoteIp?.trim();
const version = node.version?.trim() || "unknown";
const text = `Node: ${host}${ip ? ` (${ip})` : ""} · app ${version} · last input 0s ago · mode remote · reason iris-disconnected`;
upsertPresence(node.nodeId, {
host,
ip,
version,
mode: "remote",
reason: "iris-disconnected",
lastInputSeconds: 0,
instanceId: node.nodeId,
text,
});
presenceVersion += 1;
broadcast(
"presence",
{ presence: listSystemPresence() },
{
dropIfSlow: true,
stateVersion: {
presence: presenceVersion,
health: healthVersion,
},
},
);
},
onEvent: handleBridgeEvent,
onPairRequested: (request) => {
broadcast("node.pair.requested", request, { dropIfSlow: true });
},
});
if (started.port > 0) {
bridge = started;
@@ -680,9 +739,22 @@ export async function startGatewayServer(
}
try {
const sshPortEnv = process.env.CLAWDIS_SSH_PORT?.trim();
const sshPortParsed = sshPortEnv ? Number.parseInt(sshPortEnv, 10) : NaN;
const sshPort =
Number.isFinite(sshPortParsed) && sshPortParsed > 0
? sshPortParsed
: undefined;
const tailnetDnsEnv = process.env.CLAWDIS_TAILNET_DNS?.trim();
const tailnetDns =
tailnetDnsEnv && tailnetDnsEnv.length > 0 ? tailnetDnsEnv : undefined;
const bonjour = await startGatewayBonjourAdvertiser({
gatewayPort: port,
bridgePort: bridge?.port,
sshPort,
tailnetDns,
});
bonjourStop = bonjour.stop;
} catch (err) {