@datafrog-io/n2n-nexus 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ /**
2
+ * CLI Argument Parsing
3
+ */
4
+ const args = process.argv.slice(2);
5
+ export function getArg(k) {
6
+ const i = args.indexOf(k);
7
+ return i !== -1 && args[i + 1] ? args[i + 1] : "";
8
+ }
9
+ export function hasFlag(k) {
10
+ return args.includes(k) || args.includes(k.charAt(1) === "-" ? k : k.substring(0, 2));
11
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Config Module - Central Configuration
3
+ */
4
+ import fs from "fs";
5
+ import path from "path";
6
+ import { fileURLToPath } from "url";
7
+ import { FILE_ENCODING, PACKAGE_JSON } from "../constants.js";
8
+ import { getArg, hasFlag } from "./cli.js";
9
+ import { getRootPath } from "./paths.js";
10
+ import { isHostAutoElection } from "../network/election.js";
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ // Load version from package.json
13
+ const pkgPath = path.resolve(__dirname, `../../${PACKAGE_JSON}`);
14
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, FILE_ENCODING));
15
+ export { pkg };
16
+ // --- CLI Commands Handlers ---
17
+ if (hasFlag("--help") || hasFlag("-h")) {
18
+ console.error(`
19
+ n2ns Nexus 🚀 - Local Digital Asset Hub (MCP Server) v${pkg.version}
20
+
21
+ USAGE:
22
+ npx -y @datafrog-io/n2n-nexus [options]
23
+
24
+ DESCRIPTION:
25
+ A local-first project management and collaboration hub designed for
26
+ multi-AI assistant coordination across different IDEs (Cursor, VS Code, etc.).
27
+
28
+ OPTIONS:
29
+ --root <path> Directory for data persistence. Default: ~/.n2n-nexus
30
+ --version, -v Show version number.
31
+ --help, -h Show this message.
32
+
33
+ MCP CONFIG EXAMPLE (claude_desktop_config.json):
34
+ {
35
+ "mcpServers": {
36
+ "n2n-nexus": {
37
+ "command": "npx",
38
+ "args": ["-y", "@datafrog-io/n2n-nexus", "--root", "/path/to/storage"]
39
+ }
40
+ }
41
+ }
42
+
43
+ ENVIRONMENT VARIABLES:
44
+ NEXUS_ROOT Override default storage path.
45
+ `);
46
+ process.exit(0);
47
+ }
48
+ if (hasFlag("--version") || hasFlag("-v")) {
49
+ console.error(pkg.version);
50
+ process.exit(0);
51
+ }
52
+ /**
53
+ * Automatic Project Name Detection
54
+ */
55
+ function getAutoProjectName() {
56
+ try {
57
+ const localPkgPath = path.join(process.cwd(), PACKAGE_JSON);
58
+ if (fs.existsSync(localPkgPath)) {
59
+ const localPkg = JSON.parse(fs.readFileSync(localPkgPath, FILE_ENCODING));
60
+ if (localPkg.name)
61
+ return localPkg.name.split("/").pop() || localPkg.name;
62
+ }
63
+ }
64
+ catch { /* ignore */ }
65
+ const base = path.basename(process.cwd()) || "Assistant";
66
+ const suffix = Math.random().toString(36).substring(2, 6);
67
+ return `${base}-${suffix}`;
68
+ }
69
+ // Run election at module load
70
+ const rootPath = getRootPath();
71
+ const election = await isHostAutoElection(rootPath);
72
+ const projectName = getAutoProjectName();
73
+ export const hostServer = election.server;
74
+ export const CONFIG = {
75
+ instanceId: getArg("--id") || projectName,
76
+ isHost: election.isHost,
77
+ rootStorage: election.isHost ? rootPath : (election.rootStorage || rootPath),
78
+ port: election.port
79
+ };
80
+ // Re-export for Guest reconnection
81
+ export { isHostAutoElection } from "../network/election.js";
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Path Resolution Logic
3
+ */
4
+ import path from "path";
5
+ import os from "os";
6
+ import { SERVICE_NAME } from "../constants.js";
7
+ import { getArg } from "./cli.js";
8
+ /**
9
+ * Normalize and resolve the root storage path
10
+ */
11
+ export function normalizeRootPath(inputPath) {
12
+ // Priority: CLI --root > ENV NEXUS_ROOT > System Default
13
+ let root = inputPath || process.env.NEXUS_ROOT || getDefaultDataDir();
14
+ // Resolve ~ to home directory
15
+ if (root.startsWith("~")) {
16
+ root = path.join(os.homedir(), root.slice(1));
17
+ }
18
+ // Cross-platform adaptation (WSL <-> Windows)
19
+ if (process.platform === "linux" && /^[a-zA-Z]:[/\\]/.test(root)) {
20
+ const drive = root[0].toLowerCase();
21
+ root = `/mnt/${drive}${root.slice(2).replace(/\\/g, "/")}`;
22
+ }
23
+ return path.resolve(root);
24
+ }
25
+ /**
26
+ * Get the default data directory
27
+ */
28
+ export function getDefaultDataDir() {
29
+ const home = os.homedir();
30
+ // Use ~/.n2n-nexus for all platforms (developer-friendly convention)
31
+ return path.join(home, `.${SERVICE_NAME}`);
32
+ }
33
+ /**
34
+ * Get the root storage path from CLI or environment
35
+ */
36
+ export function getRootPath() {
37
+ return normalizeRootPath(getArg("--root"));
38
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Nexus Network Constants
3
+ *
4
+ * Centralized configuration for network-related settings.
5
+ */
6
+ // Service identification
7
+ export const SERVICE_NAME = "n2n-nexus";
8
+ // Host address for binding and connecting
9
+ // Use "0.0.0.0" to allow connections from any interface
10
+ // Use "127.0.0.1" to restrict to localhost only
11
+ export const NEXUS_HOST = "0.0.0.0";
12
+ // Port range for auto-election
13
+ export const PORT_RANGE_START = 5688;
14
+ export const PORT_RANGE_END = 5800;
15
+ // Timeouts (milliseconds)
16
+ export const HANDSHAKE_TIMEOUT = 500;
17
+ export const HEARTBEAT_INTERVAL = 30000;
18
+ // Task cleanup
19
+ export const TASK_CLEANUP_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
20
+ // File I/O
21
+ export const FILE_ENCODING = "utf-8";
22
+ export const PACKAGE_JSON = "package.json";
package/build/index.js CHANGED
@@ -1,31 +1,25 @@
1
1
  #!/usr/bin/env node
2
+ /**
3
+ * n2ns Nexus: Unified Project Asset & Collaboration Hub
4
+ *
5
+ * Modular MCP Server for multi-AI assistant coordination.
6
+ */
2
7
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
8
  import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ErrorCode, McpError, } from "@modelcontextprotocol/sdk/types.js";
