claude-code-hub 0.42.0 → 1.0.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 +6 -0
- package/lib/actions.js +39 -0
- package/lib/apps.js +136 -9
- package/lib/contain.js +78 -0
- package/lib/install.js +89 -0
- package/lib/kill-child.js +32 -0
- package/lib/net-guard.js +6 -1
- package/lib/ping.js +23 -0
- package/lib/themes.js +134 -0
- package/package.json +12 -9
- package/packages/claude-hub-sdk/src/client.js +371 -0
- package/packages/claude-hub-sdk/src/server.js +30 -0
- package/packages/claude-hub-sdk/src/stub.js +49 -0
- package/plugin/.claude-plugin/marketplace.json +14 -0
- package/plugin/plugins/claude-code-hub/.claude-plugin/plugin.json +8 -0
- package/plugin/plugins/claude-code-hub/skills/hub-builder/SKILL.md +30 -0
- package/plugin/plugins/claude-code-hub/skills/hub-builder/references/extensibility.md +93 -0
- package/plugin/plugins/claude-code-hub/skills/hub-builder/references/themes.md +57 -0
- package/public/app.js +382 -132
- package/public/index.html +23 -2
- package/public/locked.html +1 -1
- package/server.js +304 -115
package/server.js
CHANGED
|
@@ -8,9 +8,13 @@ const http = require('http');
|
|
|
8
8
|
const tcp = require('net');
|
|
9
9
|
const os = require('os');
|
|
10
10
|
const path = require('path');
|
|
11
|
-
const {
|
|
11
|
+
const { actionTable } = require('./lib/actions');
|
|
12
|
+
const { selectApps, loadApp, conflicts } = require('./lib/apps');
|
|
12
13
|
const { stripCookie } = require('./lib/cookies');
|
|
13
|
-
const { createNetGuard } = require('./lib/net-guard');
|
|
14
|
+
const { createNetGuard, KEEP_ALIVE_MS } = require('./lib/net-guard');
|
|
15
|
+
const { ping } = require('./lib/ping');
|
|
16
|
+
const { killChild } = require('./lib/kill-child');
|
|
17
|
+
const { readRegistry, themeConfig } = require('./lib/themes');
|
|
14
18
|
|
|
15
19
|
function getArg(name) {
|
|
16
20
|
const idx = process.argv.findIndex((a) => a.startsWith(`--${name}`));
|
|
@@ -42,9 +46,25 @@ function activePool() {
|
|
|
42
46
|
// Every sub-app resolves CLAUDE_CONFIG_DIR once at startup into a module constant, so switching
|
|
43
47
|
// the dir means restarting the children. Kept on the server, not in the browser: the dir has to
|
|
44
48
|
// be known before the first spawn, and the hub already has one localStorage key to reason about.
|
|
45
|
-
const HUB_DIR = path.
|
|
49
|
+
const HUB_DIR = path.resolve(expandHome(getArg('hub-dir') || process.env.CLAUDE_HUB_DIR || '~/.claude-hub'));
|
|
46
50
|
const HUB_CONFIG_FILE = path.join(HUB_DIR, 'config.json');
|
|
47
51
|
|
|
52
|
+
if (process.argv.includes('--install') || process.argv.includes('--uninstall')) {
|
|
53
|
+
const { runInstall, runUninstall } = require('./lib/install');
|
|
54
|
+
const dir = getArg('dir') || process.env.CLAUDE_CONFIG_DIR || '~/.claude';
|
|
55
|
+
let configDir;
|
|
56
|
+
try {
|
|
57
|
+
configDir = canonicalDir(dir);
|
|
58
|
+
} catch {
|
|
59
|
+
console.error(`Config dir not found: ${dir}`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
const ok = process.argv.includes('--install')
|
|
63
|
+
? runInstall({ hubDir: HUB_DIR, configDir })
|
|
64
|
+
: runUninstall({ configDir });
|
|
65
|
+
process.exit(ok ? 0 : 1);
|
|
66
|
+
}
|
|
67
|
+
|
|
48
68
|
// The dir the hub would use with no saved config. Set by loadHubConfig; the client uses it to keep
|
|
49
69
|
// the default dir out of the window title.
|
|
50
70
|
let defaultConfigDir = null;
|
|
@@ -106,13 +126,25 @@ function saveHubConfig() {
|
|
|
106
126
|
|
|
107
127
|
const hubConfig = loadHubConfig();
|
|
108
128
|
|
|
109
|
-
const { apps:
|
|
110
|
-
|
|
111
|
-
|
|
129
|
+
const { apps: selectedApps, unknown: unknownApps } = selectApps(hubConfig.apps, path.dirname(HUB_CONFIG_FILE));
|
|
130
|
+
const ENABLED_APPS = selectedApps.map((a) => loadApp(a, { flagPort: getArg(`${a.id}-port`) })).filter(Boolean);
|
|
131
|
+
for (const id of unknownApps) console.log(`Unknown app "${id}" in ${HUB_CONFIG_FILE} has no "path", ignored`);
|
|
132
|
+
for (const a of selectedApps) if (a.path) console.log(`${a.id} runs from ${a.path}`);
|
|
133
|
+
for (const line of conflicts(ENABLED_APPS)) console.log(line);
|
|
134
|
+
if (!selectedApps.length) {
|
|
112
135
|
console.error(`Every app is disabled in ${HUB_CONFIG_FILE}. Enable at least one.`);
|
|
113
136
|
process.exit(1);
|
|
114
137
|
}
|
|
115
|
-
|
|
138
|
+
if (!ENABLED_APPS.length) {
|
|
139
|
+
const ids = selectedApps.map((a) => a.id).join(', ');
|
|
140
|
+
console.error(`No enabled app could load (${ids}). The reasons are logged above.`);
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
const ACTIONS = actionTable(ENABLED_APPS);
|
|
144
|
+
// The first enabled app in tab order that declares the capability in its manifest.
|
|
145
|
+
const provider = (cap) => ENABLED_APPS.find((a) => a.provides[cap]);
|
|
146
|
+
const PROJECTS_APP = provider('projects');
|
|
147
|
+
const TERMINAL_APP = provider('terminal');
|
|
116
148
|
|
|
117
149
|
// Every keystroke into cck's terminal passes through the upgrade proxy below. Windows only, for the
|
|
118
150
|
// reasons in cck/lib/priority.js; the children spawned after this still start at normal.
|
|
@@ -136,8 +168,7 @@ const net = createNetGuard({ appName: 'Claude Code Hub' });
|
|
|
136
168
|
// the WebSocket. cck applies the rest of its policy (exposure refusal, Origin, session cap) itself.
|
|
137
169
|
const TERMINAL = {
|
|
138
170
|
...(hubConfig.terminal || {}),
|
|
139
|
-
enabled:
|
|
140
|
-
appEnabled('kanban') && !process.argv.includes('--disable-terminal') && hubConfig.terminal?.enabled !== false,
|
|
171
|
+
enabled: !!TERMINAL_APP && !process.argv.includes('--disable-terminal') && hubConfig.terminal?.enabled !== false,
|
|
141
172
|
};
|
|
142
173
|
const TERMINAL_TOKEN = TERMINAL.enabled ? crypto.randomBytes(32).toString('hex') : null;
|
|
143
174
|
|
|
@@ -149,6 +180,7 @@ const TOKEN_COOKIE = 'hub_token';
|
|
|
149
180
|
const TOKEN_COOKIE_RE = /(?:^|;\s*)hub_token=([^;]*)/;
|
|
150
181
|
const TOKEN_COOKIE_MAX_AGE_MS = 400 * 24 * 60 * 60 * 1000;
|
|
151
182
|
const LOCKED_PAGE = path.join(__dirname, 'public', 'locked.html');
|
|
183
|
+
const HUB_SDK_SERVER = path.join(__dirname, 'packages', 'claude-hub-sdk', 'src', 'server.js');
|
|
152
184
|
|
|
153
185
|
function loadHubToken() {
|
|
154
186
|
try {
|
|
@@ -196,8 +228,9 @@ function childEnv(pool, name) {
|
|
|
196
228
|
HUB_URL: `http://localhost:${hubPort}`,
|
|
197
229
|
HOST: net.BIND_HOST,
|
|
198
230
|
ALLOWED_HOSTS: net.ALLOWED_HOSTS,
|
|
231
|
+
HUB_SDK_SERVER,
|
|
199
232
|
};
|
|
200
|
-
if (name ===
|
|
233
|
+
if (TERMINAL.enabled && name === TERMINAL_APP.id) {
|
|
201
234
|
env.CCK_TERMINAL = JSON.stringify(TERMINAL);
|
|
202
235
|
env.CCK_TERMINAL_TOKEN = TERMINAL_TOKEN;
|
|
203
236
|
}
|
|
@@ -208,7 +241,7 @@ function spawnApp(pool, name, cmd, args) {
|
|
|
208
241
|
const child = spawn(cmd, args, {
|
|
209
242
|
cwd: __dirname,
|
|
210
243
|
env: childEnv(pool, name),
|
|
211
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
244
|
+
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
|
212
245
|
});
|
|
213
246
|
|
|
214
247
|
let stdoutBuf = '';
|
|
@@ -233,8 +266,9 @@ function spawnApp(pool, name, cmd, args) {
|
|
|
233
266
|
child.stderr.on('data', (d) => process.stderr.write(`[${name}] ${d}`));
|
|
234
267
|
child.on('exit', (code) => {
|
|
235
268
|
console.log(`[${name}] exited (code ${code})`);
|
|
236
|
-
// The port dies with the child and the OS may hand it to a stranger, so stop dialing it.
|
|
237
|
-
|
|
269
|
+
// The port dies with the child and the OS may hand it to a stranger, so stop dialing it. A child
|
|
270
|
+
// that restartApp replaced can exit after its successor printed a port.
|
|
271
|
+
if (pool.children.get(name) === child) delete pool.ports[name];
|
|
238
272
|
markReady();
|
|
239
273
|
if (pool.retired || pool.children.get(name) !== child) return;
|
|
240
274
|
// Nothing else would ever bring this app back: a pool is only rebuilt when its dir is
|
|
@@ -242,6 +276,7 @@ function spawnApp(pool, name, cmd, args) {
|
|
|
242
276
|
const recent = (pool.restarts[name] || []).filter((t) => Date.now() - t < RESTART_WINDOW_MS);
|
|
243
277
|
if (recent.length >= MAX_RESTARTS) {
|
|
244
278
|
console.log(`[${name}] gave up after ${MAX_RESTARTS} restarts in ${RESTART_WINDOW_MS / 1000}s`);
|
|
279
|
+
child.gaveUp = true;
|
|
245
280
|
return;
|
|
246
281
|
}
|
|
247
282
|
recent.push(Date.now());
|
|
@@ -257,22 +292,27 @@ function spawnApp(pool, name, cmd, args) {
|
|
|
257
292
|
return child;
|
|
258
293
|
}
|
|
259
294
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
295
|
+
// Asked for by the user, so it does not count toward MAX_RESTARTS and it brings back an app that gave up.
|
|
296
|
+
async function restartApp(pool, app) {
|
|
297
|
+
const { id } = app;
|
|
298
|
+
const old = pool.children.get(id);
|
|
299
|
+
pool.children.delete(id);
|
|
300
|
+
delete pool.ports[id];
|
|
301
|
+
pool.restarts[id] = [];
|
|
302
|
+
if (old) await killChild(old);
|
|
303
|
+
if (pool.retired) return false;
|
|
304
|
+
const child = spawnOne(pool, app);
|
|
305
|
+
await withTimeout(child.ready, 20000);
|
|
306
|
+
return !!pool.ports[id];
|
|
267
307
|
}
|
|
268
308
|
|
|
269
309
|
function killPool(pool) {
|
|
270
310
|
pool.retired = true;
|
|
271
|
-
|
|
311
|
+
return Promise.all([...pool.children.values()].map((child) => killChild(child)));
|
|
272
312
|
}
|
|
273
313
|
|
|
274
314
|
function killAll() {
|
|
275
|
-
|
|
315
|
+
return Promise.all([...pools.values()].map(killPool));
|
|
276
316
|
}
|
|
277
317
|
|
|
278
318
|
function withTimeout(promise, ms) {
|
|
@@ -305,10 +345,12 @@ async function ensurePool(dir) {
|
|
|
305
345
|
}
|
|
306
346
|
|
|
307
347
|
async function terminalCount(pool) {
|
|
308
|
-
const port = pool.ports.
|
|
348
|
+
const port = pool.ports[TERMINAL_APP.id];
|
|
309
349
|
if (!port) return 0;
|
|
310
350
|
try {
|
|
311
|
-
const r = await fetch(`http://127.0.0.1:${port}
|
|
351
|
+
const r = await fetch(`http://127.0.0.1:${port}${TERMINAL_APP.provides.terminal.liveWork}`, {
|
|
352
|
+
signal: AbortSignal.timeout(1500),
|
|
353
|
+
});
|
|
312
354
|
return r.ok ? (await r.json()).sessions?.length || 0 : 0;
|
|
313
355
|
} catch {
|
|
314
356
|
return 0;
|
|
@@ -342,9 +384,12 @@ function dropPool(dir) {
|
|
|
342
384
|
killPool(pool);
|
|
343
385
|
}
|
|
344
386
|
|
|
387
|
+
// Waits for the children, so a POSIX child that ignores SIGTERM still gets SIGKILL before the hub exits.
|
|
388
|
+
let shuttingDown = false;
|
|
345
389
|
function shutdown() {
|
|
346
|
-
|
|
347
|
-
|
|
390
|
+
if (shuttingDown) return;
|
|
391
|
+
shuttingDown = true;
|
|
392
|
+
killAll().then(() => process.exit(0));
|
|
348
393
|
}
|
|
349
394
|
|
|
350
395
|
process.on('SIGINT', shutdown);
|
|
@@ -366,17 +411,6 @@ process.stdin.on('data', (data) => {
|
|
|
366
411
|
process.stdin.on('end', shutdown);
|
|
367
412
|
process.stdin.on('close', shutdown);
|
|
368
413
|
|
|
369
|
-
function resolveApp(submoduleDir, npmPackage) {
|
|
370
|
-
const local = path.join(__dirname, submoduleDir, 'server.js');
|
|
371
|
-
try {
|
|
372
|
-
require.resolve(local);
|
|
373
|
-
return local;
|
|
374
|
-
} catch {}
|
|
375
|
-
return require.resolve(`${npmPackage}/server.js`);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
const appPaths = Object.fromEntries(ENABLED_APPS.map((a) => [a.id, resolveApp(a.dir, a.pkg)]));
|
|
379
|
-
|
|
380
414
|
// Raise header size limit to 64KB — localhost cookies from sibling apps can pile up and
|
|
381
415
|
// trip Node's default 16KB limit, breaking iframes with HTTP 431.
|
|
382
416
|
const HDR_BYTES = 65536;
|
|
@@ -385,13 +419,15 @@ const NODE_HDR = `--max-http-header-size=${HDR_BYTES}`;
|
|
|
385
419
|
// Children bind ephemeral ports; the public ports below belong to the hub's proxies. Browser
|
|
386
420
|
// localStorage is keyed by origin, so the sub-app origin has to stay put across switches or the
|
|
387
421
|
// user loses pins and filters every time the active set changes.
|
|
422
|
+
function spawnOne(pool, app) {
|
|
423
|
+
return spawnApp(pool, app.id, process.execPath, [NODE_HDR, app.entry]);
|
|
424
|
+
}
|
|
425
|
+
|
|
388
426
|
function spawnChildren(pool) {
|
|
389
|
-
for (const a of ENABLED_APPS)
|
|
427
|
+
for (const a of ENABLED_APPS) spawnOne(pool, a);
|
|
390
428
|
}
|
|
391
429
|
|
|
392
|
-
const publicPorts = Object.fromEntries(
|
|
393
|
-
ENABLED_APPS.map((a) => [a.id, parseInt(getArg(`${a.id}-port`) || String(a.port), 10)]),
|
|
394
|
-
);
|
|
430
|
+
const publicPorts = Object.fromEntries(ENABLED_APPS.map((a) => [a.id, a.port]));
|
|
395
431
|
|
|
396
432
|
function rewriteOrigin(origin, publicPort, childPort) {
|
|
397
433
|
try {
|
|
@@ -404,7 +440,9 @@ function rewriteOrigin(origin, publicPort, childPort) {
|
|
|
404
440
|
}
|
|
405
441
|
}
|
|
406
442
|
|
|
407
|
-
|
|
443
|
+
// The socket timeout drops idle sockets before the app's server closes them (KEEP_ALIVE_MS), so a
|
|
444
|
+
// request never lands on a socket being closed. It does not cut a slow response.
|
|
445
|
+
const proxyAgent = new http.Agent({ keepAlive: true, maxSockets: 64, timeout: KEEP_ALIVE_MS - 5000 });
|
|
408
446
|
|
|
409
447
|
// Two failures look the same from here and both recover on their own: a pooled socket the child
|
|
410
448
|
// closed under us, and a child that is restarting. The port is re-read per attempt, so a replay
|
|
@@ -436,24 +474,33 @@ async function waitForPort(name, deadline) {
|
|
|
436
474
|
const pool = activePool();
|
|
437
475
|
const port = pool?.ports[name];
|
|
438
476
|
if (port) return port;
|
|
439
|
-
if (pool
|
|
477
|
+
if (pool?.children.get(name)?.gaveUp) return null;
|
|
440
478
|
const remaining = deadline - Date.now();
|
|
441
479
|
if (remaining <= 0) return null;
|
|
442
480
|
await Promise.race([pool?.children.get(name)?.ready, delay(Math.min(remaining, 250))]);
|
|
443
481
|
}
|
|
444
482
|
}
|
|
445
483
|
|
|
484
|
+
function noPortReason(name) {
|
|
485
|
+
const child = activePool()?.children.get(name);
|
|
486
|
+
if (child?.gaveUp) {
|
|
487
|
+
const detail = `${name} exited after ${MAX_RESTARTS} restarts in ${RESTART_WINDOW_MS / 1000}s. The hub stopped restarting it`;
|
|
488
|
+
return { headline: 'stopped', detail };
|
|
489
|
+
}
|
|
490
|
+
return { headline: 'is starting', detail: `${name} is starting` };
|
|
491
|
+
}
|
|
492
|
+
|
|
446
493
|
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
447
494
|
|
|
448
495
|
// A document load that fails gets a page that reloads itself — the reload the user would do by
|
|
449
496
|
// hand. The cap is small because the server already waited NAV_PORT_WAIT_MS before serving this,
|
|
450
497
|
// and each reload re-spends that wait. sessionStorage throws when site data is blocked, and a page
|
|
451
498
|
// whose only job is to reload must still reload there.
|
|
452
|
-
function retryPage(name, detail) {
|
|
499
|
+
function retryPage(name, headline, detail) {
|
|
453
500
|
return `<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(name)}</title>
|
|
454
501
|
<style>:root{color-scheme:light dark}body{font:14px system-ui,sans-serif;margin:0;display:grid;place-items:center;height:100vh}
|
|
455
502
|
div{text-align:center;opacity:.7}code{font-size:12px;opacity:.6}</style>
|
|
456
|
-
</head><body><div><p id="m">${escapeHtml(name)}
|
|
503
|
+
</head><body><div><p id="m">${escapeHtml(name)} ${escapeHtml(headline)}…</p><p><code>${escapeHtml(detail)}</code></p></div>
|
|
457
504
|
<script>
|
|
458
505
|
let n = 0;
|
|
459
506
|
const k = 'hub-retry:' + location.pathname;
|
|
@@ -463,11 +510,11 @@ else { try { sessionStorage.removeItem(k); } catch {} document.getElementById('m
|
|
|
463
510
|
</script></body></html>`;
|
|
464
511
|
}
|
|
465
512
|
|
|
466
|
-
function sendUnavailable(req, res, name, status, detail) {
|
|
513
|
+
function sendUnavailable(req, res, name, status, detail, headline) {
|
|
467
514
|
if (res.destroyed || res.headersSent) return;
|
|
468
515
|
if (isNavigation(req)) {
|
|
469
516
|
res.writeHead(status, { 'Retry-After': '1', 'Content-Type': 'text/html; charset=utf-8' });
|
|
470
|
-
res.end(retryPage(name, detail));
|
|
517
|
+
res.end(retryPage(name, headline, detail));
|
|
471
518
|
return;
|
|
472
519
|
}
|
|
473
520
|
// The sub-app's own callers hand the body to .json(): plain text surfaces as a SyntaxError
|
|
@@ -476,26 +523,98 @@ function sendUnavailable(req, res, name, status, detail) {
|
|
|
476
523
|
res.end(JSON.stringify({ error: detail }));
|
|
477
524
|
}
|
|
478
525
|
|
|
526
|
+
// The child on childPort, or null when it has been replaced since the connect was tried.
|
|
527
|
+
function currentChild(name, childPort) {
|
|
528
|
+
const pool = activePool();
|
|
529
|
+
return pool?.ports[name] === childPort ? pool.children.get(name) : null;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// A busy event loop (cck's cache warmup) must not read as a dead app.
|
|
533
|
+
const PING_TIMEOUT_MS = 3000;
|
|
534
|
+
// A drop burst fails many requests at once; they share one ping per child.
|
|
535
|
+
const pings = new WeakMap();
|
|
536
|
+
function pingChild(name, childPort, err) {
|
|
537
|
+
const child = currentChild(name, childPort);
|
|
538
|
+
if (!child) return Promise.resolve(false);
|
|
539
|
+
if (!pings.has(child)) {
|
|
540
|
+
const alive = ping(child, PING_TIMEOUT_MS).then((ok) => {
|
|
541
|
+
pings.delete(child);
|
|
542
|
+
if (ok) console.warn(`[${name}] answers over IPC at 127.0.0.1:${childPort} (${err.code}) - keeping it`);
|
|
543
|
+
return ok;
|
|
544
|
+
});
|
|
545
|
+
pings.set(child, alive);
|
|
546
|
+
}
|
|
547
|
+
return pings.get(child);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// A drop burst fails dozens of connects that the next attempt fixes, so retries are counted and
|
|
551
|
+
// logged as one line per app every RETRY_LOG_MS. The IPC check, a replace and a failure log at once.
|
|
552
|
+
const RETRY_LOG_MS = 5 * 60_000;
|
|
553
|
+
const retryCounts = new Map();
|
|
554
|
+
function countRetry(name, code) {
|
|
555
|
+
const key = `${name} ${code}`;
|
|
556
|
+
const entry = retryCounts.get(key) || { name, code, n: 0 };
|
|
557
|
+
entry.n++;
|
|
558
|
+
retryCounts.set(key, entry);
|
|
559
|
+
}
|
|
560
|
+
setInterval(() => {
|
|
561
|
+
for (const { name, code, n } of retryCounts.values()) {
|
|
562
|
+
console.log(`[${name}] ${n} connects failed (${code}) and were retried in the last ${RETRY_LOG_MS / 60_000} min`);
|
|
563
|
+
}
|
|
564
|
+
retryCounts.clear();
|
|
565
|
+
}, RETRY_LOG_MS).unref();
|
|
566
|
+
|
|
567
|
+
const ALIVE_WAIT_MS = 30000;
|
|
568
|
+
const MAX_BACKOFF_MS = 2000;
|
|
569
|
+
|
|
570
|
+
// Returns {wait} before the next connect attempt, or {headline, detail} to give up with. A pong
|
|
571
|
+
// moves budget.deadline to ALIVE_WAIT_MS from now: the child runs, so the request waits out the drop.
|
|
572
|
+
async function afterConnectFailure(name, childPort, err, attempt, budget) {
|
|
573
|
+
const next = await nextAttempt(name, childPort, err, attempt, budget);
|
|
574
|
+
if (next.wait) countRetry(name, err.code);
|
|
575
|
+
return next;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function nextAttempt(name, childPort, err, attempt, budget) {
|
|
579
|
+
if (attempt < MAX_ATTEMPTS) return { wait: ATTEMPT_DELAY_MS };
|
|
580
|
+
if (attempt === MAX_ATTEMPTS) {
|
|
581
|
+
if (!(await pingChild(name, childPort, err))) {
|
|
582
|
+
replaceChild(name, childPort, err);
|
|
583
|
+
return { headline: 'is restarting', detail: `${name} did not answer over TCP (${err.code}) or IPC` };
|
|
584
|
+
}
|
|
585
|
+
budget.deadline = Math.max(budget.deadline, Date.now() + ALIVE_WAIT_MS);
|
|
586
|
+
}
|
|
587
|
+
const wait = Math.min(ATTEMPT_DELAY_MS * 2 ** (attempt - MAX_ATTEMPTS + 1), MAX_BACKOFF_MS);
|
|
588
|
+
if (Date.now() + wait < budget.deadline) return { wait };
|
|
589
|
+
// Its listener is gone or stuck while the process lives on. A new process gets a new port.
|
|
590
|
+
if (currentChild(name, childPort)) {
|
|
591
|
+
console.warn(`[${name}] answers over IPC, but connects to 127.0.0.1:${childPort} kept failing (${err.code})`);
|
|
592
|
+
replaceChild(name, childPort, err);
|
|
593
|
+
}
|
|
594
|
+
return {
|
|
595
|
+
headline: 'is restarting',
|
|
596
|
+
detail: `${name} answers over IPC, but connects to it kept failing (${err.code}) for ${ALIVE_WAIT_MS / 1000}s`,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
479
600
|
// A pooled socket the child closed under us is normal and recovers on the next attempt. A port that
|
|
480
601
|
// never completes a connect does not, so the child is replaced and the respawn gives it a new one.
|
|
481
602
|
function replaceChild(name, childPort, err) {
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
if (!child || pool.ports[name] !== childPort) return;
|
|
603
|
+
const child = currentChild(name, childPort);
|
|
604
|
+
if (!child) return;
|
|
485
605
|
console.log(`[${name}] unreachable at 127.0.0.1:${childPort} (${err.code}) - replacing it`);
|
|
486
|
-
delete
|
|
606
|
+
delete activePool().ports[name];
|
|
487
607
|
killChild(child);
|
|
488
608
|
}
|
|
489
609
|
|
|
610
|
+
const connectTimeoutError = (name, childPort) =>
|
|
611
|
+
Object.assign(new Error(`no connect to ${name} at 127.0.0.1:${childPort}`), { code: 'ETIMEDOUT' });
|
|
612
|
+
|
|
490
613
|
// Fires only while the socket is still connecting, so a slow handler is never interrupted.
|
|
491
614
|
function capConnect(upstream, name, childPort) {
|
|
492
615
|
upstream.on('socket', (socket) => {
|
|
493
616
|
if (!socket.connecting) return;
|
|
494
|
-
const timer = setTimeout(() =>
|
|
495
|
-
const err = new Error(`no connect to ${name} at 127.0.0.1:${childPort}`);
|
|
496
|
-
err.code = 'ETIMEDOUT';
|
|
497
|
-
upstream.destroy(err);
|
|
498
|
-
}, CONNECT_TIMEOUT_MS);
|
|
617
|
+
const timer = setTimeout(() => upstream.destroy(connectTimeoutError(name, childPort)), CONNECT_TIMEOUT_MS);
|
|
499
618
|
const clear = () => clearTimeout(timer);
|
|
500
619
|
socket.once('connect', clear);
|
|
501
620
|
upstream.once('close', clear);
|
|
@@ -504,7 +623,29 @@ function capConnect(upstream, name, childPort) {
|
|
|
504
623
|
|
|
505
624
|
// Resolves null once the response is committed (or the client is gone), or the error when nothing
|
|
506
625
|
// has been written yet and the caller may still retry.
|
|
507
|
-
|
|
626
|
+
const MAX_REPLAY_BODY = 1024 * 1024;
|
|
627
|
+
|
|
628
|
+
// Resolves the whole body when it is small enough to keep for a retry, else null (the body then
|
|
629
|
+
// streams once). A chunked body has no length up front, so it streams too.
|
|
630
|
+
function readReplayBody(req) {
|
|
631
|
+
const te = req.headers['transfer-encoding'];
|
|
632
|
+
const cl = req.headers['content-length'];
|
|
633
|
+
if (te) return Promise.resolve(null);
|
|
634
|
+
if (cl === undefined) return Promise.resolve(Buffer.alloc(0));
|
|
635
|
+
const len = Number(cl);
|
|
636
|
+
if (!Number.isSafeInteger(len) || len > MAX_REPLAY_BODY) return Promise.resolve(null);
|
|
637
|
+
return new Promise((resolve) => {
|
|
638
|
+
const chunks = [];
|
|
639
|
+
req.on('data', (c) => chunks.push(c));
|
|
640
|
+
req.once('end', () => resolve(Buffer.concat(chunks)));
|
|
641
|
+
req.once('error', () => resolve(undefined));
|
|
642
|
+
req.once('close', () => {
|
|
643
|
+
if (!req.complete) resolve(undefined);
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function forward(name, req, res, childPort, headers, body, onUpstream) {
|
|
508
649
|
return new Promise((resolve) => {
|
|
509
650
|
const upstream = http.request(
|
|
510
651
|
{ host: '127.0.0.1', port: childPort, method: req.method, path: req.url, headers, agent: proxyAgent },
|
|
@@ -524,11 +665,18 @@ function forward(name, req, res, childPort, headers, replayable, onUpstream) {
|
|
|
524
665
|
}
|
|
525
666
|
resolve(err);
|
|
526
667
|
});
|
|
527
|
-
if (
|
|
668
|
+
if (body) upstream.end(body.length ? body : undefined);
|
|
528
669
|
else req.pipe(upstream);
|
|
529
670
|
});
|
|
530
671
|
}
|
|
531
672
|
|
|
673
|
+
// RFC 9110 §7.6.1. The client's "Connection: close" would otherwise close the pooled socket to the
|
|
674
|
+
// app too. Transfer-Encoding stays: it frames the body that is piped on.
|
|
675
|
+
function stripHopByHop(headers) {
|
|
676
|
+
for (const h of (headers.connection || '').split(',')) delete headers[h.trim().toLowerCase()];
|
|
677
|
+
for (const h of ['connection', 'keep-alive', 'proxy-connection', 'te', 'trailer', 'upgrade']) delete headers[h];
|
|
678
|
+
}
|
|
679
|
+
|
|
532
680
|
// Host is forwarded untouched so the child's hostGuard still sees what the browser sent. Origin is
|
|
533
681
|
// the one header the child cannot judge on its own: its guard compares the port to its own
|
|
534
682
|
// ephemeral one, so the public-port spelling is mapped to the child's; anything else passes
|
|
@@ -540,38 +688,41 @@ function proxyHandler(name) {
|
|
|
540
688
|
const cookie = stripCookie(headers.cookie, TOKEN_COOKIE);
|
|
541
689
|
if (cookie) headers.cookie = cookie;
|
|
542
690
|
else delete headers.cookie;
|
|
543
|
-
|
|
544
|
-
//
|
|
545
|
-
|
|
691
|
+
stripHopByHop(headers);
|
|
692
|
+
// req is consumed by the first attempt, so a request is re-sent only from its kept body. Waiting
|
|
693
|
+
// for a port is not a replay — nothing has been sent yet — so every request gets the same wait.
|
|
694
|
+
const body = await readReplayBody(req);
|
|
695
|
+
if (body === undefined) return;
|
|
546
696
|
// One deadline for the whole request, not one per attempt: a retry must not re-spend the wait.
|
|
547
|
-
const
|
|
697
|
+
const budget = { deadline: Date.now() + (isNavigation(req) ? NAV_PORT_WAIT_MS : API_PORT_WAIT_MS) };
|
|
548
698
|
// One listener for the request, however many attempts it takes.
|
|
549
699
|
let current = null;
|
|
550
700
|
res.once('close', () => current?.destroy());
|
|
551
701
|
|
|
552
702
|
for (let attempt = 1; !res.destroyed; attempt++) {
|
|
553
|
-
const childPort = await waitForPort(name, deadline);
|
|
703
|
+
const childPort = await waitForPort(name, budget.deadline);
|
|
554
704
|
if (!childPort) {
|
|
555
|
-
|
|
705
|
+
const { headline, detail } = noPortReason(name);
|
|
706
|
+
sendUnavailable(req, res, name, 503, detail, headline);
|
|
556
707
|
return;
|
|
557
708
|
}
|
|
558
709
|
if (req.headers.origin) {
|
|
559
710
|
headers.origin = rewriteOrigin(req.headers.origin, publicPorts[name], childPort);
|
|
560
711
|
}
|
|
561
|
-
const err = await forward(name, req, res, childPort, headers,
|
|
712
|
+
const err = await forward(name, req, res, childPort, headers, body, (u) => {
|
|
562
713
|
current = u;
|
|
563
714
|
});
|
|
564
715
|
if (!err) return;
|
|
565
|
-
if (
|
|
566
|
-
|
|
567
|
-
if (attempt >= MAX_ATTEMPTS) replaceChild(name, childPort, err);
|
|
568
|
-
sendUnavailable(req, res, name, 502, err.message);
|
|
716
|
+
if (body === null || !RETRYABLE.has(err.code)) {
|
|
717
|
+
sendUnavailable(req, res, name, 502, err.message, 'did not respond');
|
|
569
718
|
return;
|
|
570
719
|
}
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
720
|
+
const next = await afterConnectFailure(name, childPort, err, attempt, budget);
|
|
721
|
+
if (!next.wait) {
|
|
722
|
+
sendUnavailable(req, res, name, 503, next.detail, next.headline);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
await delay(next.wait);
|
|
575
726
|
}
|
|
576
727
|
};
|
|
577
728
|
}
|
|
@@ -595,13 +746,13 @@ function proxyUpgrade(name) {
|
|
|
595
746
|
if (header === 'cookie' && !(value = stripCookie(value, TOKEN_COOKIE))) continue;
|
|
596
747
|
lines.push(`${req.rawHeaders[i]}: ${value}`);
|
|
597
748
|
}
|
|
598
|
-
|
|
749
|
+
const budget = { deadline: Date.now() + NAV_PORT_WAIT_MS };
|
|
750
|
+
tunnel(name, childPort, `${lines.join('\r\n')}\r\n\r\n`, head, socket, 1, budget);
|
|
599
751
|
};
|
|
600
752
|
}
|
|
601
753
|
|
|
602
|
-
// A
|
|
603
|
-
|
|
604
|
-
function tunnel(name, childPort, request, head, socket, retries) {
|
|
754
|
+
// A connect that never opened follows the same retry rule as proxyHandler.
|
|
755
|
+
function tunnel(name, childPort, request, head, socket, attempt, budget) {
|
|
605
756
|
let connected = false;
|
|
606
757
|
const upstream = tcp.connect(childPort, '127.0.0.1', () => {
|
|
607
758
|
connected = true;
|
|
@@ -611,20 +762,25 @@ function tunnel(name, childPort, request, head, socket, retries) {
|
|
|
611
762
|
upstream.pipe(socket);
|
|
612
763
|
socket.pipe(upstream);
|
|
613
764
|
});
|
|
614
|
-
const connectTimer = setTimeout(() => upstream.destroy(), CONNECT_TIMEOUT_MS);
|
|
765
|
+
const connectTimer = setTimeout(() => upstream.destroy(connectTimeoutError(name, childPort)), CONNECT_TIMEOUT_MS);
|
|
615
766
|
const onSocketClose = () => upstream.destroy();
|
|
616
767
|
socket.on('close', onSocketClose);
|
|
617
|
-
upstream.on('error', (err) => {
|
|
618
|
-
if (connected || socket.destroyed
|
|
619
|
-
if (!connected) console.warn(`[${name}] upgrade connect to 127.0.0.1:${childPort} failed (${err.code})`);
|
|
768
|
+
upstream.on('error', async (err) => {
|
|
769
|
+
if (connected || socket.destroyed) {
|
|
620
770
|
socket.destroy();
|
|
621
771
|
return;
|
|
622
772
|
}
|
|
623
|
-
console.warn(`[${name}] upgrade connect to 127.0.0.1:${childPort} failed (${err.code}), retrying`);
|
|
624
773
|
clearTimeout(connectTimer);
|
|
625
774
|
socket.off('close', onSocketClose);
|
|
626
775
|
upstream.removeAllListeners('close');
|
|
627
|
-
|
|
776
|
+
const next = await afterConnectFailure(name, childPort, err, attempt, budget);
|
|
777
|
+
if (!next.wait || socket.destroyed) {
|
|
778
|
+
socket.destroy();
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
await delay(next.wait);
|
|
782
|
+
if (socket.destroyed) return;
|
|
783
|
+
tunnel(name, childPort, request, head, socket, attempt + 1, budget);
|
|
628
784
|
});
|
|
629
785
|
upstream.on('close', () => socket.destroy());
|
|
630
786
|
}
|
|
@@ -664,40 +820,36 @@ app.use(express.json());
|
|
|
664
820
|
|
|
665
821
|
// The hub's CSS variables per color theme and mode, read from the same registry generate-themes.mjs
|
|
666
822
|
// compiles into each sub-app's themes.css. Served rather than hand-copied into public/app.js so there
|
|
667
|
-
// is one source of truth.
|
|
668
|
-
// iframe without a flash of another color.
|
|
823
|
+
// is one source of truth.
|
|
669
824
|
// Read once — themes.json only changes when the generator is re-run, which restarts the hub anyway.
|
|
670
|
-
const
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
'--surface': p.surface,
|
|
674
|
-
'--surface-hover': p.hover,
|
|
675
|
-
'--border': p.border,
|
|
676
|
-
'--text': p.ink1,
|
|
677
|
-
'--text-dim': p.inkMuted,
|
|
678
|
-
});
|
|
679
|
-
const themePalettes = (() => {
|
|
680
|
-
try {
|
|
681
|
-
const themes = JSON.parse(fs.readFileSync(path.join(__dirname, 'lib/themes.json'), 'utf8'));
|
|
682
|
-
return Object.fromEntries(themes.map((t) => [t.id, { dark: hubVars(t.dark), light: hubVars(t.light) }]));
|
|
683
|
-
} catch {
|
|
684
|
-
// Palette falls back to the --accent in index.html; not worth failing startup over.
|
|
685
|
-
return {};
|
|
686
|
-
}
|
|
687
|
-
})();
|
|
825
|
+
const THEME_REGISTRY = readRegistry();
|
|
826
|
+
// Read on each /api/config, so an edit applies on the next page load.
|
|
827
|
+
const USER_THEMES_FILE = path.join(HUB_DIR, 'themes.json');
|
|
688
828
|
|
|
689
829
|
function appsConfig() {
|
|
690
830
|
return Object.fromEntries(
|
|
691
831
|
ENABLED_APPS.map((a) => {
|
|
692
|
-
const entry = {
|
|
693
|
-
|
|
832
|
+
const entry = {
|
|
833
|
+
name: a.name,
|
|
834
|
+
url: `http://localhost:${publicPorts[a.id]}`,
|
|
835
|
+
icon: a.icon,
|
|
836
|
+
loading: a.loading,
|
|
837
|
+
publishes: a.publishes,
|
|
838
|
+
};
|
|
839
|
+
if (TERMINAL_TOKEN && a.id === TERMINAL_APP.id) entry.terminalToken = TERMINAL_TOKEN;
|
|
694
840
|
return [a.id, entry];
|
|
695
841
|
}),
|
|
696
842
|
);
|
|
697
843
|
}
|
|
698
844
|
|
|
699
845
|
app.get('/api/config', (_req, res) => {
|
|
700
|
-
res.json({
|
|
846
|
+
res.json({
|
|
847
|
+
...themeConfig(THEME_REGISTRY, { userFile: USER_THEMES_FILE }),
|
|
848
|
+
apps: appsConfig(),
|
|
849
|
+
actions: ACTIONS,
|
|
850
|
+
activeConfigDir: hubConfig.activeConfigDir,
|
|
851
|
+
defaultConfigDir,
|
|
852
|
+
});
|
|
701
853
|
});
|
|
702
854
|
|
|
703
855
|
app.get('/api/config-dirs', (_req, res) => {
|
|
@@ -762,30 +914,67 @@ app.post('/api/config-dirs/activate', async (req, res) => {
|
|
|
762
914
|
res.json({ apps: appsConfig() });
|
|
763
915
|
});
|
|
764
916
|
|
|
765
|
-
//
|
|
766
|
-
|
|
767
|
-
|
|
917
|
+
// Restarting the terminal provider ends its live terminals, so that takes force: true.
|
|
918
|
+
const restarting = new Set();
|
|
919
|
+
app.post('/api/apps/:id/restart', async (req, res) => {
|
|
920
|
+
const pool = activePool();
|
|
921
|
+
const { id } = req.params;
|
|
922
|
+
const target = ENABLED_APPS.find((a) => a.id === id);
|
|
923
|
+
if (!pool || !target) {
|
|
924
|
+
res.status(404).json({ error: 'Unknown app' });
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
const key = `${pool.dir}\n${id}`;
|
|
928
|
+
if (restarting.has(key)) {
|
|
929
|
+
res.status(409).json({ error: `${target.name} is already restarting` });
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
restarting.add(key);
|
|
933
|
+
try {
|
|
934
|
+
if (TERMINAL.enabled && id === TERMINAL_APP.id && req.body?.force !== true) {
|
|
935
|
+
const terminals = await terminalCount(pool);
|
|
936
|
+
if (terminals) {
|
|
937
|
+
res.status(409).json({ error: `${target.name} has live terminals`, terminals });
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
console.log(`[${id}] restart asked for by the hub page`);
|
|
942
|
+
if (await restartApp(pool, target)) res.json({ ok: true });
|
|
943
|
+
else res.status(503).json({ error: `${target.name} did not start` });
|
|
944
|
+
} finally {
|
|
945
|
+
restarting.delete(key);
|
|
946
|
+
}
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
// Project list for the switcher palette, proxied from the app that provides `projects`. The port is
|
|
950
|
+
// read per request because the provider's real port is only known once its banner has been scraped
|
|
951
|
+
// (see spawnApp).
|
|
768
952
|
app.get('/api/projects', async (_req, res) => {
|
|
769
953
|
res.setHeader('Cache-Control', 'no-store');
|
|
954
|
+
if (!PROJECTS_APP) {
|
|
955
|
+
res.status(503).json({ error: 'no enabled app provides projects' });
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
const { id } = PROJECTS_APP;
|
|
770
959
|
// Waits like the proxy does, so the palette is not the one thing that hard-fails during a restart.
|
|
771
|
-
const
|
|
772
|
-
if (!
|
|
773
|
-
res.status(503).json({ error:
|
|
960
|
+
const port = await waitForPort(id, Date.now() + API_PORT_WAIT_MS);
|
|
961
|
+
if (!port) {
|
|
962
|
+
res.status(503).json({ error: `${id} is starting` });
|
|
774
963
|
return;
|
|
775
964
|
}
|
|
776
965
|
try {
|
|
777
966
|
// 127.0.0.1, not localhost — Node's verbatim DNS ordering tries ::1 first on Windows.
|
|
778
|
-
const upstream = await fetch(`http://127.0.0.1:${
|
|
967
|
+
const upstream = await fetch(`http://127.0.0.1:${port}${PROJECTS_APP.provides.projects.path}`, {
|
|
779
968
|
signal: AbortSignal.timeout(4000),
|
|
780
969
|
});
|
|
781
970
|
if (!upstream.ok) {
|
|
782
|
-
res.status(502).json({ error:
|
|
971
|
+
res.status(502).json({ error: `${id} responded ${upstream.status}` });
|
|
783
972
|
return;
|
|
784
973
|
}
|
|
785
974
|
res.json(await upstream.json());
|
|
786
975
|
} catch (err) {
|
|
787
976
|
// Soft-fail so the palette still opens and offers literal-path entry.
|
|
788
|
-
res.status(502).json({ error:
|
|
977
|
+
res.status(502).json({ error: `${id} unavailable: ${err.message}` });
|
|
789
978
|
}
|
|
790
979
|
});
|
|
791
980
|
|