@datafrog-io/n2n-nexus 0.3.4 → 0.3.5

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.
package/build/config.js CHANGED
@@ -136,7 +136,7 @@ async function probeHost(port, myId) {
136
136
  * 3. If not found, try to become Host
137
137
  * 4. If bind fails, wait and re-probe (give winner time to start)
138
138
  */
139
- async function isHostAutoElection(root) {
139
+ async function isHostAutoElection(root, blacklistPorts = []) {
140
140
  const startPort = 5688;
141
141
  const endPort = 5800;
142
142
  let retryCount = 0;
@@ -149,6 +149,8 @@ async function isHostAutoElection(root) {
149
149
  const batchEnd = Math.min(batchStart + BATCH_SIZE - 1, endPort);
150
150
  const promises = [];
151
151
  for (let port = batchStart; port <= batchEnd; port++) {
152
+ if (blacklistPorts.includes(port))
153
+ continue;
152
154
  promises.push(probeHost(port, myId).then(res => ({ port, ...res })));
153
155
  }
154
156
  const results = await Promise.all(promises);
@@ -159,6 +161,8 @@ async function isHostAutoElection(root) {
159
161
  }
160
162
  // Phase 2: No Host found, attempt to become Host
161
163
  for (let port = startPort; port <= endPort; port++) {
164
+ if (blacklistPorts.includes(port))
165
+ continue;
162
166
  const result = await new Promise((resolve) => {
163
167
  const server = http.createServer((req, res) => {
164
168
  // HANDSHAKE ENDPOINT
@@ -198,9 +202,11 @@ async function isHostAutoElection(root) {
198
202
  }
199
203
  // Phase 3: Bind failed - another Guest won. Wait then join winner.
200
204
  await new Promise(r => setTimeout(r, 2000)); // Short wait for winner to stabilize
201
- const probe = await probeHost(port, myId);
202
- if (probe.isNexus) {
203
- return { isHost: false, port, rootStorage: probe.rootStorage };
205
+ if (backoffProbe(port, myId)) {
206
+ const probe = await probeHost(port, myId);
207
+ if (probe.isNexus) {
208
+ return { isHost: false, port, rootStorage: probe.rootStorage };
209
+ }
204
210
  }
205
211
  // If still not Nexus, try next port
206
212
  }
@@ -211,6 +217,9 @@ async function isHostAutoElection(root) {
211
217
  retryCount++;
212
218
  }
213
219
  }
220
+ function backoffProbe(_port, _myId) { return true; } // simplified inline for diff
221
+ // Export needed for re-election
222
+ export { isHostAutoElection };
214
223
  /**
215
224
  * Automatic Project Name Detection
216
225
  */
package/build/index.js CHANGED
@@ -130,7 +130,15 @@ class NexusServer {
130
130
  });
131
131
  }
132
132
  async run() {
133
- // Handle graceful shutdown
133
+ this.setupShutdownHandlers();
134
+ if (CONFIG.isHost && hostServer) {
135
+ await this.startHost(hostServer);
136
+ }
137
+ else {
138
+ await this.startGuest(CONFIG.port);
139
+ }
140
+ }
141
+ setupShutdownHandlers() {
134
142
  const shutdown = async (signal) => {
135
143
  console.error(`\n[Nexus] Received ${signal}. Shutting down...`);
136
144
  try {
@@ -141,155 +149,178 @@ class NexusServer {
141
149
  catch { /* ignore */ }
142
150
  process.exit(0);
143
151
  };
144
- // Global Error Handlers to prevent process exit on background errors
145
152
  process.on("uncaughtException", (err) => {
146
153
  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
154
  });
150
155
  process.on("unhandledRejection", (reason, promise) => {
151
156
  console.error("[Nexus WARNING] Unhandled Rejection at:", promise, "reason:", reason);
152
- // Do not exit. Background tasks (like file sync) often trigger this.
153
157
  });
154
158
  process.on("SIGINT", () => shutdown("SIGINT"));
155
159
  process.on("SIGTERM", () => shutdown("SIGTERM"));
156
- 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);
160
+ }
161
+ async startHost(server) {
162
+ // --- HOST MODE: Central Hub ---
163
+ await StorageManager.init();
164
+ server.on("request", async (req, res) => {
165
+ const url = new URL(req.url || "", `http://${req.headers.host}`);
166
+ if (url.pathname === "/mcp") {
167
+ const guestId = url.searchParams.get("id") || "UnknownGuest";
168
+ if (req.method === "GET") {
169
+ const transport = new SSEServerTransport("/mcp", res);
170
+ this.sseTransports.set(transport.sessionId, transport);
171
+ const msg = `Guest Joined: ${guestId}`;
172
+ await StorageManager.addGlobalLog(`HOST:${CONFIG.instanceId}`, msg, "UPDATE");
173
+ console.error(`[Nexus Hub] ${msg} (Session: ${transport.sessionId})`);
174
+ // Heartbeat: keep connection alive
175
+ const heartbeat = setInterval(() => {
176
+ try {
177
+ res.write(": ping\n\n");
191
178
  }
192
- else {
193
- res.writeHead(404).end("Session unknown");
179
+ catch {
180
+ clearInterval(heartbeat);
194
181
  }
195
- return;
182
+ }, 30000);
183
+ transport.onclose = () => {
184
+ this.sseTransports.delete(transport.sessionId);
185
+ clearInterval(heartbeat);
186
+ console.error(`[Nexus Hub] Guest Left: ${guestId}`);
187
+ };
188
+ await this.server.connect(transport);
189
+ return;
190
+ }
191
+ else if (req.method === "POST") {
192
+ const sessionId = url.searchParams.get("sessionId");
193
+ const transport = sessionId ? this.sseTransports.get(sessionId) : null;
194
+ if (transport) {
195
+ await transport.handlePostMessage(req, res);
196
196
  }
197
+ else {
198
+ res.writeHead(404).end("Session unknown");
199
+ }
200
+ return;
197
201
  }
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})`);
205
- }
206
- 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);
202
+ }
203
+ });
204
+ // Support local stdio for the host's own IDE
205
+ const transport = new StdioServerTransport();
206
+ await this.server.connect(transport);
207
+ const onlineMsg = `Nexus Hub Active. Playing Host.`;
208
+ await StorageManager.addGlobalLog(`SYSTEM:${CONFIG.instanceId}`, onlineMsg, "UPDATE");
209
+ console.error(`[Nexus:${CONFIG.instanceId}] ${onlineMsg} (Port: ${server.address() && typeof server.address() === 'object' ? server.address().port : '?'})`);
210
+ }
211
+ async startGuest(targetPort) {
212
+ // --- GUEST MODE: SSE Proxy ---
213
+ const guestId = CONFIG.instanceId;
214
+ let retryCount = 0;
215
+ const maxRetries = 10;
216
+ const blacklistPorts = [];
217
+ // Random delay function to prevent thundering herd during re-election
218
+ const randomDelay = () => Math.floor(Math.random() * 3000);
219
+ const connect = () => {
220
+ // ZOMBIE HOST DETECTION: If we hit max retries, it means the Host is unstable (Zombie).
221
+ // Trigger re-election ignoring the bad port.
222
+ if (retryCount >= maxRetries) {
223
+ console.error(`[Nexus Guest] Host at ${targetPort} is unstable (Zombie). Triggering re-election...`);
224
+ import("./config.js").then(async ({ isHostAutoElection }) => {
225
+ blacklistPorts.push(targetPort);
226
+ // Re-run election with blacklist
227
+ const result = await isHostAutoElection(CONFIG.rootStorage, blacklistPorts);
228
+ if (result.isHost && result.server) {
229
+ // We became Host!
230
+ console.error(`[Nexus] Promoted to Host on port ${result.port}!`);
231
+ // Update global config (hack but necessary for singleton logs)
232
+ CONFIG.isHost = true;
233
+ CONFIG.port = result.port;
234
+ await this.startHost(result.server);
235
+ }
236
+ else {
237
+ // Found a new Host
238
+ console.error(`[Nexus] Found new Host at ${result.port}. Reconnecting...`);
239
+ CONFIG.port = result.port;
240
+ this.startGuest(result.port);
241
+ }
242
+ });
243
+ return;
244
+ }
245
+ retryCount++;
246
+ // Clear any stale stdin listeners before starting
247
+ process.stdin.removeAllListeners("data");
248
+ console.error(`[Nexus:${guestId}] Global Hub detected at ${targetPort}. Joining... (attempt ${retryCount})`);
249
+ let sessionId = null;
250
+ let lastActivity = Date.now();
251
+ // Watchdog: trigger re-election if Host is silent for too long
252
+ const watchdog = setInterval(() => {
253
+ if (Date.now() - lastActivity > 60000) {
254
+ console.error("[Nexus Guest] Host stale. Reconnecting...");
255
+ cleanup();
256
+ // Use setImmediate to break call stack, then delay
257
+ setImmediate(() => setTimeout(connect, randomDelay()));
217
258
  }
218
- retryCount++;
219
- // Clear any stale stdin listeners before starting
259
+ }, 10000);
260
+ const cleanup = () => {
261
+ clearInterval(watchdog);
220
262
  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();
263
+ };
264
+ const stdioHandler = (chunk) => {
265
+ if (!sessionId)
266
+ return;
267
+ try {
268
+ const req = http.request({
269
+ hostname: "127.0.0.1",
270
+ port: targetPort,
271
+ path: `/mcp?sessionId=${sessionId}&id=${encodeURIComponent(guestId)}`,
272
+ method: "POST",
273
+ headers: { "Content-Type": "application/json" }
274
+ });
275
+ // Handle request errors to prevent unhandled exceptions
276
+ req.on("error", () => { });
277
+ req.write(chunk);
278
+ req.end();
279
+ }
280
+ catch { /* suppress */ }
281
+ };
282
+ process.stdin.on("data", stdioHandler);
283
+ http.get(`http://127.0.0.1:${targetPort}/mcp?id=${encodeURIComponent(guestId)}`, (res) => {
284
+ // INTELLIGENT RETRY: Only reset retryCount if connection held for > 5 seconds
285
+ // This prevents infinite loops with "Zombie Hosts" that accept then immediately close
286
+ const stableTimer = setTimeout(() => {
287
+ retryCount = 0;
288
+ }, 5000);
289
+ let buffer = "";
290
+ res.on("data", (chunk) => {
291
+ lastActivity = Date.now();
292
+ const str = chunk.toString();
293
+ buffer += str;
294
+ if (!sessionId && buffer.includes("event: endpoint")) {
295
+ const match = buffer.match(/sessionId=([a-f0-9-]+)/);
296
+ if (match)
297
+ sessionId = match[1];
252
298
  }
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 */ }
299
+ if (str.includes("event: message")) {
300
+ const lines = str.split("\n");
301
+ const dataLine = lines.find((l) => l.startsWith("data: "));
302
+ if (dataLine) {
303
+ try {
304
+ process.stdout.write(dataLine.substring(6) + "\n");
276
305
  }
306
+ catch { /* ignore stdout errors */ }
277
307
  }
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...");
308
+ }
309
+ });
310
+ res.on("end", () => {
311
+ clearTimeout(stableTimer);
312
+ console.error("[Nexus Guest] Lost connection to Host. Reconnecting...");
287
313
  cleanup();
288
- setImmediate(() => setTimeout(startProxy, 1000 + randomDelay()));
314
+ // Use setImmediate to break call stack
315
+ setImmediate(() => setTimeout(connect, randomDelay()));
289
316
  });
290
- };
291
- startProxy();
292
- }
317
+ }).on("error", () => {
318
+ console.error("[Nexus Guest] Proxy Receive Error. Retrying...");
319
+ cleanup();
320
+ setImmediate(() => setTimeout(connect, 1000 + randomDelay()));
321
+ });
322
+ };
323
+ connect();
293
324
  }
294
325
  }
295
326
  const server = new NexusServer();
@@ -2,6 +2,11 @@
2
2
 
3
3
  本项目的所有重大变更都将记录在此文件中。
4
4
 
5
+ ## [0.3.5] - 2026-01-10
6
+ ### 协议与稳定性
7
+ - **修复 (僵尸 Host)**: 实现了“智能重试”和“重新选举”逻辑。如果 Guest 反复连接到僵尸 Host(握手成功但 SSE 断开),现在会自动触发重新选举流程,将坏端口加入黑名单,并在必要时在从端口上将自身提升为 Host。
8
+ - **重构**: 启用了动态角色切换 (Guest -> Host),无需重启进程。
9
+
5
10
  ## [0.3.4] - 2026-01-10
6
11
  ### 协议与稳定性
7
12
  - **新握手协议**: 引入 `POST /nexus/handshake` 替代旧版 `/hello`。支持严格的客户端/服务端版本校验和稳健的 Host 探测。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datafrog-io/n2n-nexus",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "Modular MCP Server for multi-AI assistant coordination",
5
5
  "main": "build/index.js",
6
6
  "type": "module",