5
- import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
6
- import { readFileSync } from "fs";
7
- import { join } from "path";
8
- import { fileURLToPath } from "url";
9
- import http from "http";
10
- import { CONFIG, hostServer } from "./config.js";
9
+ import { CONFIG, hostServer, pkg } from "./config/index.js";
11
10
  import { StorageManager } from "./storage/index.js";
12
11
  import { TOOL_DEFINITIONS, handleToolCall } from "./tools/index.js";
13
12
  import { listResources, getResourceContent } from "./resources/index.js";
14
13
  import { sanitizeErrorMessage } from "./utils/error.js";
15
14
  import { checkHostPermission } from "./utils/auth.js";
16
- const __dirname = fileURLToPath(new URL(".", import.meta.url));
17
- const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8"));
18
- /**
19
- * n2ns Nexus: Unified Project Asset & Collaboration Hub
20
- *
21
- * Modular MCP Server for multi-AI assistant coordination.
22
- */
15
+ import { SERVICE_NAME } from "./constants.js";
16
+ import { startHost, startGuest } from "./network/index.js";
23
17
  class NexusServer {
24
18
  server;
25
19
  currentProject = null;
26
20
  sseTransports = new Map();
27
21
  constructor() {
28
- this.server = new Server({ name: "n2n-nexus", version: pkg.version }, { capabilities: { resources: {}, tools: {}, prompts: {} } });
22
+ this.server = new Server({ name: SERVICE_NAME, version: pkg.version }, { capabilities: { resources: {}, tools: {}, prompts: {} } });
29
23
  this.setupHandlers();
30
24
  }
31
25
  setupHandlers() {
@@ -41,96 +35,101 @@ class NexusServer {
41
35
  });
42
36
  // --- Resource Reading ---
43
37
  this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
44
- const { uri } = request.params;
45
38
  try {
46
- await StorageManager.init();
47
- const content = await getResourceContent(uri, this.currentProject);
48
- if (content) {
49
- return { contents: [{ uri, mimeType: content.mimeType, text: content.text }] };
39
+ const result = await getResourceContent(request.params.uri, this.currentProject);
40
+ if (!result) {
41
+ throw new McpError(ErrorCode.InvalidRequest, `Resource not found: ${request.params.uri}`);
50
42
  }
51
- throw new McpError(ErrorCode.InvalidRequest, `Resource not found: ${uri}`);
43
+ return { contents: [{ uri: request.params.uri, ...result }] };
52
44
  }
53
45
  catch (error) {
54
- if (error instanceof McpError)
55
- throw error;
56
46
  const msg = error instanceof Error ? error.message : String(error);
57
- throw new McpError(ErrorCode.InternalError, `Nexus Resource Error: ${sanitizeErrorMessage(msg)}`);
47
+ throw new McpError(ErrorCode.InternalError, `Nexus Read Error: ${sanitizeErrorMessage(msg)}`);
58
48
  }
59
49
  });
