claude-code-hub 0.37.0 → 0.39.0-rc.1
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/README.md +110 -40
- package/lib/net-guard.js +20 -3
- package/package.json +7 -5
- package/public/app.js +206 -47
- package/public/index.html +131 -10
- package/public/locked.html +169 -0
- package/public/sw.js +12 -2
- package/server.js +367 -58
package/server.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
const { spawn } = require('child_process');
|
|
4
|
+
const crypto = require('crypto');
|
|
4
5
|
const express = require('express');
|
|
5
6
|
const fs = require('fs');
|
|
6
7
|
const http = require('http');
|
|
8
|
+
const tcp = require('net');
|
|
7
9
|
const os = require('os');
|
|
8
10
|
const path = require('path');
|
|
9
11
|
const { createNetGuard } = require('./lib/net-guard');
|
|
@@ -24,6 +26,12 @@ const MEMORY_PORT = parseInt(getArg('memory-port') || '3544', 10);
|
|
|
24
26
|
|
|
25
27
|
const POOL_SIZE = Math.max(1, parseInt(getArg('pool-size') || '3', 10));
|
|
26
28
|
const DEFAULT_PORTS = { marketplace: MARKETPLACE_PORT, kanban: KANBAN_PORT, cost: COST_PORT, memory: MEMORY_PORT };
|
|
29
|
+
const RESTART_DELAY_MS = 500;
|
|
30
|
+
// A rate window, not a total: a child may die and recover all session, but one that cannot stay up
|
|
31
|
+
// stops being restarted. A consecutive counter cannot express that — anything that starts at all
|
|
32
|
+
// clears it, so a child that starts and dies forever restarts forever.
|
|
33
|
+
const MAX_RESTARTS = 5;
|
|
34
|
+
const RESTART_WINDOW_MS = 60_000;
|
|
27
35
|
|
|
28
36
|
// One child set per config dir, kept alive after a switch so switching back is instant. Map
|
|
29
37
|
// insertion order doubles as LRU: activating a dir re-inserts it at the end.
|
|
@@ -36,7 +44,8 @@ function activePool() {
|
|
|
36
44
|
// Every sub-app resolves CLAUDE_CONFIG_DIR once at startup into a module constant, so switching
|
|
37
45
|
// the dir means restarting the children. Kept on the server, not in the browser: the dir has to
|
|
38
46
|
// be known before the first spawn, and the hub already has one localStorage key to reason about.
|
|
39
|
-
const
|
|
47
|
+
const HUB_DIR = path.join(os.homedir(), '.claude-hub');
|
|
48
|
+
const HUB_CONFIG_FILE = path.join(HUB_DIR, 'config.json');
|
|
40
49
|
|
|
41
50
|
// The dir the hub would use with no saved config. Set by loadHubConfig; the client uses it to keep
|
|
42
51
|
// the default dir out of the window title.
|
|
@@ -76,6 +85,8 @@ function loadHubConfig() {
|
|
|
76
85
|
const savedActive = typeof saved.activeConfigDir === 'string' ? canonical(saved.activeConfigDir) : null;
|
|
77
86
|
const active = dirs.includes(savedActive) ? savedActive : fallback;
|
|
78
87
|
const config = { configDirs: dirs, activeConfigDir: active };
|
|
88
|
+
// Hand-edited only; kept verbatim so a rewrite of the file does not drop it.
|
|
89
|
+
if (saved.terminal && typeof saved.terminal === 'object') config.terminal = saved.terminal;
|
|
79
90
|
// Configs saved before canonicalization can hold one dir under two spellings; persist the merge.
|
|
80
91
|
if (serializeConfig(config) !== raw) writeHubConfig(config);
|
|
81
92
|
return config;
|
|
@@ -101,18 +112,83 @@ const hubConfig = loadHubConfig();
|
|
|
101
112
|
// while every iframe 403'd on its own Host check.
|
|
102
113
|
const net = createNetGuard({ appName: 'Claude Code Hub' });
|
|
103
114
|
|
|
115
|
+
// cck's embedded terminal, on by default under the hub (standalone cck keeps it opt-in).
|
|
116
|
+
// `"terminal": {"enabled": false}` or --disable-terminal turns it off. One token per hub launch,
|
|
117
|
+
// shared by every pool: the hub puts it in the kanban iframe's URL fragment and cck checks it on
|
|
118
|
+
// the WebSocket. cck applies the rest of its policy (exposure refusal, Origin, session cap) itself.
|
|
119
|
+
const TERMINAL = {
|
|
120
|
+
...(hubConfig.terminal || {}),
|
|
121
|
+
enabled: !process.argv.includes('--disable-terminal') && hubConfig.terminal?.enabled !== false,
|
|
122
|
+
};
|
|
123
|
+
const TERMINAL_TOKEN = TERMINAL.enabled ? crypto.randomBytes(32).toString('hex') : null;
|
|
124
|
+
|
|
125
|
+
// Loopback is not a user boundary: any local account can reach the hub port, and /api/config carries
|
|
126
|
+
// the terminal token. Persisted, unlike the terminal token, so an installed PWA's cookie outlives a
|
|
127
|
+
// hub restart.
|
|
128
|
+
const HUB_TOKEN_FILE = path.join(HUB_DIR, 'token');
|
|
129
|
+
const TOKEN_COOKIE = 'hub_token';
|
|
130
|
+
const TOKEN_COOKIE_RE = /(?:^|;\s*)hub_token=([^;]*)/;
|
|
131
|
+
const TOKEN_COOKIE_MAX_AGE_MS = 400 * 24 * 60 * 60 * 1000;
|
|
132
|
+
const LOCKED_PAGE = path.join(__dirname, 'public', 'locked.html');
|
|
133
|
+
|
|
134
|
+
function loadHubToken() {
|
|
135
|
+
try {
|
|
136
|
+
const saved = fs.readFileSync(HUB_TOKEN_FILE, 'utf8').trim();
|
|
137
|
+
if (/^[0-9a-f]{64}$/.test(saved)) return saved;
|
|
138
|
+
} catch {}
|
|
139
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
140
|
+
fs.mkdirSync(HUB_DIR, { recursive: true, mode: 0o700 });
|
|
141
|
+
fs.writeFileSync(HUB_TOKEN_FILE, `${token}\n`, { mode: 0o600 });
|
|
142
|
+
return token;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const HUB_TOKEN = loadHubToken();
|
|
146
|
+
const HUB_TOKEN_BUF = Buffer.from(HUB_TOKEN);
|
|
147
|
+
|
|
148
|
+
function tokenMatches(candidate) {
|
|
149
|
+
if (typeof candidate !== 'string' || candidate.length !== HUB_TOKEN.length) return false;
|
|
150
|
+
return crypto.timingSafeEqual(Buffer.from(candidate), HUB_TOKEN_BUF);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Scripts, styles, icons and the manifest stay public: they hold no secrets, and Chrome fetches the
|
|
154
|
+
// manifest without cookies, so gating it would break PWA install.
|
|
155
|
+
function tokenGuard(req, res, next) {
|
|
156
|
+
const isApi = req.path.startsWith('/api/');
|
|
157
|
+
if (!isApi && req.path !== '/' && req.path !== '/index.html') return next();
|
|
158
|
+
if (tokenMatches(req.query.token)) {
|
|
159
|
+
res.cookie(TOKEN_COOKIE, HUB_TOKEN, { httpOnly: true, sameSite: 'strict', maxAge: TOKEN_COOKIE_MAX_AGE_MS });
|
|
160
|
+
if (isApi) return next();
|
|
161
|
+
const url = new URL(req.originalUrl, 'http://hub');
|
|
162
|
+
url.searchParams.delete('token');
|
|
163
|
+
return res.redirect(302, url.pathname + url.search);
|
|
164
|
+
}
|
|
165
|
+
if (tokenMatches(TOKEN_COOKIE_RE.exec(req.headers.cookie || '')?.[1])) return next();
|
|
166
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
167
|
+
if (isApi) return res.status(401).json({ error: 'hub token required' });
|
|
168
|
+
res.status(401).sendFile(LOCKED_PAGE, { cacheControl: false });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function childEnv(pool, name) {
|
|
172
|
+
const env = {
|
|
173
|
+
...process.env,
|
|
174
|
+
PORT: '0',
|
|
175
|
+
CLAUDE_CONFIG_DIR: pool.dir,
|
|
176
|
+
CLAUDE_HUB: '1',
|
|
177
|
+
HUB_URL: `http://localhost:${HUB_PORT}`,
|
|
178
|
+
HOST: net.BIND_HOST,
|
|
179
|
+
ALLOWED_HOSTS: net.ALLOWED_HOSTS,
|
|
180
|
+
};
|
|
181
|
+
if (name === 'kanban' && TERMINAL.enabled) {
|
|
182
|
+
env.CCK_TERMINAL = JSON.stringify(TERMINAL);
|
|
183
|
+
env.CCK_TERMINAL_TOKEN = TERMINAL_TOKEN;
|
|
184
|
+
}
|
|
185
|
+
return env;
|
|
186
|
+
}
|
|
187
|
+
|
|
104
188
|
function spawnApp(pool, name, cmd, args) {
|
|
105
189
|
const child = spawn(cmd, args, {
|
|
106
190
|
cwd: __dirname,
|
|
107
|
-
env:
|
|
108
|
-
...process.env,
|
|
109
|
-
PORT: '0',
|
|
110
|
-
CLAUDE_CONFIG_DIR: pool.dir,
|
|
111
|
-
CLAUDE_HUB: '1',
|
|
112
|
-
HUB_URL: `http://localhost:${HUB_PORT}`,
|
|
113
|
-
HOST: net.BIND_HOST,
|
|
114
|
-
ALLOWED_HOSTS: net.ALLOWED_HOSTS,
|
|
115
|
-
},
|
|
191
|
+
env: childEnv(pool, name),
|
|
116
192
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
117
193
|
});
|
|
118
194
|
|
|
@@ -138,24 +214,44 @@ function spawnApp(pool, name, cmd, args) {
|
|
|
138
214
|
child.stderr.on('data', (d) => process.stderr.write(`[${name}] ${d}`));
|
|
139
215
|
child.on('exit', (code) => {
|
|
140
216
|
console.log(`[${name}] exited (code ${code})`);
|
|
217
|
+
// The port dies with the child and the OS may hand it to a stranger, so stop dialing it.
|
|
218
|
+
delete pool.ports[name];
|
|
141
219
|
markReady();
|
|
220
|
+
if (pool.retired || pool.children.get(name) !== child) return;
|
|
221
|
+
// Nothing else would ever bring this app back: a pool is only rebuilt when its dir is
|
|
222
|
+
// re-activated.
|
|
223
|
+
const recent = (pool.restarts[name] || []).filter((t) => Date.now() - t < RESTART_WINDOW_MS);
|
|
224
|
+
if (recent.length >= MAX_RESTARTS) {
|
|
225
|
+
console.log(`[${name}] gave up after ${MAX_RESTARTS} restarts in ${RESTART_WINDOW_MS / 1000}s`);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
recent.push(Date.now());
|
|
229
|
+
pool.restarts[name] = recent;
|
|
230
|
+
setTimeout(() => {
|
|
231
|
+
if (pool.retired) return;
|
|
232
|
+
console.log(`[${name}] restarting (${recent.length}/${MAX_RESTARTS})`);
|
|
233
|
+
spawnApp(pool, name, cmd, args);
|
|
234
|
+
}, RESTART_DELAY_MS).unref();
|
|
142
235
|
});
|
|
143
236
|
|
|
144
|
-
pool.children.
|
|
237
|
+
pool.children.set(name, child);
|
|
145
238
|
return child;
|
|
146
239
|
}
|
|
147
240
|
|
|
148
|
-
function
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
child.kill();
|
|
155
|
-
}
|
|
241
|
+
function killChild(child) {
|
|
242
|
+
if (child.exitCode !== null || child.killed) return;
|
|
243
|
+
if (process.platform === 'win32') {
|
|
244
|
+
spawn('taskkill', ['/pid', String(child.pid), '/f', '/t'], { stdio: 'ignore' });
|
|
245
|
+
} else {
|
|
246
|
+
child.kill();
|
|
156
247
|
}
|
|
157
248
|
}
|
|
158
249
|
|
|
250
|
+
function killPool(pool) {
|
|
251
|
+
pool.retired = true;
|
|
252
|
+
for (const child of pool.children.values()) killChild(child);
|
|
253
|
+
}
|
|
254
|
+
|
|
159
255
|
function killAll() {
|
|
160
256
|
for (const pool of pools.values()) killPool(pool);
|
|
161
257
|
}
|
|
@@ -178,18 +274,48 @@ async function ensurePool(dir) {
|
|
|
178
274
|
pools.set(dir, pool);
|
|
179
275
|
return pool.ready;
|
|
180
276
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
}
|
|
186
|
-
pool = { dir, children: [], ports: {} };
|
|
277
|
+
if (pools.size >= POOL_SIZE) await evictPools(dir);
|
|
278
|
+
pool = pools.get(dir);
|
|
279
|
+
if (pool) return pool.ready;
|
|
280
|
+
pool = { dir, children: new Map(), ports: {}, restarts: {}, retired: false };
|
|
187
281
|
pools.set(dir, pool);
|
|
188
282
|
spawnChildren(pool);
|
|
189
|
-
|
|
283
|
+
const ready = [...pool.children.values()].map((c) => c.ready);
|
|
284
|
+
pool.ready = withTimeout(Promise.all(ready), 20000).then(() => pool);
|
|
190
285
|
return pool.ready;
|
|
191
286
|
}
|
|
192
287
|
|
|
288
|
+
async function terminalCount(pool) {
|
|
289
|
+
const port = pool.ports.kanban;
|
|
290
|
+
if (!port) return 0;
|
|
291
|
+
try {
|
|
292
|
+
const r = await fetch(`http://127.0.0.1:${port}/api/terminals`, { signal: AbortSignal.timeout(1500) });
|
|
293
|
+
return r.ok ? (await r.json()).sessions?.length || 0 : 0;
|
|
294
|
+
} catch {
|
|
295
|
+
return 0;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// A pool whose cck holds live terminals is pinned: killing it would kill running claude sessions.
|
|
300
|
+
// When every candidate is pinned the pool size is exceeded rather than losing work.
|
|
301
|
+
async function evictPools(keepDir) {
|
|
302
|
+
let pinned = new Set();
|
|
303
|
+
if (TERMINAL.enabled) {
|
|
304
|
+
const candidates = [...pools.values()].filter((p) => p.dir !== keepDir);
|
|
305
|
+
const counts = await Promise.all(candidates.map(terminalCount));
|
|
306
|
+
pinned = new Set(candidates.filter((_, i) => counts[i]).map((p) => p.dir));
|
|
307
|
+
}
|
|
308
|
+
while (pools.size >= POOL_SIZE) {
|
|
309
|
+
const victim = [...pools.keys()].find((d) => d !== keepDir && !pinned.has(d));
|
|
310
|
+
if (!victim) {
|
|
311
|
+
console.log(`Pool size ${POOL_SIZE} exceeded: every other pool has live terminals`);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
console.log(`Evicting children for ${victim}`);
|
|
315
|
+
dropPool(victim);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
193
319
|
function dropPool(dir) {
|
|
194
320
|
const pool = pools.get(dir);
|
|
195
321
|
if (!pool) return;
|
|
@@ -265,42 +391,206 @@ function rewriteOrigin(origin, publicPort, childPort) {
|
|
|
265
391
|
|
|
266
392
|
const proxyAgent = new http.Agent({ keepAlive: true, maxSockets: 64 });
|
|
267
393
|
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
394
|
+
// Two failures look the same from here and both recover on their own: a pooled socket the child
|
|
395
|
+
// closed under us, and a child that is restarting. The port is re-read per attempt, so a replay
|
|
396
|
+
// reaches the replacement child rather than the ephemeral port that died with the old one.
|
|
397
|
+
const RETRYABLE = new Set(['ECONNRESET', 'EPIPE', 'ECONNABORTED', 'ECONNREFUSED', 'ETIMEDOUT']);
|
|
398
|
+
const MAX_ATTEMPTS = 3;
|
|
399
|
+
const ATTEMPT_DELAY_MS = 250;
|
|
400
|
+
// A connect to a freshly-bound loopback port is sometimes black-holed on Windows (the child shows
|
|
401
|
+
// as LISTENING and answers nothing) and the OS gives up only after ~20s. This caps the connect
|
|
402
|
+
// alone, never the response: a sub-app handler may legitimately take minutes on a cold scan, and
|
|
403
|
+
// capping time-to-first-byte would kill that request, replay it, and then kill a healthy child.
|
|
404
|
+
const CONNECT_TIMEOUT_MS = 3000;
|
|
405
|
+
// A restart costs about a second, so a document load waits it out instead of showing an error.
|
|
406
|
+
const NAV_PORT_WAIT_MS = 10000;
|
|
407
|
+
const API_PORT_WAIT_MS = 2000;
|
|
408
|
+
|
|
409
|
+
const delay = (ms) => new Promise((r) => setTimeout(r, ms).unref());
|
|
410
|
+
|
|
411
|
+
// An iframe document load, as opposed to the sub-app's own fetch/XHR.
|
|
412
|
+
function isNavigation(req) {
|
|
413
|
+
if (req.headers['sec-fetch-mode']) return req.headers['sec-fetch-mode'] === 'navigate';
|
|
414
|
+
return (req.headers.accept || '').includes('text/html');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Races the child's own ready promise rather than polling it, so a restarted app starts serving the
|
|
418
|
+
// moment it prints its port instead of on the next tick.
|
|
419
|
+
async function waitForPort(name, deadline) {
|
|
420
|
+
for (;;) {
|
|
421
|
+
const pool = activePool();
|
|
422
|
+
const port = pool?.ports[name];
|
|
423
|
+
if (port) return port;
|
|
424
|
+
const remaining = deadline - Date.now();
|
|
425
|
+
if (remaining <= 0) return null;
|
|
426
|
+
await Promise.race([pool?.children.get(name)?.ready, delay(Math.min(remaining, 250))]);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
431
|
+
|
|
432
|
+
// A document load that fails gets a page that reloads itself — the reload the user would do by
|
|
433
|
+
// hand. The cap is small because the server already waited NAV_PORT_WAIT_MS before serving this,
|
|
434
|
+
// and each reload re-spends that wait. sessionStorage throws when site data is blocked, and a page
|
|
435
|
+
// whose only job is to reload must still reload there.
|
|
436
|
+
function retryPage(name, detail) {
|
|
437
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(name)}</title>
|
|
438
|
+
<style>:root{color-scheme:light dark}body{font:14px system-ui,sans-serif;margin:0;display:grid;place-items:center;height:100vh}
|
|
439
|
+
div{text-align:center;opacity:.7}code{font-size:12px;opacity:.6}</style>
|
|
440
|
+
</head><body><div><p id="m">${escapeHtml(name)} is restarting…</p><p><code>${escapeHtml(detail)}</code></p></div>
|
|
441
|
+
<script>
|
|
442
|
+
let n = 0;
|
|
443
|
+
const k = 'hub-retry:' + location.pathname;
|
|
444
|
+
try { n = +(sessionStorage.getItem(k) || 0); sessionStorage.setItem(k, n + 1); } catch {}
|
|
445
|
+
if (n < 3) setTimeout(() => location.reload(), 1000);
|
|
446
|
+
else { try { sessionStorage.removeItem(k); } catch {} document.getElementById('m').textContent = 'not responding'; }
|
|
447
|
+
</script></body></html>`;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function sendUnavailable(req, res, name, status, detail) {
|
|
451
|
+
if (res.destroyed || res.headersSent) return;
|
|
452
|
+
if (isNavigation(req)) {
|
|
453
|
+
res.writeHead(status, { 'Retry-After': '1', 'Content-Type': 'text/html; charset=utf-8' });
|
|
454
|
+
res.end(retryPage(name, detail));
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
// The sub-app's own callers hand the body to .json(): plain text surfaces as a SyntaxError
|
|
458
|
+
// instead of the reason.
|
|
459
|
+
res.writeHead(status, { 'Retry-After': '1', 'Content-Type': 'application/json' });
|
|
460
|
+
res.end(JSON.stringify({ error: detail }));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// A pooled socket the child closed under us is normal and recovers on the next attempt. A port that
|
|
464
|
+
// never completes a connect does not, so the child is replaced and the respawn gives it a new one.
|
|
465
|
+
function replaceChild(name, childPort, err) {
|
|
466
|
+
const pool = activePool();
|
|
467
|
+
const child = pool?.children.get(name);
|
|
468
|
+
if (!child || pool.ports[name] !== childPort) return;
|
|
469
|
+
console.log(`[${name}] unreachable at 127.0.0.1:${childPort} (${err.code}) - replacing it`);
|
|
470
|
+
delete pool.ports[name];
|
|
471
|
+
killChild(child);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Fires only while the socket is still connecting, so a slow handler is never interrupted.
|
|
475
|
+
function capConnect(upstream, name, childPort) {
|
|
476
|
+
upstream.on('socket', (socket) => {
|
|
477
|
+
if (!socket.connecting) return;
|
|
478
|
+
const timer = setTimeout(() => {
|
|
479
|
+
const err = new Error(`no connect to ${name} at 127.0.0.1:${childPort}`);
|
|
480
|
+
err.code = 'ETIMEDOUT';
|
|
481
|
+
upstream.destroy(err);
|
|
482
|
+
}, CONNECT_TIMEOUT_MS);
|
|
483
|
+
const clear = () => clearTimeout(timer);
|
|
484
|
+
socket.once('connect', clear);
|
|
485
|
+
upstream.once('close', clear);
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Resolves null once the response is committed (or the client is gone), or the error when nothing
|
|
490
|
+
// has been written yet and the caller may still retry.
|
|
491
|
+
function forward(name, req, res, childPort, headers, replayable, onUpstream) {
|
|
492
|
+
return new Promise((resolve) => {
|
|
282
493
|
const upstream = http.request(
|
|
283
494
|
{ host: '127.0.0.1', port: childPort, method: req.method, path: req.url, headers, agent: proxyAgent },
|
|
284
495
|
(u) => {
|
|
285
496
|
res.writeHead(u.statusCode, u.headers);
|
|
286
497
|
u.pipe(res);
|
|
498
|
+
resolve(null);
|
|
287
499
|
},
|
|
288
500
|
);
|
|
501
|
+
onUpstream(upstream);
|
|
502
|
+
capConnect(upstream, name, childPort);
|
|
289
503
|
upstream.on('error', (err) => {
|
|
290
|
-
if (
|
|
291
|
-
|
|
504
|
+
if (res.headersSent) {
|
|
505
|
+
res.end();
|
|
506
|
+
resolve(null);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
resolve(err);
|
|
510
|
+
});
|
|
511
|
+
if (replayable) upstream.end();
|
|
512
|
+
else req.pipe(upstream);
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Host is forwarded untouched so the child's hostGuard still sees what the browser sent. Origin is
|
|
517
|
+
// the one header the child cannot judge on its own: its guard compares the port to its own
|
|
518
|
+
// ephemeral one, so the public-port spelling is mapped to the child's; anything else passes
|
|
519
|
+
// through as-is and the child rejects it.
|
|
520
|
+
function proxyHandler(name) {
|
|
521
|
+
return async (req, res) => {
|
|
522
|
+
const headers = { ...req.headers };
|
|
523
|
+
// req is consumed by the first attempt, so only a request with no body can be re-sent. Waiting
|
|
524
|
+
// for a port is not a replay — nothing has been sent yet — so every method gets the same wait.
|
|
525
|
+
const replayable = req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS';
|
|
526
|
+
// One deadline for the whole request, not one per attempt: a retry must not re-spend the wait.
|
|
527
|
+
const deadline = Date.now() + (isNavigation(req) ? NAV_PORT_WAIT_MS : API_PORT_WAIT_MS);
|
|
528
|
+
// One listener for the request, however many attempts it takes.
|
|
529
|
+
let current = null;
|
|
530
|
+
res.once('close', () => current?.destroy());
|
|
531
|
+
|
|
532
|
+
for (let attempt = 1; !res.destroyed; attempt++) {
|
|
533
|
+
const childPort = await waitForPort(name, deadline);
|
|
534
|
+
if (!childPort) {
|
|
535
|
+
sendUnavailable(req, res, name, 503, `${name} is starting`);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (req.headers.origin) {
|
|
539
|
+
headers.origin = rewriteOrigin(req.headers.origin, publicPorts[name], childPort);
|
|
540
|
+
}
|
|
541
|
+
const err = await forward(name, req, res, childPort, headers, replayable, (u) => {
|
|
542
|
+
current = u;
|
|
543
|
+
});
|
|
544
|
+
if (!err) return;
|
|
545
|
+
if (!replayable || attempt >= MAX_ATTEMPTS || !RETRYABLE.has(err.code)) {
|
|
546
|
+
// A port that ate every attempt is not coming back; anything less is a transient close.
|
|
547
|
+
if (attempt >= MAX_ATTEMPTS) replaceChild(name, childPort, err);
|
|
548
|
+
sendUnavailable(req, res, name, 502, err.message);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
await delay(ATTEMPT_DELAY_MS);
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// WebSocket upgrades (cck's terminal) are tunnelled as raw bytes for every app. Host and Origin get
|
|
557
|
+
// the same treatment as proxyHandler; the child runs its own upgrade checks, and a child with no
|
|
558
|
+
// upgrade listener closes the socket.
|
|
559
|
+
function proxyUpgrade(name) {
|
|
560
|
+
return async (req, socket, head) => {
|
|
561
|
+
socket.on('error', () => socket.destroy());
|
|
562
|
+
const childPort = await waitForPort(name, Date.now() + API_PORT_WAIT_MS);
|
|
563
|
+
if (!childPort || socket.destroyed) {
|
|
564
|
+
socket.destroy();
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const lines = [`${req.method} ${req.url} HTTP/${req.httpVersion}`];
|
|
568
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) {
|
|
569
|
+
let value = req.rawHeaders[i + 1];
|
|
570
|
+
if (req.rawHeaders[i].toLowerCase() === 'origin') value = rewriteOrigin(value, publicPorts[name], childPort);
|
|
571
|
+
lines.push(`${req.rawHeaders[i]}: ${value}`);
|
|
572
|
+
}
|
|
573
|
+
const upstream = tcp.connect(childPort, '127.0.0.1', () => {
|
|
574
|
+
clearTimeout(connectTimer);
|
|
575
|
+
upstream.write(`${lines.join('\r\n')}\r\n\r\n`);
|
|
576
|
+
if (head?.length) upstream.write(head);
|
|
577
|
+
upstream.pipe(socket);
|
|
578
|
+
socket.pipe(upstream);
|
|
292
579
|
});
|
|
293
|
-
|
|
294
|
-
|
|
580
|
+
const connectTimer = setTimeout(() => upstream.destroy(), CONNECT_TIMEOUT_MS);
|
|
581
|
+
upstream.on('error', () => socket.destroy());
|
|
582
|
+
socket.on('close', () => upstream.destroy());
|
|
583
|
+
upstream.on('close', () => socket.destroy());
|
|
295
584
|
};
|
|
296
585
|
}
|
|
297
586
|
|
|
298
|
-
function listenWithFallback(handler, port, onReady, label) {
|
|
299
|
-
const
|
|
587
|
+
function listenWithFallback(handler, port, onReady, label, onUpgrade) {
|
|
588
|
+
const opts = { maxHeaderSize: HDR_BYTES, onUpgrade };
|
|
589
|
+
const server = net.listenLoopback(handler, port, onReady, opts);
|
|
300
590
|
server.on('error', (err) => {
|
|
301
591
|
if (err.code !== 'EADDRINUSE' && err.code !== 'EACCES') throw err;
|
|
302
592
|
console.log(`${label}port ${port} in use, trying random port...`);
|
|
303
|
-
net.listenLoopback(handler, 0, onReady,
|
|
593
|
+
net.listenLoopback(handler, 0, onReady, opts);
|
|
304
594
|
});
|
|
305
595
|
return server;
|
|
306
596
|
}
|
|
@@ -313,28 +603,40 @@ for (const [name, port] of Object.entries(DEFAULT_PORTS)) {
|
|
|
313
603
|
publicPorts[name] = actual;
|
|
314
604
|
},
|
|
315
605
|
`[${name}] `,
|
|
606
|
+
proxyUpgrade(name),
|
|
316
607
|
);
|
|
317
608
|
}
|
|
318
609
|
|
|
319
610
|
ensurePool(hubConfig.activeConfigDir);
|
|
320
611
|
|
|
321
612
|
const app = express();
|
|
322
|
-
app.use(express.json());
|
|
323
613
|
|
|
324
614
|
// Mounted before the routes below, which are registered ahead of the first
|
|
325
615
|
// app.use() and would otherwise bypass the guards entirely.
|
|
326
616
|
app.use(net.hostGuard);
|
|
327
617
|
app.use(net.frameGuard);
|
|
328
618
|
app.use(net.originGuard);
|
|
619
|
+
app.use(tokenGuard);
|
|
620
|
+
app.use(express.json());
|
|
329
621
|
|
|
330
|
-
// The
|
|
331
|
-
// sub-app's themes.css. Served rather than hand-copied into public/app.js so there
|
|
332
|
-
// truth
|
|
622
|
+
// The hub's CSS variables per color theme and mode, read from the same registry generate-themes.mjs
|
|
623
|
+
// compiles into each sub-app's themes.css. Served rather than hand-copied into public/app.js so there
|
|
624
|
+
// is one source of truth. --bg is the sub-apps' --bg-deep, so the loading screen hands off to an
|
|
625
|
+
// iframe without a flash of another color.
|
|
333
626
|
// Read once — themes.json only changes when the generator is re-run, which restarts the hub anyway.
|
|
334
|
-
const
|
|
627
|
+
const hubVars = (p) => ({
|
|
628
|
+
'--accent': p.ember,
|
|
629
|
+
'--bg': p.field,
|
|
630
|
+
'--surface': p.surface,
|
|
631
|
+
'--surface-hover': p.hover,
|
|
632
|
+
'--border': p.border,
|
|
633
|
+
'--text': p.ink1,
|
|
634
|
+
'--text-dim': p.inkMuted,
|
|
635
|
+
});
|
|
636
|
+
const themePalettes = (() => {
|
|
335
637
|
try {
|
|
336
638
|
const themes = JSON.parse(fs.readFileSync(path.join(__dirname, 'scripts/themes.json'), 'utf8'));
|
|
337
|
-
return Object.fromEntries(themes.map((t) => [t.id, { dark: t.dark
|
|
639
|
+
return Object.fromEntries(themes.map((t) => [t.id, { dark: hubVars(t.dark), light: hubVars(t.light) }]));
|
|
338
640
|
} catch {
|
|
339
641
|
// Palette falls back to the --accent in index.html; not worth failing startup over.
|
|
340
642
|
return {};
|
|
@@ -342,8 +644,10 @@ const themeAccents = (() => {
|
|
|
342
644
|
})();
|
|
343
645
|
|
|
344
646
|
function appsConfig() {
|
|
647
|
+
const kanban = { name: 'Kanban', url: `http://localhost:${publicPorts.kanban}`, icon: 'columns' };
|
|
648
|
+
if (TERMINAL_TOKEN) kanban.terminalToken = TERMINAL_TOKEN;
|
|
345
649
|
return {
|
|
346
|
-
kanban
|
|
650
|
+
kanban,
|
|
347
651
|
marketplace: { name: 'Marketplace', url: `http://localhost:${publicPorts.marketplace}`, icon: 'store' },
|
|
348
652
|
cost: { name: 'Cost', url: `http://localhost:${publicPorts.cost}`, icon: 'dollar-sign' },
|
|
349
653
|
memory: { name: 'Memory Diagnoser', url: `http://localhost:${publicPorts.memory}`, icon: 'database' },
|
|
@@ -351,7 +655,7 @@ function appsConfig() {
|
|
|
351
655
|
}
|
|
352
656
|
|
|
353
657
|
app.get('/api/config', (_req, res) => {
|
|
354
|
-
res.json({
|
|
658
|
+
res.json({ themePalettes, apps: appsConfig(), activeConfigDir: hubConfig.activeConfigDir, defaultConfigDir });
|
|
355
659
|
});
|
|
356
660
|
|
|
357
661
|
app.get('/api/config-dirs', (_req, res) => {
|
|
@@ -421,7 +725,8 @@ app.post('/api/config-dirs/activate', async (req, res) => {
|
|
|
421
725
|
// once its banner has been scraped (see spawnApp).
|
|
422
726
|
app.get('/api/projects', async (_req, res) => {
|
|
423
727
|
res.setHeader('Cache-Control', 'no-store');
|
|
424
|
-
|
|
728
|
+
// Waits like the proxy does, so the palette is not the one thing that hard-fails during a restart.
|
|
729
|
+
const kanbanPort = await waitForPort('kanban', Date.now() + API_PORT_WAIT_MS);
|
|
425
730
|
if (!kanbanPort) {
|
|
426
731
|
res.status(503).json({ error: 'kanban is starting' });
|
|
427
732
|
return;
|
|
@@ -455,14 +760,18 @@ app.use(express.static(path.join(__dirname, 'public')));
|
|
|
455
760
|
const onReady = (actual) => {
|
|
456
761
|
printBanner(actual);
|
|
457
762
|
if (process.argv.includes('--open')) {
|
|
458
|
-
import('open').then((m) => m.default(
|
|
763
|
+
import('open').then((m) => m.default(accessUrl(actual)));
|
|
459
764
|
}
|
|
460
765
|
};
|
|
461
766
|
|
|
462
767
|
listenWithFallback(app, HUB_PORT, onReady, '');
|
|
463
768
|
|
|
769
|
+
function accessUrl(port) {
|
|
770
|
+
return `http://localhost:${port}/?token=${HUB_TOKEN}`;
|
|
771
|
+
}
|
|
772
|
+
|
|
464
773
|
function printBanner(port) {
|
|
465
|
-
console.log(`Claude Code Hub running at
|
|
774
|
+
console.log(`Claude Code Hub running at ${accessUrl(port)}`);
|
|
466
775
|
const warning = net.exposureWarning();
|
|
467
776
|
if (warning) console.log(warning);
|
|
468
777
|
console.log('Type "q" or "exit" to stop the server');
|