feat(FN-1222): add distributed node discovery APIs
- Add discovery configuration and node metadata types in core, including exported interfaces for consumers - Implement a NodeDiscovery service and wire it into CentralCore with lifecycle hooks and discovery helpers - Add comprehensive unit coverage for NodeDiscovery behavior and CentralCore discovery integration - Expose discovery REST endpoints in dashboard routes for config, node listing, and summary/health data - Add frontend discovery API helpers and route-level tests, plus dependency updates in lockfiles
This commit is contained in:
445
packages/core/src/node-discovery.test.ts
Normal file
445
packages/core/src/node-discovery.test.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import type { DiscoveryConfig, DiscoveredNode } from "./types.js";
|
||||
|
||||
interface MockBrowser {
|
||||
on: ReturnType<typeof vi.fn>;
|
||||
off: ReturnType<typeof vi.fn>;
|
||||
stop: ReturnType<typeof vi.fn>;
|
||||
emit: (event: string, ...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
const { BonjourMock, publishMock, findMock, destroyMock } = vi.hoisted(() => ({
|
||||
BonjourMock: vi.fn(),
|
||||
publishMock: vi.fn(),
|
||||
findMock: vi.fn(),
|
||||
destroyMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("bonjour-service", () => ({
|
||||
Bonjour: BonjourMock,
|
||||
default: BonjourMock,
|
||||
}));
|
||||
|
||||
import { NodeDiscovery } from "./node-discovery.js";
|
||||
|
||||
function createMockBrowser(): MockBrowser {
|
||||
const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
|
||||
|
||||
const browser: MockBrowser = {
|
||||
on: vi.fn((event: string, callback: (...args: unknown[]) => void) => {
|
||||
const callbacks = listeners.get(event) ?? new Set();
|
||||
callbacks.add(callback);
|
||||
listeners.set(event, callbacks);
|
||||
return browser;
|
||||
}),
|
||||
off: vi.fn((event: string, callback: (...args: unknown[]) => void) => {
|
||||
listeners.get(event)?.delete(callback);
|
||||
return browser;
|
||||
}),
|
||||
stop: vi.fn(),
|
||||
emit(event: string, ...args: unknown[]) {
|
||||
for (const callback of listeners.get(event) ?? []) {
|
||||
callback(...args);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return browser;
|
||||
}
|
||||
|
||||
function defaultConfig(overrides: Partial<DiscoveryConfig> = {}): DiscoveryConfig {
|
||||
return {
|
||||
broadcast: false,
|
||||
listen: false,
|
||||
serviceType: "_fusion._tcp",
|
||||
port: 4040,
|
||||
staleTimeoutMs: 300_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createService(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
name: "peer-node",
|
||||
port: 4040,
|
||||
addresses: ["192.168.1.200"],
|
||||
txt: {
|
||||
nodeType: "remote",
|
||||
nodeId: "node_remote_1",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("NodeDiscovery", () => {
|
||||
let browser: MockBrowser;
|
||||
let publishService: { stop: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
|
||||
browser = createMockBrowser();
|
||||
publishService = { stop: vi.fn() };
|
||||
|
||||
publishMock.mockReturnValue(publishService);
|
||||
findMock.mockReturnValue(browser);
|
||||
destroyMock.mockReturnValue(undefined);
|
||||
BonjourMock.mockImplementation(() => ({
|
||||
publish: publishMock,
|
||||
find: findMock,
|
||||
destroy: destroyMock,
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("starts and stops broadcast mode", () => {
|
||||
const discovery = new NodeDiscovery(defaultConfig({ broadcast: true }));
|
||||
discovery.start("node_local_1", "Local Node");
|
||||
|
||||
expect(publishMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "Local Node",
|
||||
type: "fusion",
|
||||
protocol: "tcp",
|
||||
port: 4040,
|
||||
txt: expect.objectContaining({
|
||||
nodeType: "local",
|
||||
nodeId: "node_local_1",
|
||||
version: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
discovery.stop();
|
||||
expect(publishService.stop).toHaveBeenCalledTimes(1);
|
||||
expect(destroyMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to hostname when broadcast nodeName is empty", () => {
|
||||
const discovery = new NodeDiscovery(defaultConfig({ broadcast: true }));
|
||||
discovery.start("node_local_1", " ");
|
||||
|
||||
expect(publishMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("starts listen mode and emits node:discovered/node:lost", () => {
|
||||
const discovery = new NodeDiscovery(defaultConfig({ listen: true }));
|
||||
const discoveredHandler = vi.fn();
|
||||
const lostHandler = vi.fn();
|
||||
|
||||
discovery.on("node:discovered", discoveredHandler);
|
||||
discovery.on("node:lost", lostHandler);
|
||||
|
||||
discovery.start("node_local_1", "Local");
|
||||
|
||||
expect(findMock).toHaveBeenCalledWith({
|
||||
type: "fusion",
|
||||
protocol: "tcp",
|
||||
});
|
||||
|
||||
browser.emit("up", createService());
|
||||
|
||||
expect(discoveredHandler).toHaveBeenCalledTimes(1);
|
||||
expect(discoveredHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "peer-node",
|
||||
host: "192.168.1.200",
|
||||
port: 4040,
|
||||
nodeType: "remote",
|
||||
nodeId: "node_remote_1",
|
||||
discoveredAt: expect.any(String),
|
||||
lastSeenAt: expect.any(String),
|
||||
}),
|
||||
);
|
||||
|
||||
browser.emit("down", createService());
|
||||
expect(lostHandler).toHaveBeenCalledWith("peer-node");
|
||||
});
|
||||
|
||||
it("ignores down events for unknown services", () => {
|
||||
const discovery = new NodeDiscovery(defaultConfig({ listen: true }));
|
||||
const lostHandler = vi.fn();
|
||||
discovery.on("node:lost", lostHandler);
|
||||
|
||||
discovery.start("node_local_1", "Local");
|
||||
browser.emit("down", createService({ name: "missing-node" }));
|
||||
|
||||
expect(lostHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits node:updated for already known services", () => {
|
||||
const discovery = new NodeDiscovery(defaultConfig({ listen: true }));
|
||||
const updatedHandler = vi.fn();
|
||||
|
||||
discovery.on("node:updated", updatedHandler);
|
||||
discovery.start("node_local_1", "Local");
|
||||
|
||||
browser.emit("up", createService());
|
||||
browser.emit("up", createService({ addresses: ["192.168.1.201"] }));
|
||||
|
||||
expect(updatedHandler).toHaveBeenCalledTimes(1);
|
||||
expect(updatedHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ host: "192.168.1.201" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("self-filters discovery events with matching nodeId", () => {
|
||||
const discovery = new NodeDiscovery(defaultConfig({ listen: true }));
|
||||
const discoveredHandler = vi.fn();
|
||||
|
||||
discovery.on("node:discovered", discoveredHandler);
|
||||
discovery.start("node_local_1", "Local");
|
||||
|
||||
browser.emit(
|
||||
"up",
|
||||
createService({
|
||||
txt: {
|
||||
nodeType: "local",
|
||||
nodeId: "node_local_1",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(discoveredHandler).not.toHaveBeenCalled();
|
||||
expect(discovery.getDiscoveredNodes()).toEqual([]);
|
||||
});
|
||||
|
||||
it("supports combined broadcast + listen mode", () => {
|
||||
const discovery = new NodeDiscovery(defaultConfig({ broadcast: true, listen: true }));
|
||||
|
||||
discovery.start("node_local_1", "Local Node");
|
||||
|
||||
expect(publishMock).toHaveBeenCalledTimes(1);
|
||||
expect(findMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cleans up stale nodes after staleTimeoutMs", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-08T12:00:00.000Z"));
|
||||
|
||||
const discovery = new NodeDiscovery(defaultConfig({ listen: true, staleTimeoutMs: 1_000 }));
|
||||
const lostHandler = vi.fn();
|
||||
|
||||
discovery.on("node:lost", lostHandler);
|
||||
discovery.start("node_local_1", "Local");
|
||||
|
||||
browser.emit("up", createService({ name: "stale-node" }));
|
||||
expect(discovery.getDiscoveredNode("stale-node")).toBeDefined();
|
||||
|
||||
vi.advanceTimersByTime(61_000);
|
||||
|
||||
expect(discovery.getDiscoveredNode("stale-node")).toBeUndefined();
|
||||
expect(lostHandler).toHaveBeenCalledWith("stale-node");
|
||||
});
|
||||
|
||||
it("stop() is idempotent", () => {
|
||||
const discovery = new NodeDiscovery(defaultConfig({ broadcast: true, listen: true }));
|
||||
|
||||
discovery.start("node_local_1", "Local");
|
||||
expect(() => discovery.stop()).not.toThrow();
|
||||
expect(() => discovery.stop()).not.toThrow();
|
||||
|
||||
expect(publishService.stop).toHaveBeenCalledTimes(1);
|
||||
expect(browser.stop).toHaveBeenCalledTimes(1);
|
||||
expect(destroyMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("startBroadcast() and startListening() are idempotent", () => {
|
||||
const discovery = new NodeDiscovery(defaultConfig({ broadcast: true, listen: true }));
|
||||
|
||||
discovery.startBroadcast("node_local_1", "Local");
|
||||
discovery.startBroadcast("node_local_1", "Local");
|
||||
discovery.startListening();
|
||||
discovery.startListening();
|
||||
|
||||
expect(publishMock).toHaveBeenCalledTimes(1);
|
||||
expect(findMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("continues when broadcast publish throws and emits error", () => {
|
||||
const error = new Error("multicast unavailable");
|
||||
publishMock.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
const discovery = new NodeDiscovery(defaultConfig({ broadcast: true }));
|
||||
const errorHandler = vi.fn();
|
||||
|
||||
discovery.on("error", errorHandler);
|
||||
|
||||
expect(() => discovery.start("node_local_1", "Local")).not.toThrow();
|
||||
expect(errorHandler).toHaveBeenCalledWith(error);
|
||||
});
|
||||
|
||||
it("continues when listener startup throws and emits error", () => {
|
||||
const error = new Error("listen failed");
|
||||
findMock.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
const discovery = new NodeDiscovery(defaultConfig({ listen: true }));
|
||||
const errorHandler = vi.fn();
|
||||
|
||||
discovery.on("error", errorHandler);
|
||||
|
||||
expect(() => discovery.start("node_local_1", "Local")).not.toThrow();
|
||||
expect(errorHandler).toHaveBeenCalledWith(error);
|
||||
});
|
||||
|
||||
it("returns discovered nodes and specific discovered node by name", () => {
|
||||
const discovery = new NodeDiscovery(defaultConfig({ listen: true }));
|
||||
|
||||
expect(discovery.getDiscoveredNodes()).toEqual([]);
|
||||
expect(discovery.getDiscoveredNode("missing")).toBeUndefined();
|
||||
|
||||
discovery.start("node_local_1", "Local");
|
||||
browser.emit("up", createService({ name: "peer-a" }));
|
||||
browser.emit("up", createService({ name: "peer-b", addresses: ["192.168.1.201"] }));
|
||||
|
||||
const nodes = discovery.getDiscoveredNodes();
|
||||
expect(nodes).toHaveLength(2);
|
||||
expect(nodes.map((node) => node.name).sort()).toEqual(["peer-a", "peer-b"]);
|
||||
|
||||
expect(discovery.getDiscoveredNode("peer-b")).toEqual(
|
||||
expect.objectContaining({ host: "192.168.1.201" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults nodeType to local when TXT nodeType is missing", () => {
|
||||
const discovery = new NodeDiscovery(defaultConfig({ listen: true }));
|
||||
const discoveredHandler = vi.fn();
|
||||
|
||||
discovery.on("node:discovered", discoveredHandler);
|
||||
discovery.start("node_local_1", "Local");
|
||||
|
||||
browser.emit(
|
||||
"up",
|
||||
createService({
|
||||
txt: {
|
||||
nodeId: "node_remote",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(discoveredHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ nodeType: "local" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses fallback host resolution paths", () => {
|
||||
const discovery = new NodeDiscovery(defaultConfig({ listen: true }));
|
||||
const discoveredHandler = vi.fn();
|
||||
|
||||
discovery.on("node:discovered", discoveredHandler);
|
||||
discovery.start("node_local_1", "Local");
|
||||
|
||||
browser.emit(
|
||||
"up",
|
||||
createService({
|
||||
addresses: ["fe80::1"],
|
||||
referer: { address: "10.0.0.9" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(discoveredHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ host: "10.0.0.9" }),
|
||||
);
|
||||
|
||||
browser.emit(
|
||||
"up",
|
||||
createService({
|
||||
name: "referer-only",
|
||||
addresses: [],
|
||||
referer: { address: "10.0.0.10" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(discovery.getDiscoveredNode("referer-only")).toEqual(
|
||||
expect.objectContaining({ host: "10.0.0.10" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("handles service type parsing for short format and uppercase", () => {
|
||||
const shortTypeDiscovery = new NodeDiscovery(defaultConfig({ broadcast: true, serviceType: "fusion" }));
|
||||
shortTypeDiscovery.start("node_local_1", "Local");
|
||||
|
||||
expect(publishMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "fusion", protocol: "tcp" }),
|
||||
);
|
||||
|
||||
const uppercaseDiscovery = new NodeDiscovery(
|
||||
defaultConfig({
|
||||
listen: true,
|
||||
serviceType: "_FUSION._TCP",
|
||||
}),
|
||||
);
|
||||
uppercaseDiscovery.start("node_local_1", "Local");
|
||||
|
||||
expect(findMock).toHaveBeenCalledWith({ type: "fusion", protocol: "tcp" });
|
||||
});
|
||||
|
||||
it("stringifies numeric and boolean TXT values", () => {
|
||||
const discovery = new NodeDiscovery(defaultConfig({ listen: true }));
|
||||
const discoveredHandler = vi.fn();
|
||||
|
||||
discovery.on("node:discovered", discoveredHandler);
|
||||
discovery.start("node_local_1", "Local");
|
||||
|
||||
browser.emit(
|
||||
"up",
|
||||
createService({
|
||||
txt: {
|
||||
nodeType: true,
|
||||
nodeId: 1234,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(discoveredHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ nodeId: "1234", nodeType: "local" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not emit discovery when host resolution fails", () => {
|
||||
const discovery = new NodeDiscovery(defaultConfig({ listen: true }));
|
||||
const discoveredHandler = vi.fn();
|
||||
|
||||
discovery.on("node:discovered", discoveredHandler);
|
||||
discovery.start("node_local_1", "Local");
|
||||
|
||||
browser.emit(
|
||||
"up",
|
||||
createService({
|
||||
name: "no-host",
|
||||
addresses: [],
|
||||
referer: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(discoveredHandler).not.toHaveBeenCalled();
|
||||
expect(discovery.getDiscoveredNode("no-host")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits discovery start/stop lifecycle events", () => {
|
||||
const discovery = new NodeDiscovery(defaultConfig({ broadcast: true }));
|
||||
const startedHandler = vi.fn();
|
||||
const stoppedHandler = vi.fn();
|
||||
|
||||
discovery.on("discovery:started", startedHandler);
|
||||
discovery.on("discovery:stopped", stoppedHandler);
|
||||
|
||||
discovery.start("node_local_1", "Local");
|
||||
discovery.stop();
|
||||
|
||||
expect(startedHandler).toHaveBeenCalledTimes(1);
|
||||
expect(stoppedHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user