60
50
  // --- Tool Listing ---
61
- this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
62
- tools: TOOL_DEFINITIONS
63
- }));
64
- // --- Tool Execution ---
51
+ this.server.setRequestHandler(ListToolsRequestSchema, async () => {
52
+ return { tools: TOOL_DEFINITIONS };
53
+ });
54
+ // --- Tool Calling ---
65
55
  this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
66
- const { name, arguments: toolArgs } = request.params;
56
+ const agentId = CONFIG.instanceId;
67
57
  try {
68
- if (name.startsWith("host_"))
69
- checkHostPermission(name);
70
- const result = await handleToolCall(name, toolArgs, {
58
+ // Special handling for switch_project
59
+ if (request.params.name === "switch_project") {
60
+ const args = request.params.arguments;
61
+ if (args.project_id) {
62
+ const manifest = await StorageManager.getProjectManifest(args.project_id);
63
+ if (manifest) {
64
+ this.currentProject = args.project_id;
65
+ return { content: [{ type: "text", text: `Switched to project: ${args.project_id}` }] };
66
+ }
67
+ }
68
+ return { content: [{ type: "text", text: `Project '${args.project_id}' not found.` }] };
69
+ }
70
+ // Host permission check for privileged tools
71
+ const hostOnlyTools = ["delete_project", "rename_project", "clear_global_logs", "archive_meeting"];
72
+ if (hostOnlyTools.includes(request.params.name)) {
73
+ try {
74
+ checkHostPermission(request.params.name);
75
+ }
76
+ catch {
77
+ return { content: [{ type: "text", text: `[Permission Denied] Tool '${request.params.name}' requires Host privileges.` }] };
78
+ }
79
+ }
80
+ // Delegate to tool handler
81
+ const ctx = {
71
82
  currentProject: this.currentProject,
72
83
  setCurrentProject: (id) => { this.currentProject = id; },
73
- notifyResourceUpdate: (uri) => {
74
- this.server.sendResourceUpdated({ uri });
75
- }
76
- });
84
+ notifyResourceUpdate: (_uri) => { }
85
+ };
86
+ const result = await handleToolCall(request.params.name, request.params.arguments, ctx);
77
87
  return result;
78
88
  }
