@kylindc/ccxray 1.2.0

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/server/hub.js ADDED
@@ -0,0 +1,418 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const http = require('http');
7
+
8
+ const HUB_DIR = process.env.CCXRAY_HOME || path.join(os.homedir(), '.ccxray');
9
+ const HUB_LOCK_PATH = path.join(HUB_DIR, 'hub.json');
10
+ const HUB_LOG_PATH = path.join(HUB_DIR, 'hub.log');
11
+ const IDLE_TIMEOUT_MS = 5000;
12
+ const DEAD_CLIENT_CHECK_MS = 30000;
13
+ const HUB_HEALTH_CHECK_MS = 5000;
14
+ const READINESS_POLL_MS = 200;
15
+ const READINESS_TIMEOUT_MS = 10000;
16
+ const HUB_LOG_MAX_BYTES = 1 * 1024 * 1024; // 1 MB
17
+ const HUB_LOG_KEEP_BYTES = 100 * 1024; // 100 KB
18
+
19
+ // ── Lockfile operations ─────────────────────────────────────────────
20
+
21
+ function ensureHubDir() {
22
+ if (!fs.existsSync(HUB_DIR)) fs.mkdirSync(HUB_DIR, { recursive: true });
23
+ }
24
+
25
+ function readHubLock() {
26
+ try {
27
+ return JSON.parse(fs.readFileSync(HUB_LOCK_PATH, 'utf8'));
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function writeHubLock(port, pid, versionOverride) {
34
+ ensureHubDir();
35
+ const version = versionOverride || require('../package.json').version;
36
+ const data = { port, pid, version, startedAt: new Date().toISOString() };
37
+ fs.writeFileSync(HUB_LOCK_PATH, JSON.stringify(data));
38
+ return data;
39
+ }
40
+
41
+ function deleteHubLock() {
42
+ try { fs.unlinkSync(HUB_LOCK_PATH); } catch {}
43
+ }
44
+
45
+ // ── PID check ───────────────────────────────────────────────────────
46
+
47
+ function isPidAlive(pid) {
48
+ try {
49
+ process.kill(pid, 0);
50
+ return true;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ // ── Health check (HTTP probe) ───────────────────────────────────────
57
+
58
+ function checkHubHealth(port, timeoutMs = 2000) {
59
+ return new Promise(resolve => {
60
+ const req = http.get(`http://localhost:${port}/_api/health`, { timeout: timeoutMs }, res => {
61
+ let data = '';
62
+ res.on('data', chunk => { data += chunk; });
63
+ res.on('end', () => {
64
+ try {
65
+ const parsed = JSON.parse(data);
66
+ resolve(parsed.ok === true);
67
+ } catch { resolve(false); }
68
+ });
69
+ });
70
+ req.on('error', () => resolve(false));
71
+ req.on('timeout', () => { req.destroy(); resolve(false); });
72
+ });
73
+ }
74
+
75
+ // ── Orphan hub probe (port-level fallback when lockfile missing) ────
76
+
77
+ function probeHubStatus(port, timeoutMs = 2000) {
78
+ return new Promise(resolve => {
79
+ const req = http.get(`http://localhost:${port}/_api/hub/status`, { timeout: timeoutMs }, res => {
80
+ let data = '';
81
+ res.on('data', chunk => { data += chunk; });
82
+ res.on('end', () => {
83
+ try {
84
+ const parsed = JSON.parse(data);
85
+ if (parsed.app === 'ccxray' && parsed.pid && parsed.version && parsed.port) resolve(parsed);
86
+ else resolve(null);
87
+ } catch { resolve(null); }
88
+ });
89
+ });
90
+ req.on('error', () => resolve(null));
91
+ req.on('timeout', () => { req.destroy(); resolve(null); });
92
+ });
93
+ }
94
+
95
+ // ── Hub discovery (dual verification: pid + health) ─────────────────
96
+
97
+ async function discoverHub(defaultPort) {
98
+ const lock = readHubLock();
99
+ if (lock) {
100
+ if (!isPidAlive(lock.pid)) {
101
+ deleteHubLock();
102
+ return null;
103
+ }
104
+ const healthy = await checkHubHealth(lock.port);
105
+ if (!healthy) {
106
+ deleteHubLock();
107
+ return null;
108
+ }
109
+ return lock;
110
+ }
111
+
112
+ // Lockfile missing — probe default port for orphan hub
113
+ if (!defaultPort) return null;
114
+ const status = await probeHubStatus(defaultPort);
115
+ if (!status) return null;
116
+ if (status.port !== defaultPort) return null; // reject port mismatch (non-ccxray service)
117
+ if (!isPidAlive(status.pid)) return null;
118
+
119
+ // Reconstruct lockfile from live hub (use hub's version, not client's)
120
+ const recovered = writeHubLock(status.port, status.pid, status.version);
121
+ return recovered;
122
+ }
123
+
124
+ // ── Version compatibility (semver major check) ──────────────────────
125
+
126
+ function checkVersionCompat(hubVersion) {
127
+ const clientVersion = require('../package.json').version;
128
+ if (hubVersion === clientVersion) return { ok: true };
129
+
130
+ const hubMajor = parseInt(hubVersion.split('.')[0], 10);
131
+ const clientMajor = parseInt(clientVersion.split('.')[0], 10);
132
+
133
+ if (hubMajor !== clientMajor) {
134
+ return {
135
+ ok: false,
136
+ fatal: true,
137
+ message: `Hub (v${hubVersion}) is incompatible with this client (v${clientVersion}). Close all ccxray instances and restart.`,
138
+ };
139
+ }
140
+
141
+ const hubMinor = parseInt(hubVersion.split('.')[1], 10);
142
+ const clientMinor = parseInt(clientVersion.split('.')[1], 10);
143
+ if (hubMinor !== clientMinor) {
144
+ return {
145
+ ok: true,
146
+ warning: `Hub is v${hubVersion}, client is v${clientVersion} (minor version mismatch)`,
147
+ };
148
+ }
149
+
150
+ return { ok: true };
151
+ }
152
+
153
+ // ── Fork detached hub process ───────────────────────────────────────
154
+
155
+ function forkHub(port) {
156
+ const { spawn } = require('child_process');
157
+ ensureHubDir();
158
+ truncateHubLog();
159
+
160
+ const fd = fs.openSync(HUB_LOG_PATH, 'a');
161
+ const hubScript = path.resolve(__dirname, 'index.js');
162
+ const args = ['--port', String(port), '--hub-mode'];
163
+
164
+ const child = spawn(process.execPath, [hubScript, ...args], {
165
+ detached: true,
166
+ stdio: ['ignore', fd, fd],
167
+ env: { ...process.env },
168
+ });
169
+ child.unref();
170
+ fs.closeSync(fd);
171
+ return child.pid;
172
+ }
173
+
174
+ // ── Wait for hub readiness (poll lockfile) ──────────────────────────
175
+
176
+ function waitForHubReady(timeoutMs = READINESS_TIMEOUT_MS) {
177
+ return new Promise((resolve, reject) => {
178
+ const start = Date.now();
179
+ const check = () => {
180
+ const lock = readHubLock();
181
+ if (lock) return resolve(lock);
182
+ if (Date.now() - start > timeoutMs) {
183
+ return reject(new Error(`Hub did not become ready within ${timeoutMs / 1000}s. Check ${HUB_LOG_PATH}`));
184
+ }
185
+ setTimeout(check, READINESS_POLL_MS);
186
+ };
187
+ check();
188
+ });
189
+ }
190
+
191
+ // ── Client registration (HTTP calls to hub) ─────────────────────────
192
+
193
+ function registerClient(port, pid, cwd) {
194
+ return new Promise((resolve, reject) => {
195
+ const body = JSON.stringify({ pid, cwd });
196
+ const req = http.request(`http://localhost:${port}/_api/hub/register`, {
197
+ method: 'POST',
198
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
199
+ timeout: 3000,
200
+ }, res => {
201
+ let data = '';
202
+ res.on('data', chunk => { data += chunk; });
203
+ res.on('end', () => {
204
+ if (res.statusCode !== 200) return resolve(null);
205
+ try { resolve(JSON.parse(data)); } catch { resolve(null); }
206
+ });
207
+ });
208
+ req.on('error', reject);
209
+ req.on('timeout', () => { req.destroy(); reject(new Error('register timeout')); });
210
+ req.end(body);
211
+ });
212
+ }
213
+
214
+ function unregisterClient(port, pid) {
215
+ return new Promise(resolve => {
216
+ const body = JSON.stringify({ pid });
217
+ const req = http.request(`http://localhost:${port}/_api/hub/unregister`, {
218
+ method: 'POST',
219
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
220
+ timeout: 3000,
221
+ }, res => {
222
+ res.resume();
223
+ resolve();
224
+ });
225
+ req.on('error', () => resolve());
226
+ req.on('timeout', () => { req.destroy(); resolve(); });
227
+ req.end(body);
228
+ });
229
+ }
230
+
231
+ // ── Hub log truncation ──────────────────────────────────────────────
232
+
233
+ function truncateHubLog() {
234
+ try {
235
+ const stat = fs.statSync(HUB_LOG_PATH);
236
+ if (stat.size > HUB_LOG_MAX_BYTES) {
237
+ const buf = Buffer.alloc(HUB_LOG_KEEP_BYTES);
238
+ const fd = fs.openSync(HUB_LOG_PATH, 'r');
239
+ fs.readSync(fd, buf, 0, HUB_LOG_KEEP_BYTES, stat.size - HUB_LOG_KEEP_BYTES);
240
+ fs.closeSync(fd);
241
+ // Find first newline to avoid partial line
242
+ const nl = buf.indexOf(0x0a);
243
+ const clean = nl >= 0 ? buf.subarray(nl + 1) : buf;
244
+ fs.writeFileSync(HUB_LOG_PATH, clean);
245
+ }
246
+ } catch {}
247
+ }
248
+
249
+ // ── Client lifecycle (hub-side state) ───────────────────────────────
250
+
251
+ const clients = new Map(); // pid → { cwd, connectedAt }
252
+ let idleTimer = null;
253
+ let deadCheckInterval = null;
254
+ let hubListenPort = null; // set once at startup, survives lockfile deletion
255
+ let onShutdown = null; // injectable shutdown handler (default: process.exit)
256
+
257
+ function addClient(pid, cwd) {
258
+ if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
259
+ clients.set(pid, { cwd, connectedAt: new Date().toISOString() });
260
+ }
261
+
262
+ function removeClient(pid) {
263
+ clients.delete(pid);
264
+ if (clients.size === 0) startIdleTimer();
265
+ }
266
+
267
+ function startIdleTimer() {
268
+ if (idleTimer) return;
269
+ idleTimer = setTimeout(() => {
270
+ console.log('All clients disconnected. Shutting down hub.');
271
+ shutdownHub();
272
+ }, IDLE_TIMEOUT_MS);
273
+ }
274
+
275
+ function setOnShutdown(fn) { onShutdown = fn; }
276
+
277
+ function shutdownHub() {
278
+ if (deadCheckInterval) clearInterval(deadCheckInterval);
279
+ deleteHubLock();
280
+ if (onShutdown) onShutdown();
281
+ else process.exit(0);
282
+ }
283
+
284
+ function startDeadClientCheck() {
285
+ deadCheckInterval = setInterval(() => {
286
+ for (const [pid] of clients) {
287
+ if (!isPidAlive(pid)) {
288
+ console.log(`Dead client detected: pid ${pid}, removing.`);
289
+ removeClient(pid);
290
+ }
291
+ }
292
+ }, DEAD_CLIENT_CHECK_MS);
293
+ deadCheckInterval.unref();
294
+ }
295
+
296
+ function setHubPort(port) { hubListenPort = port; }
297
+
298
+ function getHubStatus() {
299
+ return {
300
+ app: 'ccxray',
301
+ port: hubListenPort || readHubLock()?.port,
302
+ pid: process.pid,
303
+ version: require('../package.json').version,
304
+ uptime: Math.floor(process.uptime()),
305
+ clients: [...clients.entries()].map(([pid, info]) => ({ pid, ...info })),
306
+ };
307
+ }
308
+
309
+ // ── Hub route handler (mounted in server) ───────────────────────────
310
+
311
+ function handleHubRoutes(clientReq, clientRes) {
312
+ if (clientReq.url === '/_api/health' && clientReq.method === 'GET') {
313
+ clientRes.writeHead(200, { 'Content-Type': 'application/json' });
314
+ clientRes.end(JSON.stringify({ ok: true }));
315
+ return true;
316
+ }
317
+
318
+ if (clientReq.url === '/_api/hub/status' && clientReq.method === 'GET') {
319
+ clientRes.writeHead(200, { 'Content-Type': 'application/json' });
320
+ clientRes.end(JSON.stringify(getHubStatus()));
321
+ return true;
322
+ }
323
+
324
+ if (clientReq.url === '/_api/hub/register' && clientReq.method === 'POST') {
325
+ let body = '';
326
+ clientReq.on('data', c => { body += c; });
327
+ clientReq.on('end', () => {
328
+ try {
329
+ const { pid, cwd } = JSON.parse(body);
330
+ const wasEmpty = clients.size === 0;
331
+ addClient(pid, cwd);
332
+ clientRes.writeHead(200, { 'Content-Type': 'application/json' });
333
+ clientRes.end(JSON.stringify({ ok: true, firstClient: wasEmpty }));
334
+ } catch {
335
+ clientRes.writeHead(400);
336
+ clientRes.end('Bad request');
337
+ }
338
+ });
339
+ return true;
340
+ }
341
+
342
+ if (clientReq.url === '/_api/hub/unregister' && clientReq.method === 'POST') {
343
+ let body = '';
344
+ clientReq.on('data', c => { body += c; });
345
+ clientReq.on('end', () => {
346
+ try {
347
+ const { pid } = JSON.parse(body);
348
+ removeClient(pid);
349
+ clientRes.writeHead(200, { 'Content-Type': 'application/json' });
350
+ clientRes.end(JSON.stringify({ ok: true }));
351
+ } catch {
352
+ clientRes.writeHead(400);
353
+ clientRes.end('Bad request');
354
+ }
355
+ });
356
+ return true;
357
+ }
358
+
359
+ return false;
360
+ }
361
+
362
+ // ── Hub pid monitoring (client-side recovery) ───────────────────────
363
+
364
+ function startHubMonitor(hubPid, hubPort, onRecovery) {
365
+ const interval = setInterval(async () => {
366
+ if (isPidAlive(hubPid)) return;
367
+
368
+ clearInterval(interval);
369
+ console.error('\x1b[33mHub process died. Attempting recovery...\x1b[0m');
370
+ deleteHubLock();
371
+
372
+ try {
373
+ forkHub(hubPort);
374
+ const lock = await waitForHubReady();
375
+ if (lock.port !== hubPort) {
376
+ console.error(`\x1b[31mHub recovered on port ${lock.port} but Claude is using port ${hubPort}. Cannot recover.\x1b[0m`);
377
+ try { process.kill(lock.pid, 'SIGTERM'); } catch {}
378
+ return;
379
+ }
380
+ console.error(`\x1b[32mHub recovered (pid ${lock.pid}, port ${lock.port})\x1b[0m`);
381
+ if (onRecovery) onRecovery(lock);
382
+ startHubMonitor(lock.pid, lock.port, onRecovery);
383
+ } catch (err) {
384
+ console.error(`\x1b[31mHub recovery failed: ${err.message}\x1b[0m`);
385
+ }
386
+ }, HUB_HEALTH_CHECK_MS);
387
+ interval.unref();
388
+ return interval;
389
+ }
390
+
391
+ module.exports = {
392
+ HUB_DIR,
393
+ HUB_LOCK_PATH,
394
+ HUB_LOG_PATH,
395
+ readHubLock,
396
+ writeHubLock,
397
+ deleteHubLock,
398
+ isPidAlive,
399
+ checkHubHealth,
400
+ probeHubStatus,
401
+ discoverHub,
402
+ checkVersionCompat,
403
+ forkHub,
404
+ waitForHubReady,
405
+ registerClient,
406
+ unregisterClient,
407
+ truncateHubLog,
408
+ addClient,
409
+ removeClient,
410
+ startIdleTimer,
411
+ setOnShutdown,
412
+ shutdownHub,
413
+ startDeadClientCheck,
414
+ setHubPort,
415
+ getHubStatus,
416
+ handleHubRoutes,
417
+ startHubMonitor,
418
+ };