claude-code-hub 0.41.0 → 1.0.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/README.md +6 -0
- package/lib/actions.js +39 -0
- package/lib/apps.js +136 -9
- package/lib/contain.js +78 -0
- package/lib/cookies.js +13 -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/lib/themes.json +548 -0
- package/package.json +12 -8
- 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 +386 -145
- package/public/index.html +23 -2
- package/public/locked.html +1 -1
- package/server.js +340 -118
package/server.js
CHANGED
|
@@ -8,8 +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 {
|
|
12
|
-
const {
|
|
11
|
+
const { actionTable } = require('./lib/actions');
|
|
12
|
+
const { selectApps, loadApp, conflicts } = require('./lib/apps');
|
|
13
|
+
const { stripCookie } = require('./lib/cookies');
|
|
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');
|
|
13
18
|
|
|
14
19
|
function getArg(name) {
|
|
15
20
|
const idx = process.argv.findIndex((a) => a.startsWith(`--${name}`));
|
|
@@ -20,6 +25,7 @@ function getArg(name) {
|
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
const HUB_PORT = parseInt(getArg('port') || process.env.PORT || '3540', 10);
|
|
28
|
+
let hubPort = HUB_PORT;
|
|
23
29
|
|
|
24
30
|
const POOL_SIZE = Math.max(1, parseInt(getArg('pool-size') || '3', 10));
|
|
25
31
|
const RESTART_DELAY_MS = 500;
|
|
@@ -40,9 +46,25 @@ function activePool() {
|
|
|
40
46
|
// Every sub-app resolves CLAUDE_CONFIG_DIR once at startup into a module constant, so switching
|
|
41
47
|
// the dir means restarting the children. Kept on the server, not in the browser: the dir has to
|
|
42
48
|
// be known before the first spawn, and the hub already has one localStorage key to reason about.
|
|
43
|
-
const HUB_DIR = path.
|
|
49
|
+
const HUB_DIR = path.resolve(expandHome(getArg('hub-dir') || process.env.CLAUDE_HUB_DIR || '~/.claude-hub'));
|
|
44
50
|
const HUB_CONFIG_FILE = path.join(HUB_DIR, 'config.json');
|
|
45
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
|
+
|
|
46
68
|
// The dir the hub would use with no saved config. Set by loadHubConfig; the client uses it to keep
|
|
47
69
|
// the default dir out of the window title.
|
|
48
70
|
let defaultConfigDir = null;
|
|
@@ -104,13 +126,25 @@ function saveHubConfig() {
|
|
|
104
126
|
|
|
105
127
|
const hubConfig = loadHubConfig();
|
|
106
128
|
|
|
107
|
-
const { apps:
|
|
108
|
-
|
|
109
|
-
|
|
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) {
|
|
110
135
|
console.error(`Every app is disabled in ${HUB_CONFIG_FILE}. Enable at least one.`);
|
|
111
136
|
process.exit(1);
|
|
112
137
|
}
|
|
113
|
-
|
|
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');
|
|
114
148
|
|
|
115
149
|
// Every keystroke into cck's terminal passes through the upgrade proxy below. Windows only, for the
|
|
116
150
|
// reasons in cck/lib/priority.js; the children spawned after this still start at normal.
|
|
@@ -134,8 +168,7 @@ const net = createNetGuard({ appName: 'Claude Code Hub' });
|
|
|
134
168
|
// the WebSocket. cck applies the rest of its policy (exposure refusal, Origin, session cap) itself.
|
|
135
169
|
const TERMINAL = {
|
|
136
170
|
...(hubConfig.terminal || {}),
|
|
137
|
-
enabled:
|
|
138
|
-
appEnabled('kanban') && !process.argv.includes('--disable-terminal') && hubConfig.terminal?.enabled !== false,
|
|
171
|
+
enabled: !!TERMINAL_APP && !process.argv.includes('--disable-terminal') && hubConfig.terminal?.enabled !== false,
|
|
139
172
|
};
|
|
140
173
|
const TERMINAL_TOKEN = TERMINAL.enabled ? crypto.randomBytes(32).toString('hex') : null;
|
|
141
174
|
|
|
@@ -147,6 +180,7 @@ const TOKEN_COOKIE = 'hub_token';
|
|
|
147
180
|
const TOKEN_COOKIE_RE = /(?:^|;\s*)hub_token=([^;]*)/;
|
|
148
181
|
const TOKEN_COOKIE_MAX_AGE_MS = 400 * 24 * 60 * 60 * 1000;
|
|
149
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');
|
|
150
184
|
|
|
151
185
|
function loadHubToken() {
|
|
152
186
|
try {
|
|
@@ -191,11 +225,12 @@ function childEnv(pool, name) {
|
|
|
191
225
|
PORT: '0',
|
|
192
226
|
CLAUDE_CONFIG_DIR: pool.dir,
|
|
193
227
|
CLAUDE_HUB: '1',
|
|
194
|
-
HUB_URL: `http://localhost:${
|
|
228
|
+
HUB_URL: `http://localhost:${hubPort}`,
|
|
195
229
|
HOST: net.BIND_HOST,
|
|
196
230
|
ALLOWED_HOSTS: net.ALLOWED_HOSTS,
|
|
231
|
+
HUB_SDK_SERVER,
|
|
197
232
|
};
|
|
198
|
-
if (name ===
|
|
233
|
+
if (TERMINAL.enabled && name === TERMINAL_APP.id) {
|
|
199
234
|
env.CCK_TERMINAL = JSON.stringify(TERMINAL);
|
|
200
235
|
env.CCK_TERMINAL_TOKEN = TERMINAL_TOKEN;
|
|
201
236
|
}
|
|
@@ -206,7 +241,7 @@ function spawnApp(pool, name, cmd, args) {
|
|
|
206
241
|
const child = spawn(cmd, args, {
|
|
207
242
|
cwd: __dirname,
|
|
208
243
|
env: childEnv(pool, name),
|
|
209
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
244
|
+
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
|
210
245
|
});
|
|
211
246
|
|
|
212
247
|
let stdoutBuf = '';
|
|
@@ -231,8 +266,9 @@ function spawnApp(pool, name, cmd, args) {
|
|
|
231
266
|
child.stderr.on('data', (d) => process.stderr.write(`[${name}] ${d}`));
|
|
232
267
|
child.on('exit', (code) => {
|
|
233
268
|
console.log(`[${name}] exited (code ${code})`);
|
|
234
|
-
// The port dies with the child and the OS may hand it to a stranger, so stop dialing it.
|
|
235
|
-
|
|
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];
|
|
236
272
|
markReady();
|
|
237
273
|
if (pool.retired || pool.children.get(name) !== child) return;
|
|
238
274
|
// Nothing else would ever bring this app back: a pool is only rebuilt when its dir is
|
|
@@ -240,6 +276,7 @@ function spawnApp(pool, name, cmd, args) {
|
|
|
240
276
|
const recent = (pool.restarts[name] || []).filter((t) => Date.now() - t < RESTART_WINDOW_MS);
|
|
241
277
|
if (recent.length >= MAX_RESTARTS) {
|
|
242
278
|
console.log(`[${name}] gave up after ${MAX_RESTARTS} restarts in ${RESTART_WINDOW_MS / 1000}s`);
|
|
279
|
+
child.gaveUp = true;
|
|
243
280
|
return;
|
|
244
281
|
}
|
|
245
282
|
recent.push(Date.now());
|
|
@@ -255,22 +292,27 @@ function spawnApp(pool, name, cmd, args) {
|
|
|
255
292
|
return child;
|
|
256
293
|
}
|
|
257
294
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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];
|
|
265
307
|
}
|
|
266
308
|
|
|
267
309
|
function killPool(pool) {
|
|
268
310
|
pool.retired = true;
|
|
269
|
-
|
|
311
|
+
return Promise.all([...pool.children.values()].map((child) => killChild(child)));
|
|
270
312
|
}
|
|
271
313
|
|
|
272
314
|
function killAll() {
|
|
273
|
-
|
|
315
|
+
return Promise.all([...pools.values()].map(killPool));
|
|
274
316
|
}
|
|
275
317
|
|
|
276
318
|
function withTimeout(promise, ms) {
|
|
@@ -303,10 +345,12 @@ async function ensurePool(dir) {
|
|
|
303
345
|
}
|
|
304
346
|
|
|
305
347
|
async function terminalCount(pool) {
|
|
306
|
-
const port = pool.ports.
|
|
348
|
+
const port = pool.ports[TERMINAL_APP.id];
|
|
307
349
|
if (!port) return 0;
|
|
308
350
|
try {
|
|
309
|
-
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
|
+
});
|
|
310
354
|
return r.ok ? (await r.json()).sessions?.length || 0 : 0;
|
|
311
355
|
} catch {
|
|
312
356
|
return 0;
|
|
@@ -340,9 +384,12 @@ function dropPool(dir) {
|
|
|
340
384
|
killPool(pool);
|
|
341
385
|
}
|
|
342
386
|
|
|
387
|
+
// Waits for the children, so a POSIX child that ignores SIGTERM still gets SIGKILL before the hub exits.
|
|
388
|
+
let shuttingDown = false;
|
|
343
389
|
function shutdown() {
|
|
344
|
-
|
|
345
|
-
|
|
390
|
+
if (shuttingDown) return;
|
|
391
|
+
shuttingDown = true;
|
|
392
|
+
killAll().then(() => process.exit(0));
|
|
346
393
|
}
|
|
347
394
|
|
|
348
395
|
process.on('SIGINT', shutdown);
|
|
@@ -364,17 +411,6 @@ process.stdin.on('data', (data) => {
|
|
|
364
411
|
process.stdin.on('end', shutdown);
|
|
365
412
|
process.stdin.on('close', shutdown);
|
|
366
413
|
|
|
367
|
-
function resolveApp(submoduleDir, npmPackage) {
|
|
368
|
-
const local = path.join(__dirname, submoduleDir, 'server.js');
|
|
369
|
-
try {
|
|
370
|
-
require.resolve(local);
|
|
371
|
-
return local;
|
|
372
|
-
} catch {}
|
|
373
|
-
return require.resolve(`${npmPackage}/server.js`);
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
const appPaths = Object.fromEntries(ENABLED_APPS.map((a) => [a.id, resolveApp(a.dir, a.pkg)]));
|
|
377
|
-
|
|
378
414
|
// Raise header size limit to 64KB — localhost cookies from sibling apps can pile up and
|
|
379
415
|
// trip Node's default 16KB limit, breaking iframes with HTTP 431.
|
|
380
416
|
const HDR_BYTES = 65536;
|
|
@@ -383,13 +419,15 @@ const NODE_HDR = `--max-http-header-size=${HDR_BYTES}`;
|
|
|
383
419
|
// Children bind ephemeral ports; the public ports below belong to the hub's proxies. Browser
|
|
384
420
|
// localStorage is keyed by origin, so the sub-app origin has to stay put across switches or the
|
|
385
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
|
+
|
|
386
426
|
function spawnChildren(pool) {
|
|
387
|
-
for (const a of ENABLED_APPS)
|
|
427
|
+
for (const a of ENABLED_APPS) spawnOne(pool, a);
|
|
388
428
|
}
|
|
389
429
|
|
|
390
|
-
const publicPorts = Object.fromEntries(
|
|
391
|
-
ENABLED_APPS.map((a) => [a.id, parseInt(getArg(`${a.id}-port`) || String(a.port), 10)]),
|
|
392
|
-
);
|
|
430
|
+
const publicPorts = Object.fromEntries(ENABLED_APPS.map((a) => [a.id, a.port]));
|
|
393
431
|
|
|
394
432
|
function rewriteOrigin(origin, publicPort, childPort) {
|
|
395
433
|
try {
|
|
@@ -402,7 +440,9 @@ function rewriteOrigin(origin, publicPort, childPort) {
|
|
|
402
440
|
}
|
|
403
441
|
}
|
|
404
442
|
|
|
405
|
-
|
|
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 });
|
|
406
446
|
|
|
407
447
|
// Two failures look the same from here and both recover on their own: a pooled socket the child
|
|
408
448
|
// closed under us, and a child that is restarting. The port is re-read per attempt, so a replay
|
|
@@ -434,24 +474,33 @@ async function waitForPort(name, deadline) {
|
|
|
434
474
|
const pool = activePool();
|
|
435
475
|
const port = pool?.ports[name];
|
|
436
476
|
if (port) return port;
|
|
437
|
-
if (pool
|
|
477
|
+
if (pool?.children.get(name)?.gaveUp) return null;
|
|
438
478
|
const remaining = deadline - Date.now();
|
|
439
479
|
if (remaining <= 0) return null;
|
|
440
480
|
await Promise.race([pool?.children.get(name)?.ready, delay(Math.min(remaining, 250))]);
|
|
441
481
|
}
|
|
442
482
|
}
|
|
443
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
|
+
|
|
444
493
|
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
445
494
|
|
|
446
495
|
// A document load that fails gets a page that reloads itself — the reload the user would do by
|
|
447
496
|
// hand. The cap is small because the server already waited NAV_PORT_WAIT_MS before serving this,
|
|
448
497
|
// and each reload re-spends that wait. sessionStorage throws when site data is blocked, and a page
|
|
449
498
|
// whose only job is to reload must still reload there.
|
|
450
|
-
function retryPage(name, detail) {
|
|
499
|
+
function retryPage(name, headline, detail) {
|
|
451
500
|
return `<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(name)}</title>
|
|
452
501
|
<style>:root{color-scheme:light dark}body{font:14px system-ui,sans-serif;margin:0;display:grid;place-items:center;height:100vh}
|
|
453
502
|
div{text-align:center;opacity:.7}code{font-size:12px;opacity:.6}</style>
|
|
454
|
-
</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>
|
|
455
504
|
<script>
|
|
456
505
|
let n = 0;
|
|
457
506
|
const k = 'hub-retry:' + location.pathname;
|
|
@@ -461,11 +510,11 @@ else { try { sessionStorage.removeItem(k); } catch {} document.getElementById('m
|
|
|
461
510
|
</script></body></html>`;
|
|
462
511
|
}
|
|
463
512
|
|
|
464
|
-
function sendUnavailable(req, res, name, status, detail) {
|
|
513
|
+
function sendUnavailable(req, res, name, status, detail, headline) {
|
|
465
514
|
if (res.destroyed || res.headersSent) return;
|
|
466
515
|
if (isNavigation(req)) {
|
|
467
516
|
res.writeHead(status, { 'Retry-After': '1', 'Content-Type': 'text/html; charset=utf-8' });
|
|
468
|
-
res.end(retryPage(name, detail));
|
|
517
|
+
res.end(retryPage(name, headline, detail));
|
|
469
518
|
return;
|
|
470
519
|
}
|
|
471
520
|
// The sub-app's own callers hand the body to .json(): plain text surfaces as a SyntaxError
|
|
@@ -474,26 +523,98 @@ function sendUnavailable(req, res, name, status, detail) {
|
|
|
474
523
|
res.end(JSON.stringify({ error: detail }));
|
|
475
524
|
}
|
|
476
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
|
+
|
|
477
600
|
// A pooled socket the child closed under us is normal and recovers on the next attempt. A port that
|
|
478
601
|
// never completes a connect does not, so the child is replaced and the respawn gives it a new one.
|
|
479
602
|
function replaceChild(name, childPort, err) {
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
if (!child || pool.ports[name] !== childPort) return;
|
|
603
|
+
const child = currentChild(name, childPort);
|
|
604
|
+
if (!child) return;
|
|
483
605
|
console.log(`[${name}] unreachable at 127.0.0.1:${childPort} (${err.code}) - replacing it`);
|
|
484
|
-
delete
|
|
606
|
+
delete activePool().ports[name];
|
|
485
607
|
killChild(child);
|
|
486
608
|
}
|
|
487
609
|
|
|
610
|
+
const connectTimeoutError = (name, childPort) =>
|
|
611
|
+
Object.assign(new Error(`no connect to ${name} at 127.0.0.1:${childPort}`), { code: 'ETIMEDOUT' });
|
|
612
|
+
|
|
488
613
|
// Fires only while the socket is still connecting, so a slow handler is never interrupted.
|
|
489
614
|
function capConnect(upstream, name, childPort) {
|
|
490
615
|
upstream.on('socket', (socket) => {
|
|
491
616
|
if (!socket.connecting) return;
|
|
492
|
-
const timer = setTimeout(() =>
|
|
493
|
-
const err = new Error(`no connect to ${name} at 127.0.0.1:${childPort}`);
|
|
494
|
-
err.code = 'ETIMEDOUT';
|
|
495
|
-
upstream.destroy(err);
|
|
496
|
-
}, CONNECT_TIMEOUT_MS);
|
|
617
|
+
const timer = setTimeout(() => upstream.destroy(connectTimeoutError(name, childPort)), CONNECT_TIMEOUT_MS);
|
|
497
618
|
const clear = () => clearTimeout(timer);
|
|
498
619
|
socket.once('connect', clear);
|
|
499
620
|
upstream.once('close', clear);
|
|
@@ -502,7 +623,29 @@ function capConnect(upstream, name, childPort) {
|
|
|
502
623
|
|
|
503
624
|
// Resolves null once the response is committed (or the client is gone), or the error when nothing
|
|
504
625
|
// has been written yet and the caller may still retry.
|
|
505
|
-
|
|
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) {
|
|
506
649
|
return new Promise((resolve) => {
|
|
507
650
|
const upstream = http.request(
|
|
508
651
|
{ host: '127.0.0.1', port: childPort, method: req.method, path: req.url, headers, agent: proxyAgent },
|
|
@@ -522,47 +665,64 @@ function forward(name, req, res, childPort, headers, replayable, onUpstream) {
|
|
|
522
665
|
}
|
|
523
666
|
resolve(err);
|
|
524
667
|
});
|
|
525
|
-
if (
|
|
668
|
+
if (body) upstream.end(body.length ? body : undefined);
|
|
526
669
|
else req.pipe(upstream);
|
|
527
670
|
});
|
|
528
671
|
}
|
|
529
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
|
+
|
|
530
680
|
// Host is forwarded untouched so the child's hostGuard still sees what the browser sent. Origin is
|
|
531
681
|
// the one header the child cannot judge on its own: its guard compares the port to its own
|
|
532
682
|
// ephemeral one, so the public-port spelling is mapped to the child's; anything else passes
|
|
533
|
-
// through as-is and the child rejects it.
|
|
683
|
+
// through as-is and the child rejects it. Cookies on localhost are shared across ports, so the
|
|
684
|
+
// browser sends the hub's own token cookie to every app; it never reaches a child.
|
|
534
685
|
function proxyHandler(name) {
|
|
535
686
|
return async (req, res) => {
|
|
536
687
|
const headers = { ...req.headers };
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
688
|
+
const cookie = stripCookie(headers.cookie, TOKEN_COOKIE);
|
|
689
|
+
if (cookie) headers.cookie = cookie;
|
|
690
|
+
else delete headers.cookie;
|
|
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;
|
|
540
696
|
// One deadline for the whole request, not one per attempt: a retry must not re-spend the wait.
|
|
541
|
-
const
|
|
697
|
+
const budget = { deadline: Date.now() + (isNavigation(req) ? NAV_PORT_WAIT_MS : API_PORT_WAIT_MS) };
|
|
542
698
|
// One listener for the request, however many attempts it takes.
|
|
543
699
|
let current = null;
|
|
544
700
|
res.once('close', () => current?.destroy());
|
|
545
701
|
|
|
546
702
|
for (let attempt = 1; !res.destroyed; attempt++) {
|
|
547
|
-
const childPort = await waitForPort(name, deadline);
|
|
703
|
+
const childPort = await waitForPort(name, budget.deadline);
|
|
548
704
|
if (!childPort) {
|
|
549
|
-
|
|
705
|
+
const { headline, detail } = noPortReason(name);
|
|
706
|
+
sendUnavailable(req, res, name, 503, detail, headline);
|
|
550
707
|
return;
|
|
551
708
|
}
|
|
552
709
|
if (req.headers.origin) {
|
|
553
710
|
headers.origin = rewriteOrigin(req.headers.origin, publicPorts[name], childPort);
|
|
554
711
|
}
|
|
555
|
-
const err = await forward(name, req, res, childPort, headers,
|
|
712
|
+
const err = await forward(name, req, res, childPort, headers, body, (u) => {
|
|
556
713
|
current = u;
|
|
557
714
|
});
|
|
558
715
|
if (!err) return;
|
|
559
|
-
if (
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
716
|
+
if (body === null || !RETRYABLE.has(err.code)) {
|
|
717
|
+
sendUnavailable(req, res, name, 502, err.message, 'did not respond');
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
const next = await afterConnectFailure(name, childPort, err, attempt, budget);
|
|
721
|
+
if (!next.wait) {
|
|
722
|
+
sendUnavailable(req, res, name, 503, next.detail, next.headline);
|
|
563
723
|
return;
|
|
564
724
|
}
|
|
565
|
-
await delay(
|
|
725
|
+
await delay(next.wait);
|
|
566
726
|
}
|
|
567
727
|
};
|
|
568
728
|
}
|
|
@@ -580,24 +740,51 @@ function proxyUpgrade(name) {
|
|
|
580
740
|
}
|
|
581
741
|
const lines = [`${req.method} ${req.url} HTTP/${req.httpVersion}`];
|
|
582
742
|
for (let i = 0; i < req.rawHeaders.length; i += 2) {
|
|
743
|
+
const header = req.rawHeaders[i].toLowerCase();
|
|
583
744
|
let value = req.rawHeaders[i + 1];
|
|
584
|
-
if (
|
|
745
|
+
if (header === 'origin') value = rewriteOrigin(value, publicPorts[name], childPort);
|
|
746
|
+
if (header === 'cookie' && !(value = stripCookie(value, TOKEN_COOKIE))) continue;
|
|
585
747
|
lines.push(`${req.rawHeaders[i]}: ${value}`);
|
|
586
748
|
}
|
|
587
|
-
const
|
|
588
|
-
|
|
589
|
-
upstream.write(`${lines.join('\r\n')}\r\n\r\n`);
|
|
590
|
-
if (head?.length) upstream.write(head);
|
|
591
|
-
upstream.pipe(socket);
|
|
592
|
-
socket.pipe(upstream);
|
|
593
|
-
});
|
|
594
|
-
const connectTimer = setTimeout(() => upstream.destroy(), CONNECT_TIMEOUT_MS);
|
|
595
|
-
upstream.on('error', () => socket.destroy());
|
|
596
|
-
socket.on('close', () => upstream.destroy());
|
|
597
|
-
upstream.on('close', () => socket.destroy());
|
|
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);
|
|
598
751
|
};
|
|
599
752
|
}
|
|
600
753
|
|
|
754
|
+
// A connect that never opened follows the same retry rule as proxyHandler.
|
|
755
|
+
function tunnel(name, childPort, request, head, socket, attempt, budget) {
|
|
756
|
+
let connected = false;
|
|
757
|
+
const upstream = tcp.connect(childPort, '127.0.0.1', () => {
|
|
758
|
+
connected = true;
|
|
759
|
+
clearTimeout(connectTimer);
|
|
760
|
+
upstream.write(request);
|
|
761
|
+
if (head?.length) upstream.write(head);
|
|
762
|
+
upstream.pipe(socket);
|
|
763
|
+
socket.pipe(upstream);
|
|
764
|
+
});
|
|
765
|
+
const connectTimer = setTimeout(() => upstream.destroy(connectTimeoutError(name, childPort)), CONNECT_TIMEOUT_MS);
|
|
766
|
+
const onSocketClose = () => upstream.destroy();
|
|
767
|
+
socket.on('close', onSocketClose);
|
|
768
|
+
upstream.on('error', async (err) => {
|
|
769
|
+
if (connected || socket.destroyed) {
|
|
770
|
+
socket.destroy();
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
clearTimeout(connectTimer);
|
|
774
|
+
socket.off('close', onSocketClose);
|
|
775
|
+
upstream.removeAllListeners('close');
|
|
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);
|
|
784
|
+
});
|
|
785
|
+
upstream.on('close', () => socket.destroy());
|
|
786
|
+
}
|
|
787
|
+
|
|
601
788
|
function listenWithFallback(handler, port, onReady, label, onUpgrade) {
|
|
602
789
|
const opts = { maxHeaderSize: HDR_BYTES, onUpgrade };
|
|
603
790
|
const server = net.listenLoopback(handler, port, onReady, opts);
|
|
@@ -621,8 +808,6 @@ for (const [name, port] of Object.entries(publicPorts)) {
|
|
|
621
808
|
);
|
|
622
809
|
}
|
|
623
810
|
|
|
624
|
-
ensurePool(hubConfig.activeConfigDir);
|
|
625
|
-
|
|
626
811
|
const app = express();
|
|
627
812
|
|
|
628
813
|
// Mounted before the routes below, which are registered ahead of the first
|
|
@@ -635,40 +820,36 @@ app.use(express.json());
|
|
|
635
820
|
|
|
636
821
|
// The hub's CSS variables per color theme and mode, read from the same registry generate-themes.mjs
|
|
637
822
|
// compiles into each sub-app's themes.css. Served rather than hand-copied into public/app.js so there
|
|
638
|
-
// is one source of truth.
|
|
639
|
-
// iframe without a flash of another color.
|
|
823
|
+
// is one source of truth.
|
|
640
824
|
// Read once — themes.json only changes when the generator is re-run, which restarts the hub anyway.
|
|
641
|
-
const
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
'--surface': p.surface,
|
|
645
|
-
'--surface-hover': p.hover,
|
|
646
|
-
'--border': p.border,
|
|
647
|
-
'--text': p.ink1,
|
|
648
|
-
'--text-dim': p.inkMuted,
|
|
649
|
-
});
|
|
650
|
-
const themePalettes = (() => {
|
|
651
|
-
try {
|
|
652
|
-
const themes = JSON.parse(fs.readFileSync(path.join(__dirname, 'scripts/themes.json'), 'utf8'));
|
|
653
|
-
return Object.fromEntries(themes.map((t) => [t.id, { dark: hubVars(t.dark), light: hubVars(t.light) }]));
|
|
654
|
-
} catch {
|
|
655
|
-
// Palette falls back to the --accent in index.html; not worth failing startup over.
|
|
656
|
-
return {};
|
|
657
|
-
}
|
|
658
|
-
})();
|
|
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');
|
|
659
828
|
|
|
660
829
|
function appsConfig() {
|
|
661
830
|
return Object.fromEntries(
|
|
662
831
|
ENABLED_APPS.map((a) => {
|
|
663
|
-
const entry = {
|
|
664
|
-
|
|
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;
|
|
665
840
|
return [a.id, entry];
|
|
666
841
|
}),
|
|
667
842
|
);
|
|
668
843
|
}
|
|
669
844
|
|
|
670
845
|
app.get('/api/config', (_req, res) => {
|
|
671
|
-
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
|
+
});
|
|
672
853
|
});
|
|
673
854
|
|
|
674
855
|
app.get('/api/config-dirs', (_req, res) => {
|
|
@@ -733,30 +914,67 @@ app.post('/api/config-dirs/activate', async (req, res) => {
|
|
|
733
914
|
res.json({ apps: appsConfig() });
|
|
734
915
|
});
|
|
735
916
|
|
|
736
|
-
//
|
|
737
|
-
|
|
738
|
-
|
|
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).
|
|
739
952
|
app.get('/api/projects', async (_req, res) => {
|
|
740
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;
|
|
741
959
|
// Waits like the proxy does, so the palette is not the one thing that hard-fails during a restart.
|
|
742
|
-
const
|
|
743
|
-
if (!
|
|
744
|
-
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` });
|
|
745
963
|
return;
|
|
746
964
|
}
|
|
747
965
|
try {
|
|
748
966
|
// 127.0.0.1, not localhost — Node's verbatim DNS ordering tries ::1 first on Windows.
|
|
749
|
-
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}`, {
|
|
750
968
|
signal: AbortSignal.timeout(4000),
|
|
751
969
|
});
|
|
752
970
|
if (!upstream.ok) {
|
|
753
|
-
res.status(502).json({ error:
|
|
971
|
+
res.status(502).json({ error: `${id} responded ${upstream.status}` });
|
|
754
972
|
return;
|
|
755
973
|
}
|
|
756
974
|
res.json(await upstream.json());
|
|
757
975
|
} catch (err) {
|
|
758
976
|
// Soft-fail so the palette still opens and offers literal-path entry.
|
|
759
|
-
res.status(502).json({ error:
|
|
977
|
+
res.status(502).json({ error: `${id} unavailable: ${err.message}` });
|
|
760
978
|
}
|
|
761
979
|
});
|
|
762
980
|
|
|
@@ -770,7 +988,11 @@ app.get('/api/resolve-path', (req, res) => {
|
|
|
770
988
|
|
|
771
989
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
772
990
|
|
|
991
|
+
// Children get HUB_URL at spawn and trust only that origin, so the first pool waits for the port
|
|
992
|
+
// the hub actually bound: HUB_PORT may be busy and fall back to a random one.
|
|
773
993
|
const onReady = (actual) => {
|
|
994
|
+
hubPort = actual;
|
|
995
|
+
ensurePool(hubConfig.activeConfigDir);
|
|
774
996
|
printBanner(actual);
|
|
775
997
|
if (process.argv.includes('--open')) {
|
|
776
998
|
import('open').then((m) => m.default(accessUrl(actual)));
|