79
89
  catch (error) {
80
- if (error instanceof McpError)
81
- throw error;
82
- const errorMessage = error instanceof Error ? error.message : String(error);
90
+ const msg = error instanceof Error ? error.message : String(error);
83
91
  return {
84
- isError: true,
85
- content: [{ type: "text", text: `Nexus Error: ${sanitizeErrorMessage(errorMessage)}` }]
92
+ content: [{ type: "text", text: sanitizeErrorMessage(`Tool Error: ${msg}`) }],
93
+ isError: true
86
94
  };
87
95
  }
88
96
  });
89
97
  // --- Prompt Listing ---
90
- this.server.setRequestHandler(ListPromptsRequestSchema, async () => ({
91
- prompts: [
92
- {
93
- name: "init_project_nexus",
94
- description: "Step-by-step guide for registering a new project with proper ID naming conventions.",
95
- arguments: [
96
- { name: "projectType", description: "Type: web, api, chrome, vscode, mcp, android, ios, flutter, desktop, lib, bot, infra, doc", required: true },
97
- { name: "technicalName", description: "Domain (e.g., example.com) or repo slug (e.g., my-library)", required: true }
98
- ]
99
- }
100
- ]
101
- }));
102
- // --- Prompt Retrieval ---
98
+ this.server.setRequestHandler(ListPromptsRequestSchema, async () => {
99
+ return {
100
+ prompts: [
101
+ {
102
+ name: "nexus_status",
103
+ description: "Get a comprehensive status report of the current Nexus Hub state"
104
+ }
105
+ ]
106
+ };
107
+ });
108
+ // --- Prompt Getting ---
103
109
  this.server.setRequestHandler(GetPromptRequestSchema, async (request) => {
104
- const { name, arguments: args } = request.params;
105
- if (name === "init_project_nexus") {
106
- const projectType = args?.projectType || "[TYPE]";
107
- const technicalName = args?.technicalName || "[NAME]";
108
- const projectId = `${projectType}_${technicalName}`;
110
+ if (request.params.name === "nexus_status") {
111
+ const registry = await StorageManager.listRegistry();
112
+ const projectCount = Object.keys(registry.projects).length;
113
+ const logs = await StorageManager.getRecentLogs(5);
109
114
  return {
110
- description: "Initialize a new Nexus project",
111
- messages: [
112
- {
115
+ messages: [{
113
116
  role: "user",
114
117
  content: {
115
118
  type: "text",
116
- text: `I want to register a new project in Nexus.\n\n**Project Type:** ${projectType}\n**Technical Name:** ${technicalName}`
119
+ text: `Nexus Hub Status:
120
+ - Role: ${CONFIG.isHost ? "Host" : "Guest"}
121
+ - Instance: ${CONFIG.instanceId}
122
+ - Port: ${CONFIG.port}
123
+ - Active Projects: ${projectCount}
124
+ - Recent Activity: ${logs.length} entries`
117
125
  }
118
- },
119
- {
120
- role: "assistant",
121
- content: {
122
- type: "text",
123
- text: `## Project ID Convention\n\nBased on your input, the correct Project ID is:\n\n\`\`\`\n${projectId}\n\`\`\`\n\n### Prefix Dictionary\n| Prefix | Use Case |\n|--------|----------|\n| web_ | Websites/Domains |\n| api_ | Backend Services |\n| chrome_ | Chrome Extensions |\n| vscode_ | VSCode Extensions |\n| mcp_ | MCP Servers |\n| android_ | Native Android |\n| ios_ | Native iOS |\n| flutter_ | Cross-platform Mobile |\n| desktop_ | Desktop Apps |\n| lib_ | Libraries/SDKs |\n| bot_ | Bots |\n| infra_ | Infrastructure as Code |\n| doc_ | Technical Docs |\n\n### Next Steps\n1. Call \`register_session_context\` with projectId: \`${projectId}\`\n2. Call \`sync_project_assets\` with your manifest and internal docs.`
124
- }
125
- }
126
- ]
126
+ }]
127
127
  };
128
128
  }
129
- throw new McpError(ErrorCode.InvalidRequest, `Unknown prompt: ${name}`);
129
+ throw new McpError(ErrorCode.InvalidRequest, `Prompt not found: ${request.params.name}`);
130
130
  });
131
131
  }
