@datafrog-io/n2n-nexus 0.3.3 → 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 +65 -30
- package/build/index.js +168 -127
- package/build/storage/index.js +7 -0
- package/build/storage/tasks.js +7 -0
- package/docs/CHANGELOG_zh.md +13 -0
- package/package.json +1 -1
package/build/config.js
CHANGED
|
@@ -15,7 +15,7 @@ const getArg = (k) => {
|
|
|
15
15
|
const hasFlag = (k) => args.includes(k) || args.includes(k.charAt(1) === "-" ? k : k.substring(0, 2));
|
|
16
16
|
// --- CLI Commands Handlers ---
|
|
17
17
|
if (hasFlag("--help") || hasFlag("-h")) {
|
|
18
|
-
console.
|
|
18
|
+
console.error(`
|
|
19
19
|
n2ns Nexus 🚀 - Local Digital Asset Hub (MCP Server) v${pkg.version}
|
|
20
20
|
|
|
21
21
|
USAGE:
|
|
@@ -46,7 +46,7 @@ ENVIRONMENT VARIABLES:
|
|
|
46
46
|
process.exit(0);
|
|
47
47
|
}
|
|
48
48
|
if (hasFlag("--version") || hasFlag("-v")) {
|
|
49
|
-
console.
|
|
49
|
+
console.error(pkg.version);
|
|
50
50
|
process.exit(0);
|
|
51
51
|
}
|
|
52
52
|
// --- Path Normalization Logic ---
|
|
@@ -80,15 +80,33 @@ function getDefaultDataDir() {
|
|
|
80
80
|
/**
|
|
81
81
|
* Probe a port to see if it's a Nexus Host
|
|
82
82
|
*/
|
|
83
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Probe a port to see if it's a Nexus Host using the Custom Handshake Protocol
|
|
85
|
+
*/
|
|
86
|
+
async function probeHost(port, myId) {
|
|
84
87
|
return new Promise((resolve) => {
|
|
85
|
-
const
|
|
88
|
+
const postData = JSON.stringify({
|
|
89
|
+
clientVersion: pkg.version,
|
|
90
|
+
instanceId: myId
|
|
91
|
+
});
|
|
92
|
+
const req = http.request({
|
|
93
|
+
hostname: "127.0.0.1",
|
|
94
|
+
port: port,
|
|
95
|
+
path: "/nexus/handshake",
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: {
|
|
98
|
+
"Content-Type": "application/json",
|
|
99
|
+
"Content-Length": Buffer.byteLength(postData)
|
|
100
|
+
},
|
|
101
|
+
timeout: 500
|
|
102
|
+
}, (res) => {
|
|
86
103
|
let data = "";
|
|
87
104
|
res.on("data", (chunk) => data += chunk);
|
|
88
105
|
res.on("end", () => {
|
|
89
106
|
try {
|
|
90
107
|
const info = JSON.parse(data);
|
|
91
108
|
if (info.service === "n2n-nexus" && info.role === "host") {
|
|
109
|
+
// console.error(`[Nexus Handshake] Connected to Host v${info.serverVersion} (Protocol ${info.protocol})`);
|
|
92
110
|
resolve({ isNexus: true, rootStorage: info.rootStorage });
|
|
93
111
|
}
|
|
94
112
|
else {
|
|
@@ -105,6 +123,8 @@ async function probeHost(port) {
|
|
|
105
123
|
req.destroy();
|
|
106
124
|
resolve({ isNexus: false });
|
|
107
125
|
});
|
|
126
|
+
req.write(postData);
|
|
127
|
+
req.end();
|
|
108
128
|
});
|
|
109
129
|
}
|
|
110
130
|
/**
|
|
@@ -116,7 +136,7 @@ async function probeHost(port) {
|
|
|
116
136
|
* 3. If not found, try to become Host
|
|
117
137
|
* 4. If bind fails, wait and re-probe (give winner time to start)
|
|
118
138
|
*/
|
|
119
|
-
async function isHostAutoElection(root) {
|
|
139
|
+
async function isHostAutoElection(root, blacklistPorts = []) {
|
|
120
140
|
const startPort = 5688;
|
|
121
141
|
const endPort = 5800;
|
|
122
142
|
let retryCount = 0;
|
|
@@ -124,11 +144,14 @@ async function isHostAutoElection(root) {
|
|
|
124
144
|
while (true) {
|
|
125
145
|
// Phase 1: Probe-First - Check if any Host already exists (Concurrent Batch Scan)
|
|
126
146
|
const BATCH_SIZE = 20;
|
|
147
|
+
const myId = getArg("--id") || `candidate-${Math.random().toString(36).substring(2, 6)}`;
|
|
127
148
|
for (let batchStart = startPort; batchStart <= endPort; batchStart += BATCH_SIZE) {
|
|
128
149
|
const batchEnd = Math.min(batchStart + BATCH_SIZE - 1, endPort);
|
|
129
150
|
const promises = [];
|
|
130
151
|
for (let port = batchStart; port <= batchEnd; port++) {
|
|
131
|
-
|
|
152
|
+
if (blacklistPorts.includes(port))
|
|
153
|
+
continue;
|
|
154
|
+
promises.push(probeHost(port, myId).then(res => ({ port, ...res })));
|
|
132
155
|
}
|
|
133
156
|
const results = await Promise.all(promises);
|
|
134
157
|
const found = results.find(r => r.isNexus);
|
|
@@ -138,28 +161,37 @@ async function isHostAutoElection(root) {
|
|
|
138
161
|
}
|
|
139
162
|
// Phase 2: No Host found, attempt to become Host
|
|
140
163
|
for (let port = startPort; port <= endPort; port++) {
|
|
164
|
+
if (blacklistPorts.includes(port))
|
|
165
|
+
continue;
|
|
141
166
|
const result = await new Promise((resolve) => {
|
|
142
167
|
const server = http.createServer((req, res) => {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
168
|
+
// HANDSHAKE ENDPOINT
|
|
169
|
+
if (req.method === "POST" && req.url === "/nexus/handshake") {
|
|
170
|
+
let body = "";
|
|
171
|
+
req.on("data", chunk => body += chunk);
|
|
172
|
+
req.on("end", () => {
|
|
173
|
+
try {
|
|
174
|
+
const _clientInfo = JSON.parse(body);
|
|
175
|
+
// console.error(`[Nexus Handshake] Client connected: ${_clientInfo.instanceId} (v${_clientInfo.clientVersion})`);
|
|
176
|
+
}
|
|
177
|
+
catch { /* ignore malformed */ }
|
|
178
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
179
|
+
res.end(JSON.stringify({
|
|
180
|
+
service: "n2n-nexus",
|
|
181
|
+
protocol: "v1", // Nexus Handshake Protocol v1
|
|
182
|
+
role: "host",
|
|
183
|
+
serverVersion: pkg.version,
|
|
184
|
+
rootStorage: root,
|
|
185
|
+
status: "ready"
|
|
186
|
+
}));
|
|
187
|
+
});
|
|
151
188
|
return;
|
|
152
189
|
}
|
|
153
190
|
res.writeHead(404);
|
|
154
191
|
res.end();
|
|
155
192
|
});
|
|
156
|
-
server.on("error", (
|
|
157
|
-
|
|
158
|
-
resolve({ isHost: false });
|
|
159
|
-
}
|
|
160
|
-
else {
|
|
161
|
-
resolve({ isHost: false });
|
|
162
|
-
}
|
|
193
|
+
server.on("error", (_err) => {
|
|
194
|
+
resolve({ isHost: false });
|
|
163
195
|
});
|
|
164
196
|
server.listen(port, "0.0.0.0", () => {
|
|
165
197
|
resolve({ isHost: true, server });
|
|
@@ -169,22 +201,25 @@ async function isHostAutoElection(root) {
|
|
|
169
201
|
return { isHost: true, port, server: result.server };
|
|
170
202
|
}
|
|
171
203
|
// Phase 3: Bind failed - another Guest won. Wait then join winner.
|
|
172
|
-
await new Promise(r => setTimeout(r,
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
204
|
+
await new Promise(r => setTimeout(r, 2000)); // Short wait for winner to stabilize
|
|
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
|
+
}
|
|
176
210
|
}
|
|
177
|
-
// If still not Nexus, try next port
|
|
211
|
+
// If still not Nexus, try next port
|
|
178
212
|
}
|
|
179
213
|
// Fallback: All ports occupied - progressive backoff retry
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const intervalStr = retryCount < 5 ? "1 minute" : "2 minutes";
|
|
183
|
-
console.error(`[Nexus] All ports ${startPort}-${endPort} occupied. Retry #${retryCount + 1} in ${intervalStr}...`);
|
|
214
|
+
const waitTime = retryCount < 5 ? 5000 : 30000;
|
|
215
|
+
console.error(`[Nexus] All ports ${startPort}-${endPort} occupied. Retry #${retryCount + 1} in ${waitTime / 1000}s...`);
|
|
184
216
|
await new Promise(r => setTimeout(r, waitTime));
|
|
185
217
|
retryCount++;
|
|
186
218
|
}
|
|
187
219
|
}
|
|
220
|
+
function backoffProbe(_port, _myId) { return true; } // simplified inline for diff
|
|
221
|
+
// Export needed for re-election
|
|
222
|
+
export { isHostAutoElection };
|
|
188
223
|
/**
|
|
189
224
|
* Automatic Project Name Detection
|
|
190
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,145 +149,178 @@ class NexusServer {
|
|
|
141
149
|
catch { /* ignore */ }
|
|
142
150
|
process.exit(0);
|
|
143
151
|
};
|
|
152
|
+
process.on("uncaughtException", (err) => {
|
|
153
|
+
console.error("[Nexus CRITICAL] Uncaught Exception:", err);
|
|
154
|
+
});
|
|
155
|
+
process.on("unhandledRejection", (reason, promise) => {
|
|
156
|
+
console.error("[Nexus WARNING] Unhandled Rejection at:", promise, "reason:", reason);
|
|
157
|
+
});
|
|
144
158
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
145
159
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
catch {
|
|
165
|
-
clearInterval(heartbeat);
|
|
166
|
-
}
|
|
167
|
-
}, 30000);
|
|
168
|
-
transport.onclose = () => {
|
|
169
|
-
this.sseTransports.delete(transport.sessionId);
|
|
170
|
-
clearInterval(heartbeat);
|
|
171
|
-
console.error(`[Nexus Hub] Guest Left: ${guestId}`);
|
|
172
|
-
};
|
|
173
|
-
await this.server.connect(transport);
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
else if (req.method === "POST") {
|
|
177
|
-
const sessionId = url.searchParams.get("sessionId");
|
|
178
|
-
const transport = sessionId ? this.sseTransports.get(sessionId) : null;
|
|
179
|
-
if (transport) {
|
|
180
|
-
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");
|
|
181
178
|
}
|
|
182
|
-
|
|
183
|
-
|
|
179
|
+
catch {
|
|
180
|
+
clearInterval(heartbeat);
|
|
184
181
|
}
|
|
185
|
-
|
|
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
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
res.writeHead(404).end("Session unknown");
|
|
186
199
|
}
|
|
200
|
+
return;
|
|
187
201
|
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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()));
|
|
207
258
|
}
|
|
208
|
-
|
|
209
|
-
|
|
259
|
+
}, 10000);
|
|
260
|
+
const cleanup = () => {
|
|
261
|
+
clearInterval(watchdog);
|
|
210
262
|
process.stdin.removeAllListeners("data");
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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];
|
|
242
298
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
res.on("data", (chunk) => {
|
|
250
|
-
lastActivity = Date.now();
|
|
251
|
-
const str = chunk.toString();
|
|
252
|
-
buffer += str;
|
|
253
|
-
if (!sessionId && buffer.includes("event: endpoint")) {
|
|
254
|
-
const match = buffer.match(/sessionId=([a-f0-9-]+)/);
|
|
255
|
-
if (match)
|
|
256
|
-
sessionId = match[1];
|
|
257
|
-
}
|
|
258
|
-
if (str.includes("event: message")) {
|
|
259
|
-
const lines = str.split("\n");
|
|
260
|
-
const dataLine = lines.find((l) => l.startsWith("data: "));
|
|
261
|
-
if (dataLine) {
|
|
262
|
-
try {
|
|
263
|
-
process.stdout.write(dataLine.substring(6) + "\n");
|
|
264
|
-
}
|
|
265
|
-
catch { }
|
|
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");
|
|
266
305
|
}
|
|
306
|
+
catch { /* ignore stdout errors */ }
|
|
267
307
|
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
setImmediate(() => setTimeout(startProxy, randomDelay()));
|
|
274
|
-
});
|
|
275
|
-
}).on("error", () => {
|
|
276
|
-
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...");
|
|
277
313
|
cleanup();
|
|
278
|
-
setImmediate
|
|
314
|
+
// Use setImmediate to break call stack
|
|
315
|
+
setImmediate(() => setTimeout(connect, randomDelay()));
|
|
279
316
|
});
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
|
|
317
|
+
}).on("error", () => {
|
|
318
|
+
console.error("[Nexus Guest] Proxy Receive Error. Retrying...");
|
|
319
|
+
cleanup();
|
|
320
|
+
setImmediate(() => setTimeout(connect, 1000 + randomDelay()));
|
|
321
|
+
});
|
|
322
|
+
};
|
|
323
|
+
connect();
|
|
283
324
|
}
|
|
284
325
|
}
|
|
285
326
|
const server = new NexusServer();
|
package/build/storage/index.js
CHANGED
|
@@ -13,7 +13,10 @@ export class StorageManager {
|
|
|
13
13
|
static get projectsRoot() { return path.join(CONFIG.rootStorage, "projects"); }
|
|
14
14
|
static get registryFile() { return path.join(CONFIG.rootStorage, "registry.json"); }
|
|
15
15
|
static get archivesDir() { return path.join(CONFIG.rootStorage, "archives"); }
|
|
16
|
+
static initialized = false;
|
|
16
17
|
static async init() {
|
|
18
|
+
if (this.initialized)
|
|
19
|
+
return;
|
|
17
20
|
await fs.mkdir(CONFIG.rootStorage, { recursive: true });
|
|
18
21
|
await fs.mkdir(this.globalDir, { recursive: true });
|
|
19
22
|
await fs.mkdir(this.projectsRoot, { recursive: true });
|
|
@@ -32,6 +35,10 @@ export class StorageManager {
|
|
|
32
35
|
catch {
|
|
33
36
|
// SQLite may not be available or database not ready - will be initialized on first use
|
|
34
37
|
}
|
|
38
|
+
this.initialized = true;
|
|
39
|
+
}
|
|
40
|
+
static resetInit() {
|
|
41
|
+
this.initialized = false;
|
|
35
42
|
}
|
|
36
43
|
/**
|
|
37
44
|
* Proactively reads and validates JSON.
|
package/build/storage/tasks.js
CHANGED
|
@@ -11,10 +11,13 @@ function generateTaskId() {
|
|
|
11
11
|
const random = Math.random().toString(36).substring(2, 6);
|
|
12
12
|
return `task_${timestamp}_${random}`;
|
|
13
13
|
}
|
|
14
|
+
let initialized = false;
|
|
14
15
|
/**
|
|
15
16
|
* Initialize the tasks table (run migrations)
|
|
16
17
|
*/
|
|
17
18
|
export function initTasksTable() {
|
|
19
|
+
if (initialized)
|
|
20
|
+
return;
|
|
18
21
|
const db = getDatabase();
|
|
19
22
|
const TASKS_SCHEMA = `
|
|
20
23
|
CREATE TABLE IF NOT EXISTS tasks (
|
|
@@ -50,6 +53,10 @@ export function initTasksTable() {
|
|
|
50
53
|
// Trigger may already exist in older SQLite versions without IF NOT EXISTS support
|
|
51
54
|
}
|
|
52
55
|
console.error("[Nexus] Tasks table initialized");
|
|
56
|
+
initialized = true;
|
|
57
|
+
}
|
|
58
|
+
export function resetTasksInit() {
|
|
59
|
+
initialized = false;
|
|
53
60
|
}
|
|
54
61
|
/**
|
|
55
62
|
* Create a new task
|
package/docs/CHANGELOG_zh.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
本项目的所有重大变更都将记录在此文件中。
|
|
4
4
|
|
|
5
|
+
## [0.3.5] - 2026-01-10
|
|
6
|
+
### 协议与稳定性
|
|
7
|
+
- **修复 (僵尸 Host)**: 实现了“智能重试”和“重新选举”逻辑。如果 Guest 反复连接到僵尸 Host(握手成功但 SSE 断开),现在会自动触发重新选举流程,将坏端口加入黑名单,并在必要时在从端口上将自身提升为 Host。
|
|
8
|
+
- **重构**: 启用了动态角色切换 (Guest -> Host),无需重启进程。
|
|
9
|
+
|
|
10
|
+
## [0.3.4] - 2026-01-10
|
|
11
|
+
### 协议与稳定性
|
|
12
|
+
- **新握手协议**: 引入 `POST /nexus/handshake` 替代旧版 `/hello`。支持严格的客户端/服务端版本校验和稳健的 Host 探测。
|
|
13
|
+
- **全局错误安全网**: 增加了 `uncaughtException` 和 `unhandledRejection` 处理器,防止后台任务错误导致进程退出,确保 Hub 的高可用性。
|
|
14
|
+
- **修复 (EOF 错误)**: 解决了由于 SQLite 非幂等初始化导致的“连接关闭:EOF”崩溃问题。
|
|
15
|
+
- **修复 (僵尸 Host)**: 改进了 Guest 的 Host检测逻辑,消除了死循环重试。
|
|
16
|
+
- **测试覆盖**: 新增 `guest_connection.test.ts` 验证 Guest-Host SSE 集成。
|
|
17
|
+
|
|
5
18
|
## [0.3.3] - 2026-01-10
|
|
6
19
|
### 🔄 零配置持久化 (Zero-Config Persistence)
|
|
7
20
|
- **支持 XDG Base Directory**: 将存储位置从不稳定的 `node_modules` 迁移至系统标准的 User Data 路径:
|