handmux 0.18.0 → 0.20.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 +4 -2
- package/README.zh-CN.md +3 -1
- package/hooks/handmux-codex-usage.cjs +141 -0
- package/hooks/handmux-statusline.cjs +16 -3
- package/hooks/handmux-write.cjs +9 -0
- package/package.json +7 -5
- package/public/assets/index-DCZuq0nO.css +32 -0
- package/public/assets/index-DkaDRYCU.js +335 -0
- package/public/index.html +5 -6
- package/src/captureBackground.js +93 -0
- package/src/cli/codexHooks.js +10 -0
- package/src/cli/hookScaffold.js +3 -3
- package/src/cli/i18n/en.js +1 -1
- package/src/cli/i18n/zh.js +1 -1
- package/src/routes/system.js +4 -5
- package/src/routes/terminal.js +43 -2
- package/src/server.js +20 -3
- package/src/staticCompression.js +10 -0
- package/src/terminalStream.js +388 -0
- package/src/tmux/commands.js +25 -3
- package/src/trimCapture.js +3 -3
- package/src/usage.js +214 -60
- package/public/assets/index-CvNuQ-bC.js +0 -330
- package/public/assets/index-DjJf1FX9.css +0 -32
package/public/index.html
CHANGED
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png?v=2" />
|
|
23
23
|
|
|
24
24
|
<!-- Inline boot splash: paints instantly (before the JS bundle loads) so a cold launch shows the
|
|
25
|
-
brand, not a blank dark screen. main.jsx calls window.__hideBootSplash() once React has painted
|
|
26
|
-
|
|
25
|
+
brand, not a blank dark screen. main.jsx calls window.__hideBootSplash() once React has painted.
|
|
26
|
+
Until then it must remain visible: a slow bundle should never expose the still-empty app root. -->
|
|
27
27
|
<style>
|
|
28
28
|
html, body { margin: 0; background: #1a1b1e; } /* opaque dark from frame one — no wallpaper showing through */
|
|
29
29
|
#boot-splash { position: fixed; inset: 0; z-index: 9999; display: flex; flex-direction: column;
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
@keyframes bootDot { 0%,100% { opacity: .22; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } }
|
|
49
49
|
@media (prefers-reduced-motion: reduce) { .boot-glow, .boot-dots i { animation: none; opacity: .8; } }
|
|
50
50
|
</style>
|
|
51
|
-
<script type="module" crossorigin src="/assets/index-
|
|
52
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
51
|
+
<script type="module" crossorigin src="/assets/index-DkaDRYCU.js"></script>
|
|
52
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DCZuq0nO.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-DCZuq0nO.css"></noscript>
|
|
53
53
|
</head>
|
|
54
54
|
<body>
|
|
55
55
|
<div id="boot-splash" aria-hidden="true">
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
<div id="root"></div>
|
|
64
64
|
<script>
|
|
65
65
|
// Hide the splash once the app has painted (main.jsx), but keep it up at least ~650ms so the glow
|
|
66
|
-
// is actually seen, not a flicker.
|
|
66
|
+
// is actually seen, not a flicker. Slow or unavailable bundles leave this branded loading state visible.
|
|
67
67
|
window.__bootStart = Date.now();
|
|
68
68
|
window.__hideBootSplash = function () {
|
|
69
69
|
var el = document.getElementById('boot-splash');
|
|
@@ -74,7 +74,6 @@
|
|
|
74
74
|
setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 500);
|
|
75
75
|
}, wait);
|
|
76
76
|
};
|
|
77
|
-
setTimeout(function () { window.__hideBootSplash(); }, 8000);
|
|
78
77
|
</script>
|
|
79
78
|
</body>
|
|
80
79
|
</html>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const CSI = /\x1b\[([0-9;]*)m/g;
|
|
2
|
+
const ANSI = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/g;
|
|
3
|
+
|
|
4
|
+
function backgroundAfter(line, initial) {
|
|
5
|
+
let background = initial;
|
|
6
|
+
const sgr = new RegExp(CSI.source, 'g');
|
|
7
|
+
let match = sgr.exec(line);
|
|
8
|
+
while (match) {
|
|
9
|
+
const params = match[1] === '' ? [0] : match[1].split(';').map(Number);
|
|
10
|
+
for (let i = 0; i < params.length; i += 1) {
|
|
11
|
+
const value = params[i];
|
|
12
|
+
if (value === 0 || value === 49) background = false;
|
|
13
|
+
else if ((value >= 40 && value <= 47) || (value >= 100 && value <= 107)) background = true;
|
|
14
|
+
else if (value === 38 || value === 48) {
|
|
15
|
+
const isBackground = value === 48;
|
|
16
|
+
if (params[i + 1] === 5) {
|
|
17
|
+
i += 2;
|
|
18
|
+
if (isBackground) background = true;
|
|
19
|
+
} else if (params[i + 1] === 2) {
|
|
20
|
+
i += 4;
|
|
21
|
+
if (isBackground) background = true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
match = sgr.exec(line);
|
|
26
|
+
}
|
|
27
|
+
return background;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function paintsBackground(line) {
|
|
31
|
+
let background = false;
|
|
32
|
+
let offset = 0;
|
|
33
|
+
const sgr = new RegExp(CSI.source, 'g');
|
|
34
|
+
let match = sgr.exec(line);
|
|
35
|
+
while (match) {
|
|
36
|
+
if (background && match.index > offset) return true;
|
|
37
|
+
background = backgroundAfter(match[0], background);
|
|
38
|
+
offset = sgr.lastIndex;
|
|
39
|
+
match = sgr.exec(line);
|
|
40
|
+
}
|
|
41
|
+
return background && offset < line.length;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const isBlank = (line) => line.replace(ANSI, '').trim() === '';
|
|
45
|
+
|
|
46
|
+
// `capture-pane -e -N` compresses SGR state across newlines. A default blank row after a shaded
|
|
47
|
+
// row (Claude) and a genuinely shaded padding row (Codex) therefore look identical in the combined
|
|
48
|
+
// capture. Capturing just that row makes tmux emit the row's real starting attributes. Only those
|
|
49
|
+
// ambiguous rows need the extra read; ordinary rows keep the single fast combined capture.
|
|
50
|
+
export function ambiguousBackgroundRows(lines) {
|
|
51
|
+
const indexes = [];
|
|
52
|
+
let background = false;
|
|
53
|
+
let inBlankRun = false;
|
|
54
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
55
|
+
const line = lines[index];
|
|
56
|
+
const ambiguous = background && isBlank(line);
|
|
57
|
+
// One row resolves the whole trailing run: default closes it before the first LF; shaded is the
|
|
58
|
+
// one real padding row the client preserves and seals before clearing any additional blanks.
|
|
59
|
+
if (ambiguous && !inBlankRun) indexes.push(index);
|
|
60
|
+
inBlankRun = ambiguous;
|
|
61
|
+
background = backgroundAfter(line, background);
|
|
62
|
+
}
|
|
63
|
+
return indexes;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function restoreBlankRowBackgrounds(lines, historyLines, readRow) {
|
|
67
|
+
const restored = [...lines];
|
|
68
|
+
const indexes = ambiguousBackgroundRows(lines);
|
|
69
|
+
await Promise.all(indexes.map(async (index) => {
|
|
70
|
+
const row = index - historyLines;
|
|
71
|
+
const exact = await readRow(row);
|
|
72
|
+
// A separately-captured default row has no background SGR. Explicitly close the combined
|
|
73
|
+
// stream's inherited background before replaying it. A real padding row starts with its own
|
|
74
|
+
// background SGR and is preserved verbatim.
|
|
75
|
+
restored[index] = paintsBackground(exact) ? exact : `\x1b[49m${exact}`;
|
|
76
|
+
}));
|
|
77
|
+
return restored;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function restoreCaptureBackgrounds(capture, paneHeight, readRow) {
|
|
81
|
+
const trailingNewline = capture.endsWith('\n');
|
|
82
|
+
const body = trailingNewline ? capture.slice(0, -1) : capture;
|
|
83
|
+
const lines = body.split('\n');
|
|
84
|
+
const historyLines = Math.max(0, lines.length - paneHeight);
|
|
85
|
+
const restored = await restoreBlankRowBackgrounds(lines, historyLines, async (row) => {
|
|
86
|
+
const exact = await readRow(row);
|
|
87
|
+
return exact.endsWith('\n') ? exact.slice(0, -1) : exact;
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
ansi: restored.join('\n') + (trailingNewline ? '\n' : ''),
|
|
91
|
+
historyLines,
|
|
92
|
+
};
|
|
93
|
+
}
|
package/src/cli/codexHooks.js
CHANGED
|
@@ -105,6 +105,16 @@ export function installCodexHooks(home = homedir(), { srcDir, stateFile } = {})
|
|
|
105
105
|
return { status: 'installed' };
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
// Refresh scripts for an already opted-in install on server restart. Never enables hooks or rewrites
|
|
109
|
+
// config.toml; it only replaces our deployed script bytes with this handmux version.
|
|
110
|
+
export function syncCodexHooks(home = homedir(), { srcDir, stateFile } = {}) {
|
|
111
|
+
// The marked config is the durable opt-in signal. Refresh it even when a service manager's PATH cannot
|
|
112
|
+
// resolve the user's Codex binary; never turn a hook off merely because Node installations switched.
|
|
113
|
+
if (!readConf(home).includes(BEGIN)) return { status: codexHooksStatus(home), changed: false };
|
|
114
|
+
deployHookScripts(hooksDir(home), srcDir, stateFile);
|
|
115
|
+
return { status: 'installed', changed: false };
|
|
116
|
+
}
|
|
117
|
+
|
|
108
118
|
// Uninstall: strip our config.toml region and remove the copied scripts/env. Best-effort.
|
|
109
119
|
export function uninstallCodexHooks(home = homedir()) {
|
|
110
120
|
if (fs.existsSync(configPath(home))) writeFileAtomic(configPath(home), stripCodexHooks(readConf(home)));
|
package/src/cli/hookScaffold.js
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
export const HOOK_SCRIPTS = ['handmux-notify.sh', 'handmux-write.cjs'];
|
|
8
|
+
// Scripts every agent hook installer deploys. The notify/write pair is shared because Codex mirrors
|
|
9
|
+
// Claude's hook contract; the small Codex usage reader is inert unless handmux-write receives agent=codex.
|
|
10
|
+
export const HOOK_SCRIPTS = ['handmux-notify.sh', 'handmux-write.cjs', 'handmux-codex-usage.cjs'];
|
|
11
11
|
|
|
12
12
|
// Atomic write (tmp + rename) so a crash can't leave a half-written config file. Text in, text out — callers
|
|
13
13
|
// pass raw TOML for config.toml, or use writeJsonAtomic for pretty-printed settings.json.
|
package/src/cli/i18n/en.js
CHANGED
|
@@ -285,7 +285,7 @@ export default {
|
|
|
285
285
|
'setup.sshReady': '✓ passwordless SSH already set up',
|
|
286
286
|
'setup.sshSetup': "→ setting up passwordless SSH to {host} (you'll enter the password once) …",
|
|
287
287
|
'setup.sshHelp1': 'Server side (one-time): point a reverse proxy at the forwarded loopback port.',
|
|
288
|
-
'setup.sshHelpNginx': ' nginx: proxy_pass http://127.0.0.1:{port};
|
|
288
|
+
'setup.sshHelpNginx': ' nginx: proxy_pass http://127.0.0.1:{port}; also set proxy_http_version 1.1, the Upgrade/Connection WebSocket headers, client_max_body_size 60m, and proxy_read_timeout 90s;',
|
|
289
289
|
'setup.sshHelpCaddy': ' caddy: {url} { reverse_proxy 127.0.0.1:{port} }',
|
|
290
290
|
'setup.previewAbout': 'Open a development service running on this machine by port from your phone. Static directory preview still works without this setting.',
|
|
291
291
|
'setup.previewRoute': 'Enter only the preview domain (for example, preview.example.com). handmux uses <project-name>.preview.example.com; route *.preview.example.com to the handmux gateway.',
|
package/src/cli/i18n/zh.js
CHANGED
|
@@ -284,7 +284,7 @@ export default {
|
|
|
284
284
|
'setup.sshReady': '✓ 免密 SSH 已配置好',
|
|
285
285
|
'setup.sshSetup': '→ 正在配置到 {host} 的免密 SSH(你需要输入一次密码)…',
|
|
286
286
|
'setup.sshHelp1': '服务端(一次性):把反向代理指向被转发的 loopback 端口。',
|
|
287
|
-
'setup.sshHelpNginx': ' nginx: proxy_pass http://127.0.0.1:{port};
|
|
287
|
+
'setup.sshHelpNginx': ' nginx: proxy_pass http://127.0.0.1:{port}; 并设置 proxy_http_version 1.1、Upgrade/Connection WebSocket 头、client_max_body_size 60m、proxy_read_timeout 90s;',
|
|
288
288
|
'setup.sshHelpCaddy': ' caddy: {url} { reverse_proxy 127.0.0.1:{port} }',
|
|
289
289
|
'setup.previewAbout': '用于在手机上按端口打开本机正在运行的开发服务;不配置时,静态目录预览仍可使用。',
|
|
290
290
|
'setup.previewRoute': '只填预览域名(如 preview.example.com)。handmux 会使用 <项目名>.preview.example.com;请将 *.preview.example.com 路由到 handmux 网关。',
|
package/src/routes/system.js
CHANGED
|
@@ -106,11 +106,10 @@ export function systemRoutes({ commands, claudeEvents, asrEnv, shortcuts, home,
|
|
|
106
106
|
try { res.json(await claudeEvents.getStates(allowed)); } catch (e) { next(e); }
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
-
// Agent usage/quota for the Usage page.
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
try { res.json(getUsageCached(home)); } catch (e) { next(e); }
|
|
109
|
+
// Agent usage/quota for the Usage page. Claude reads its statusLine snapshot; Codex asks its own local
|
|
110
|
+
// app-server for account limits and merges rollout token/context data. Cached briefly; never handles auth.
|
|
111
|
+
r.get('/usage', async (req, res, next) => {
|
|
112
|
+
try { res.json(await getUsageCached(home)); } catch (e) { next(e); }
|
|
114
113
|
});
|
|
115
114
|
|
|
116
115
|
// Orphan Claude sessions: `claude` processes running on this host but NOT inside a tmux pane, so
|
package/src/routes/terminal.js
CHANGED
|
@@ -6,6 +6,7 @@ import { gzipSync } from 'node:zlib';
|
|
|
6
6
|
import { isPaneId, isWindowId } from '../tmux/commands.js';
|
|
7
7
|
import { capTrailingBlankRows } from '../trimCapture.js';
|
|
8
8
|
import { isAllowedKey } from '../keyNames.js';
|
|
9
|
+
import { restoreCaptureBackgrounds } from '../captureBackground.js';
|
|
9
10
|
|
|
10
11
|
export { isAllowedKey } from '../keyNames.js';
|
|
11
12
|
|
|
@@ -19,7 +20,16 @@ export { isAllowedKey } from '../keyNames.js';
|
|
|
19
20
|
// beat to ingest the pasted line; without it, the Enter can fold into the input as a newline
|
|
20
21
|
// instead of submitting. 120ms is imperceptible but enough to settle.
|
|
21
22
|
const SUBMIT_GAP_MS = 120;
|
|
23
|
+
const RAW_INPUT_MAX_BYTES = 16 * 1024;
|
|
22
24
|
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
25
|
+
const validHexInput = (hex) => typeof hex === 'string'
|
|
26
|
+
&& hex.length >= 2
|
|
27
|
+
&& hex.length <= RAW_INPUT_MAX_BYTES * 2
|
|
28
|
+
&& hex.length % 2 === 0
|
|
29
|
+
&& /^[0-9a-f]+$/i.test(hex);
|
|
30
|
+
const isMissingPane = (error) =>
|
|
31
|
+
/(?:can't find|cannot find|no such|unknown|not found).*\bpane\b|\bpane\b.*(?:can't find|cannot find|no such|unknown|not found)/i
|
|
32
|
+
.test(error instanceof Error ? error.message : String(error));
|
|
23
33
|
|
|
24
34
|
export function terminalRoutes({ commands }) {
|
|
25
35
|
const r = express.Router();
|
|
@@ -37,13 +47,28 @@ export function terminalRoutes({ commands }) {
|
|
|
37
47
|
// pane as exactly its visible screen (lines=0) and skip the blank-trim. A normal pane still pulls
|
|
38
48
|
// `lines` of scrollback and caps the empty grid below the cursor (fresh shell = "prompt + a wall of
|
|
39
49
|
// blank rows") so the phone's bottom-anchored render shows content, not blank. See trimCapture.js.
|
|
40
|
-
const
|
|
50
|
+
const captured = await commands.capturePane(req.query.pane, altScreen ? 0 : lines);
|
|
51
|
+
const restored = typeof commands.capturePaneRow === 'function'
|
|
52
|
+
? await restoreCaptureBackgrounds(
|
|
53
|
+
captured,
|
|
54
|
+
height,
|
|
55
|
+
(row) => commands.capturePaneRow(req.query.pane, row),
|
|
56
|
+
)
|
|
57
|
+
: {
|
|
58
|
+
ansi: captured,
|
|
59
|
+
historyLines: Math.max(
|
|
60
|
+
0,
|
|
61
|
+
(captured.endsWith('\n') ? captured.slice(0, -1) : captured).split('\n').length - height,
|
|
62
|
+
),
|
|
63
|
+
};
|
|
64
|
+
const raw = restored.ansi;
|
|
41
65
|
const ansi = altScreen ? raw : capTrailingBlankRows(raw);
|
|
42
66
|
// The cursor's row counted from the BOTTOM of the (trimmed) capture. The live screen is the
|
|
43
67
|
// capture's last `height` rows, so the cursor sits `height-1-cursorY` rows above the bottom —
|
|
44
68
|
// less however many trailing blank rows capTrailingBlankRows dropped (all of them below the
|
|
45
69
|
// cursor). The client re-places xterm's cursor this many rows up from the seed's last row.
|
|
46
70
|
const rowsOf = (s) => (s.endsWith('\n') ? s.slice(0, -1) : s).split('\n').length;
|
|
71
|
+
const historyLines = altScreen ? 0 : restored.historyLines;
|
|
47
72
|
const cur = {
|
|
48
73
|
row: Math.max(0, (height - 1 - cursorY) - (rowsOf(raw) - rowsOf(ansi))),
|
|
49
74
|
col: cursorX, vis: cursorVisible,
|
|
@@ -58,7 +83,9 @@ export function terminalRoutes({ commands }) {
|
|
|
58
83
|
.update(`${width}x${height}\n${cur.col},${cursorY},${cur.vis ? 1 : 0}\n${altScreen ? 1 : 0}${mouseAware ? 'm' : ''}\n${ansi}`)
|
|
59
84
|
.digest('hex').slice(0, 16);
|
|
60
85
|
if (req.query.since === hash) return res.status(204).end();
|
|
61
|
-
const json = JSON.stringify({
|
|
86
|
+
const json = JSON.stringify({
|
|
87
|
+
ansi, width, height, historyLines, hash, cur, alt: altScreen, mouseAware,
|
|
88
|
+
});
|
|
62
89
|
res.set('Content-Type', 'application/json');
|
|
63
90
|
res.set('Vary', 'Accept-Encoding'); // both 200 branches vary on encoding (correct for any caching proxy)
|
|
64
91
|
// Capture text is mostly SGR codes + spaces — gzip crushes it ~10x. (204s are empty, never gzipped.)
|
|
@@ -87,6 +114,20 @@ export function terminalRoutes({ commands }) {
|
|
|
87
114
|
} catch (e) { next(e); }
|
|
88
115
|
});
|
|
89
116
|
|
|
117
|
+
r.post('/input', async (req, res, next) => {
|
|
118
|
+
const { pane, hex } = req.body || {};
|
|
119
|
+
if (!isPaneId(pane)) return res.status(400).json({ error: 'bad pane id' });
|
|
120
|
+
if (!validHexInput(hex)) return res.status(400).json({ error: 'bad input bytes' });
|
|
121
|
+
try {
|
|
122
|
+
await commands.exitCopyModeIfActive(pane);
|
|
123
|
+
await commands.sendHexInput(pane, hex);
|
|
124
|
+
res.json({ ok: true });
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (isMissingPane(error)) return res.status(404).json({ error: 'pane not found' });
|
|
127
|
+
next(error);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
90
131
|
// Resize the tmux window so it reflows to the phone (auto:false), or hand sizing back to
|
|
91
132
|
// attached clients (auto:true). NOTE: this mutates the shared window — the PC's view of it
|
|
92
133
|
// changes too.
|
package/src/server.js
CHANGED
|
@@ -8,10 +8,12 @@ import { createApiRouter } from './httpApi.js';
|
|
|
8
8
|
import { loadUploadExts } from './uploadTypes.js';
|
|
9
9
|
import { createClaudeEvents } from './claudeEvents.js';
|
|
10
10
|
import { syncHooks } from './cli/claudeHooks.js';
|
|
11
|
+
import { syncCodexHooks } from './cli/codexHooks.js';
|
|
11
12
|
import { claudeStatePath } from './cli/state.js';
|
|
12
13
|
import * as commands from './tmux/commands.js';
|
|
13
14
|
import * as push from './push.js';
|
|
14
15
|
import { cacheControlFor } from './staticCache.js';
|
|
16
|
+
import { compressStaticAssets } from './staticCompression.js';
|
|
15
17
|
import { applyAppName, applyManifestName } from './appName.js';
|
|
16
18
|
import { homedir } from 'node:os';
|
|
17
19
|
import { createPreviews } from './previews.js';
|
|
@@ -22,6 +24,7 @@ import { createEnvironmentProvider } from './workspace/environment.js';
|
|
|
22
24
|
import { createWorkspaceLock } from './workspace/lock.js';
|
|
23
25
|
import { createGracefulShutdown, createWorkspaceBackground } from './workspace/checkpointer.js';
|
|
24
26
|
import { createWorkspaceRuntime } from './workspace/runtime.js';
|
|
27
|
+
import { createTerminalStream } from './terminalStream.js';
|
|
25
28
|
|
|
26
29
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
27
30
|
|
|
@@ -80,6 +83,12 @@ try {
|
|
|
80
83
|
stateFile,
|
|
81
84
|
});
|
|
82
85
|
} catch { /* best effort — hook sync never fails startup */ }
|
|
86
|
+
try {
|
|
87
|
+
syncCodexHooks(home, {
|
|
88
|
+
srcDir: path.resolve(here, '../hooks'),
|
|
89
|
+
stateFile,
|
|
90
|
+
});
|
|
91
|
+
} catch { /* best effort — hook sync never fails startup */ }
|
|
83
92
|
|
|
84
93
|
// Static-site + dynamic preview. The dynamic side is enabled by HANDMUX_PREVIEW_DOMAIN (the wildcard
|
|
85
94
|
// base domain, e.g. preview.example.com); unset → static only. One registry instance is shared by the
|
|
@@ -119,6 +128,7 @@ if (appName) {
|
|
|
119
128
|
}
|
|
120
129
|
|
|
121
130
|
// index:false so the renamed shell below owns "/" too (otherwise static would serve the generic one).
|
|
131
|
+
app.use(compressStaticAssets);
|
|
122
132
|
app.use(express.static(staticDir, {
|
|
123
133
|
index: false,
|
|
124
134
|
// Cache-Control policy lives in staticCache.js (unit-tested): index.html + sw.js are never cached
|
|
@@ -142,10 +152,17 @@ app.get('*', (req, res, next) => {
|
|
|
142
152
|
const server = app.listen(cfg.port, cfg.host, () => {
|
|
143
153
|
console.log(`[handmux] listening on http://${cfg.host}:${cfg.port} (serving ${staticDir})`);
|
|
144
154
|
});
|
|
145
|
-
|
|
146
|
-
|
|
155
|
+
const terminalStream = createTerminalStream({ token, commands });
|
|
156
|
+
// The terminal stream owns one exact path. Every other Upgrade keeps using the existing
|
|
157
|
+
// dynamic-preview router unchanged.
|
|
158
|
+
server.on('upgrade', (req, socket, head) => {
|
|
159
|
+
if (!terminalStream.onUpgrade(req, socket, head)) preview.onUpgrade(req, socket, head);
|
|
160
|
+
});
|
|
147
161
|
|
|
148
162
|
const shutdown = createGracefulShutdown({ events, workspace, server });
|
|
149
|
-
const handleSignal = () => {
|
|
163
|
+
const handleSignal = () => {
|
|
164
|
+
terminalStream.close();
|
|
165
|
+
shutdown().catch(() => {});
|
|
166
|
+
};
|
|
150
167
|
process.on('SIGINT', handleSignal);
|
|
151
168
|
process.on('SIGTERM', handleSignal);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import compression from 'compression';
|
|
2
|
+
|
|
3
|
+
const compress = compression({ threshold: 1024 });
|
|
4
|
+
|
|
5
|
+
// Only compress Vite's content-hashed frontend assets. API responses keep their existing
|
|
6
|
+
// latency/streaming behavior, while the large JS/CSS needed for first paint becomes much smaller.
|
|
7
|
+
export function compressStaticAssets(req, res, next) {
|
|
8
|
+
if (!req.path.startsWith('/assets/')) return next();
|
|
9
|
+
return compress(req, res, next);
|
|
10
|
+
}
|