132
- async run() {
133
- // Handle graceful shutdown
132
+ setupShutdownHandlers() {
134
133
  const shutdown = async (signal) => {
135
134
  console.error(`\n[Nexus] Received ${signal}. Shutting down...`);
136
135
  try {
@@ -141,154 +140,28 @@ class NexusServer {
141
140
  catch { /* ignore */ }
142
141
  process.exit(0);
143
142
  };
144
- // Global Error Handlers to prevent process exit on background errors
145
143
  process.on("uncaughtException", (err) => {
146
144
  console.error("[Nexus CRITICAL] Uncaught Exception:", err);
147
- // Attempt to log to disk if possible, but keep process alive if safe
148
- // For a Hub, staying alive is often preferred over crashing
149
145
  });
150
146
  process.on("unhandledRejection", (reason, promise) => {
151
147
  console.error("[Nexus WARNING] Unhandled Rejection at:", promise, "reason:", reason);
152
- // Do not exit. Background tasks (like file sync) often trigger this.
153
148
  });
154
149
  process.on("SIGINT", () => shutdown("SIGINT"));
155
150
  process.on("SIGTERM", () => shutdown("SIGTERM"));
151
+ }
152
+ async run() {
153
+ this.setupShutdownHandlers();
154
+ const context = {
155
+ config: CONFIG,
156
+ pkg,
157
+ mcpServer: this.server,
158
+ sseTransports: this.sseTransports
159
+ };
156
160
  if (CONFIG.isHost && hostServer) {
157
- // --- HOST MODE: Central Hub ---
158
- await StorageManager.init();
159
- hostServer.on("request", async (req, res) => {
160
- const url = new URL(req.url || "", `http://${req.headers.host}`);
161
- if (url.pathname === "/mcp") {
162
- const guestId = url.searchParams.get("id") || "UnknownGuest";
163
- if (req.method === "GET") {
164
- const transport = new SSEServerTransport("/mcp", res);
165
- this.sseTransports.set(transport.sessionId, transport);
166
- const msg = `Guest Joined: ${guestId}`;
167
- await StorageManager.addGlobalLog(`HOST:${CONFIG.instanceId}`, msg, "UPDATE");
168
- console.error(`[Nexus Hub] ${msg} (Session: ${transport.sessionId})`);
169
- // Heartbeat: keep connection alive
170
- const heartbeat = setInterval(() => {
171
- try {
172
- res.write(": ping\n\n");
173
- }
174
- catch {
175
- clearInterval(heartbeat);
176
- }
177
- }, 30000);
178
- transport.onclose = () => {
179
- this.sseTransports.delete(transport.sessionId);
180
- clearInterval(heartbeat);
181
- console.error(`[Nexus Hub] Guest Left: ${guestId}`);
182
- };
183
- await this.server.connect(transport);
184
- return;
185
- }
186
- else if (req.method === "POST") {
187
- const sessionId = url.searchParams.get("sessionId");
188
- const transport = sessionId ? this.sseTransports.get(sessionId) : null;
189
- if (transport) {
190
- await transport.handlePostMessage(req, res);
191
- }
192
- else {
193
- res.writeHead(404).end("Session unknown");
194
- }
195
- return;
196
- }
197
- }
198
- });
199
- // Support local stdio for the host's own IDE
200
- const transport = new StdioServerTransport();
201
- await this.server.connect(transport);
202
- const onlineMsg = `Nexus Hub Active. Playing Host.`;
203
- await StorageManager.addGlobalLog(`SYSTEM:${CONFIG.instanceId}`, onlineMsg, "UPDATE");
204
- console.error(`[Nexus:${CONFIG.instanceId}] ${onlineMsg} (Port: ${CONFIG.port})`);
161
+ await startHost(hostServer, context);
205
162
  }
206
163
  else {
207
- // --- GUEST MODE: SSE Proxy ---
208
- const guestId = CONFIG.instanceId;
209
- let retryCount = 0;
210
- const maxRetries = 50; // Prevent infinite reconnection loops
211
- // Random delay function to prevent thundering herd during re-election
212
- const randomDelay = () => Math.floor(Math.random() * 3000);
213
- const startProxy = () => {
214
- if (retryCount >= maxRetries) {
215
- console.error(`[Nexus Guest] Max retries (${maxRetries}) reached. Exiting.`);
216
- process.exit(1);
217
- }
218
- retryCount++;
219
- // Clear any stale stdin listeners before starting
220
- process.stdin.removeAllListeners("data");
221
- console.error(`[Nexus:${guestId}] Global Hub detected at ${CONFIG.port}. Joining... (attempt ${retryCount})`);
222
- let sessionId = null;
223
- let lastActivity = Date.now();
224
- // Watchdog: trigger re-election if Host is silent for too long
225
- const watchdog = setInterval(() => {
226
- if (Date.now() - lastActivity > 60000) {
227
- console.error("[Nexus Guest] Host stale. Reconnecting...");
228
- cleanup();
229
- // Use setImmediate to break call stack, then delay
230
- setImmediate(() => setTimeout(startProxy, randomDelay()));
231
- }
232
- }, 10000);
233
- const cleanup = () => {
234
- clearInterval(watchdog);
235
- process.stdin.removeAllListeners("data");
236
- };
237
- const stdioHandler = (chunk) => {
238
- if (!sessionId)
239
- return;
240
- try {
241
- const req = http.request({
242
- hostname: "127.0.0.1",
243
- port: CONFIG.port,
244
- path: `/mcp?sessionId=${sessionId}&id=${encodeURIComponent(guestId)}`,
245
- method: "POST",
246
- headers: { "Content-Type": "application/json" }
247
- });
248
- // Handle request errors to prevent unhandled exceptions
249
- req.on("error", () => { });
250
- req.write(chunk);
251
- req.end();
252
- }
253
- catch { /* suppress */ }
254
- };
255
- process.stdin.on("data", stdioHandler);
256
- http.get(`http://127.0.0.1:${CONFIG.port}/mcp?id=${encodeURIComponent(guestId)}`, (res) => {
257
- retryCount = 0; // Reset on successful connection
258
- let buffer = "";
259
- res.on("data", (chunk) => {
260
- lastActivity = Date.now();
261
- const str = chunk.toString();
262
- buffer += str;
263
- if (!sessionId && buffer.includes("event: endpoint")) {
264
- const match = buffer.match(/sessionId=([a-f0-9-]+)/);
265
- if (match)
266
- sessionId = match[1];
267
- }
268
- if (str.includes("event: message")) {
269
- const lines = str.split("\n");
270
- const dataLine = lines.find((l) => l.startsWith("data: "));
271
- if (dataLine) {
272
- try {
273
- process.stdout.write(dataLine.substring(6) + "\n");
274
- }
275
- catch { /* ignore stdout errors */ }
276
- }
277
- }
278
- });
279
- res.on("end", () => {
280
- console.error("[Nexus Guest] Lost connection to Host. Reconnecting...");
281
- cleanup();
282
- // Use setImmediate to break call stack
283
- setImmediate(() => setTimeout(startProxy, randomDelay()));
284
- });
285
- }).on("error", () => {
286
- console.error("[Nexus Guest] Proxy Receive Error. Retrying...");
287
- cleanup();
288
- setImmediate(() => setTimeout(startProxy, 1000 + randomDelay()));
289
- });
290
- };
291
- startProxy();
164
+ await startGuest(CONFIG.port, context);
292
165
  }
293
166
  }
294
167
  }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Host Election Logic
3
+ *
4
+ * Handles port scanning, handshake probing, and Host/Guest election.
5
+ */
6
+ import http from "http";
7
+ import { NEXUS_HOST, PORT_RANGE_START, PORT_RANGE_END, HANDSHAKE_TIMEOUT, SERVICE_NAME } from "../constants.js";
8
+ import { getArg } from "../config/cli.js";
9
+ // We need pkg version for handshake - import dynamically to avoid circular dep
10
+ let pkgVersion = "0.0.0";
11
+ import("../config/index.js").then(m => { pkgVersion = m.pkg.version; }).catch(() => { });
12
+ /**
13
+ * Probe a port to see if it's a Nexus Host using the Custom Handshake Protocol
14
+ */
15
+ export async function probeHost(port, myId) {
16
+ return new Promise((resolve) => {
17
+ const postData = JSON.stringify({
18
+ clientVersion: pkgVersion,
19
+ instanceId: myId
20
+ });
21
+ const req = http.request({
22
+ hostname: NEXUS_HOST,
23
+ port: port,
24
+ path: "/nexus/handshake",
25
+ method: "POST",
26
+ headers: {
27
+ "Content-Type": "application/json",
28
+ "Content-Length": Buffer.byteLength(postData)
29
+ },
30
+ timeout: HANDSHAKE_TIMEOUT
31
+ }, (res) => {
32
+ let data = "";
33
+ res.on("data", (chunk) => data += chunk);
34
+ res.on("end", () => {
35
+ try {
36
+ const info = JSON.parse(data);
37
+ if (info.service === SERVICE_NAME && info.role === "host") {
38
+ resolve({ isNexus: true, rootStorage: info.rootStorage });
39
+ }
40
+ else {
41
+ resolve({ isNexus: false });
42
+ }
43
+ }
44
+ catch {
45
+ resolve({ isNexus: false });
46
+ }
47
+ });
48
+ });
49
+ req.on("error", () => resolve({ isNexus: false }));
50
+ req.on("timeout", () => {
51
+ req.destroy();
52
+ resolve({ isNexus: false });
53
+ });
54
+ req.write(postData);
55
+ req.end();
56
+ });
57
+ }
58
+ /**
59
+ * Automatic Host Election
60
+ *
61
+ * For each port in range:
62
+ * 1. Try to bind → Success → I am Host
63
+ * 2. Bind fails → Try handshake → Success → I am Guest
64
+ * 3. Handshake fails → Port occupied by non-Nexus → Next port
65
+ */
66
+ export async function isHostAutoElection(_root, blacklistPorts = []) {
67
+ const startPort = PORT_RANGE_START;
68
+ const endPort = PORT_RANGE_END;
69
+ const myId = getArg("--id") || `node-${Math.random().toString(36).substring(2, 6)}`;
70
+ for (let port = startPort; port <= endPort; port++) {
71
+ if (blacklistPorts.includes(port))
72
+ continue;
73
+ // 1. Try to bind port
74
+ const bindResult = await new Promise((resolve) => {
75
+ const server = http.createServer();
76
+ server.on("error", () => resolve({ success: false }));
77
+ server.listen(port, NEXUS_HOST, () => resolve({ success: true, server }));
78
+ });
79
+ if (bindResult.success) {
80
+ // Bind success → I am Host
81
+ return { isHost: true, port, server: bindResult.server };
82
+ }
83
+ // 2. Bind failed → Try handshake
84
+ const probe = await probeHost(port, myId);
85
+ if (probe.isNexus) {
86
+ // Handshake success → I am Guest
87
+ return { isHost: false, port, rootStorage: probe.rootStorage };
88
+ }
89
+ // 3. Handshake failed → Port occupied by non-Nexus → Continue
90
+ }
91
+ // All ports unavailable
92
+ console.error(`[Nexus] All ports ${startPort}-${endPort} occupied by non-Nexus processes.`);
93
+ throw new Error("No available port for Nexus");
94
+ }