@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 +13 -4
- package/build/index.js +162 -131
- package/docs/CHANGELOG_zh.md +5 -0
- package/package.json +1 -1
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
193
|
-
|
|
179
|
+
catch {
|
|
180
|
+
clearInterval(heartbeat);
|
|
194
181
|
}
|
|
195
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
219
|
-
|
|
259
|
+
}, 10000);
|
|
260
|
+
const cleanup = () => {
|
|
261
|
+
clearInterval(watchdog);
|
|
220
262
|
process.stdin.removeAllListeners("data");
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
314
|
+
// Use setImmediate to break call stack
|
|
315
|
+
setImmediate(() => setTimeout(connect, randomDelay()));
|
|
289
316
|
});
|
|
290
|
-
}
|
|
291
|
-
|
|
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();
|
package/docs/CHANGELOG_zh.md
CHANGED
|
@@ -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 探测。
|