tmux-web 0.1.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 +44 -0
- package/dist/frontend.js +364 -0
- package/dist/index.js +111 -0
- package/dist/sessions.js +21 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# tmux-web
|
|
2
|
+
|
|
3
|
+
Access your tmux sessions from the browser. A lightweight web server that lists running tmux sessions and lets you attach to them through a full terminal in your browser.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g tmux-web
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or run directly with npx:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx tmux-web
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# Start on default port 3000
|
|
21
|
+
tmux-web
|
|
22
|
+
|
|
23
|
+
# Custom port
|
|
24
|
+
PORT=8080 tmux-web
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Then open `http://localhost:3000` in your browser. You'll see a list of active tmux sessions — click one to attach.
|
|
28
|
+
|
|
29
|
+
## Prerequisites
|
|
30
|
+
|
|
31
|
+
- **Node.js** >= 18
|
|
32
|
+
- **tmux** installed and available in your PATH
|
|
33
|
+
|
|
34
|
+
## How it works
|
|
35
|
+
|
|
36
|
+
- The landing page lists all active tmux sessions
|
|
37
|
+
- Clicking a session opens a full terminal view powered by [ghostty-web](https://github.com/nickolay/ghostty-web)
|
|
38
|
+
- The browser connects to the server over WebSocket, which spawns `tmux attach-session` via a PTY
|
|
39
|
+
- Terminal resize, input, and scrollback all work as expected
|
|
40
|
+
- Auto-reconnects if the connection drops
|
|
41
|
+
|
|
42
|
+
## License
|
|
43
|
+
|
|
44
|
+
MIT
|
package/dist/frontend.js
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
export function renderTerminal(sessionName) {
|
|
2
|
+
return /* html */ `<!DOCTYPE html>
|
|
3
|
+
<html lang="en">
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="UTF-8" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>tmux: ${sessionName}</title>
|
|
8
|
+
<style>
|
|
9
|
+
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap');
|
|
10
|
+
:root {
|
|
11
|
+
--page-bg: #111111;
|
|
12
|
+
--page-fg: #d0d0d0;
|
|
13
|
+
--panel-bg: #11161d;
|
|
14
|
+
--panel-border: #243241;
|
|
15
|
+
--panel-muted: #8a97a6;
|
|
16
|
+
--panel-accent: #f3f7fb;
|
|
17
|
+
--panel-success: #73c991;
|
|
18
|
+
}
|
|
19
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
20
|
+
html, body { background: var(--page-bg); color: var(--page-fg); height: 100%; width: 100%; overflow: hidden; }
|
|
21
|
+
body { display: flex; flex-direction: column; }
|
|
22
|
+
header {
|
|
23
|
+
padding: 7px 16px;
|
|
24
|
+
background: linear-gradient(180deg, rgba(19, 28, 36, 0.98), rgba(13, 18, 24, 0.98));
|
|
25
|
+
border-bottom: 1px solid var(--panel-border);
|
|
26
|
+
display: flex;
|
|
27
|
+
align-items: center;
|
|
28
|
+
gap: 12px;
|
|
29
|
+
flex-shrink: 0;
|
|
30
|
+
min-height: 38px;
|
|
31
|
+
}
|
|
32
|
+
header h1 {
|
|
33
|
+
font-size: 13px;
|
|
34
|
+
line-height: 1;
|
|
35
|
+
font-weight: 600;
|
|
36
|
+
letter-spacing: 0.08em;
|
|
37
|
+
text-transform: uppercase;
|
|
38
|
+
color: var(--panel-accent);
|
|
39
|
+
font-family: 'JetBrains Mono', 'SF Mono', 'Menlo', monospace;
|
|
40
|
+
white-space: nowrap;
|
|
41
|
+
}
|
|
42
|
+
header .session {
|
|
43
|
+
font-size: 11px;
|
|
44
|
+
color: var(--panel-muted);
|
|
45
|
+
font-family: 'JetBrains Mono', monospace;
|
|
46
|
+
background: rgba(0, 0, 0, 0.28);
|
|
47
|
+
border: 1px solid rgba(125, 211, 252, 0.12);
|
|
48
|
+
padding: 4px 8px;
|
|
49
|
+
border-radius: 6px;
|
|
50
|
+
}
|
|
51
|
+
header .status {
|
|
52
|
+
margin-left: auto;
|
|
53
|
+
font-size: 11px;
|
|
54
|
+
display: flex;
|
|
55
|
+
align-items: center;
|
|
56
|
+
gap: 6px;
|
|
57
|
+
font-family: 'JetBrains Mono', 'SF Mono', 'Menlo', monospace;
|
|
58
|
+
color: var(--panel-muted);
|
|
59
|
+
}
|
|
60
|
+
header .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--panel-muted); transition: background 0.2s; }
|
|
61
|
+
header .dot.connected { background: var(--panel-success); }
|
|
62
|
+
#terminal-container { flex: 1; width: 100%; overflow: hidden; }
|
|
63
|
+
</style>
|
|
64
|
+
</head>
|
|
65
|
+
<body>
|
|
66
|
+
<header>
|
|
67
|
+
<h1>tmux</h1>
|
|
68
|
+
<span class="session">${sessionName}</span>
|
|
69
|
+
<div class="status">
|
|
70
|
+
<div class="dot" id="status-dot"></div>
|
|
71
|
+
<span id="status-text">connecting</span>
|
|
72
|
+
</div>
|
|
73
|
+
</header>
|
|
74
|
+
<div id="terminal-container"></div>
|
|
75
|
+
|
|
76
|
+
<script type="module">
|
|
77
|
+
import { init, Terminal } from 'https://esm.sh/ghostty-web@latest';
|
|
78
|
+
|
|
79
|
+
await init();
|
|
80
|
+
|
|
81
|
+
const term = new Terminal({
|
|
82
|
+
fontSize: 14,
|
|
83
|
+
fontFamily: "'JetBrains Mono', 'SF Mono', 'Menlo', monospace",
|
|
84
|
+
cursorBlink: true,
|
|
85
|
+
cursorStyle: 'bar',
|
|
86
|
+
scrollback: 50000,
|
|
87
|
+
convertEol: false,
|
|
88
|
+
theme: {
|
|
89
|
+
foreground: '#ffffff',
|
|
90
|
+
background: '#282c34',
|
|
91
|
+
cursor: '#ffffff',
|
|
92
|
+
cursorAccent: '#282c34',
|
|
93
|
+
selectionBackground: '#ffffff',
|
|
94
|
+
selectionForeground: '#282c34',
|
|
95
|
+
black: '#1d1f21',
|
|
96
|
+
red: '#cc6666',
|
|
97
|
+
green: '#b5bd68',
|
|
98
|
+
yellow: '#f0c674',
|
|
99
|
+
blue: '#81a2be',
|
|
100
|
+
magenta: '#b294bb',
|
|
101
|
+
cyan: '#8abeb7',
|
|
102
|
+
white: '#c5c8c6',
|
|
103
|
+
brightBlack: '#666666',
|
|
104
|
+
brightRed: '#d54e53',
|
|
105
|
+
brightGreen: '#b9ca4a',
|
|
106
|
+
brightYellow: '#e7c547',
|
|
107
|
+
brightBlue: '#7aa6da',
|
|
108
|
+
brightMagenta: '#c397d8',
|
|
109
|
+
brightCyan: '#70c0b1',
|
|
110
|
+
brightWhite: '#eaeaea',
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const container = document.getElementById('terminal-container');
|
|
115
|
+
term.open(container);
|
|
116
|
+
|
|
117
|
+
let cols = 80, rows = 24, ws, charW = 0, charH = 0;
|
|
118
|
+
let fitRaf = 0;
|
|
119
|
+
let fitTimer = 0;
|
|
120
|
+
let touchGesture = null;
|
|
121
|
+
let suppressTouchClickUntil = 0;
|
|
122
|
+
|
|
123
|
+
function updateCellMetrics(force = false) {
|
|
124
|
+
const canvas = container.querySelector('canvas');
|
|
125
|
+
if (canvas && canvas.offsetWidth > 0 && canvas.offsetHeight > 0 && cols > 0 && rows > 0) {
|
|
126
|
+
const nextW = canvas.offsetWidth / cols;
|
|
127
|
+
const nextH = canvas.offsetHeight / rows;
|
|
128
|
+
if (force || !charW || !charH) {
|
|
129
|
+
charW = nextW;
|
|
130
|
+
charH = nextH;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!charW || !charH) {
|
|
134
|
+
charW = 9.0;
|
|
135
|
+
charH = 18;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function getTerminalViewportRect() {
|
|
140
|
+
const rect = container.getBoundingClientRect();
|
|
141
|
+
const vv = window.visualViewport;
|
|
142
|
+
if (!vv) return rect;
|
|
143
|
+
return {
|
|
144
|
+
width: Math.min(rect.width, vv.width),
|
|
145
|
+
height: Math.max(0, Math.min(rect.height, vv.height - Math.max(0, rect.top))),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function fitTerminal(force = false) {
|
|
150
|
+
const rect = getTerminalViewportRect();
|
|
151
|
+
if (rect.width <= 0 || rect.height <= 0) return;
|
|
152
|
+
updateCellMetrics(force && (!charW || !charH));
|
|
153
|
+
const nc = Math.floor(rect.width / charW);
|
|
154
|
+
const nr = Math.floor(rect.height / charH);
|
|
155
|
+
if (nc < 10 || nr < 5) return;
|
|
156
|
+
if (force || nc !== cols || nr !== rows) {
|
|
157
|
+
cols = nc;
|
|
158
|
+
rows = nr;
|
|
159
|
+
term.resize(cols, rows);
|
|
160
|
+
sendJSON({ type: 'resize', cols, rows });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function scheduleFit(force = false) {
|
|
165
|
+
if (fitRaf) cancelAnimationFrame(fitRaf);
|
|
166
|
+
clearTimeout(fitTimer);
|
|
167
|
+
fitTerminal(force);
|
|
168
|
+
fitRaf = requestAnimationFrame(() => { fitRaf = 0; fitTerminal(force); });
|
|
169
|
+
fitTimer = setTimeout(() => fitTerminal(force), 120);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
scheduleFit(true);
|
|
173
|
+
window.addEventListener('resize', () => scheduleFit(true));
|
|
174
|
+
window.visualViewport?.addEventListener('resize', () => scheduleFit(true));
|
|
175
|
+
window.visualViewport?.addEventListener('scroll', () => scheduleFit(true));
|
|
176
|
+
new ResizeObserver(() => scheduleFit(true)).observe(container);
|
|
177
|
+
|
|
178
|
+
if (document.fonts?.ready) {
|
|
179
|
+
document.fonts.ready.then(() => {
|
|
180
|
+
scheduleFit(true);
|
|
181
|
+
setTimeout(() => scheduleFit(true), 50);
|
|
182
|
+
setTimeout(() => scheduleFit(true), 200);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function scheduleKeyboardFit() {
|
|
187
|
+
scheduleFit(true);
|
|
188
|
+
setTimeout(() => scheduleFit(true), 50);
|
|
189
|
+
setTimeout(() => scheduleFit(true), 150);
|
|
190
|
+
setTimeout(() => scheduleFit(true), 300);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
container.addEventListener('focusin', () => scheduleKeyboardFit(), true);
|
|
194
|
+
|
|
195
|
+
const dot = document.getElementById('status-dot');
|
|
196
|
+
const statusText = document.getElementById('status-text');
|
|
197
|
+
function setConnected(ok) {
|
|
198
|
+
dot.className = 'dot' + (ok ? ' connected' : '');
|
|
199
|
+
statusText.textContent = ok ? 'connected' : 'reconnecting';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
203
|
+
const wsUrl = proto + '//' + location.host + '/ws/${sessionName}';
|
|
204
|
+
let reconnectDelay = 1000;
|
|
205
|
+
const isSafari = /^((?!chrome|android|crios|fxios|edgios).)*safari/i.test(navigator.userAgent);
|
|
206
|
+
|
|
207
|
+
function sendJSON(obj) {
|
|
208
|
+
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function connect() {
|
|
212
|
+
ws = new WebSocket(wsUrl);
|
|
213
|
+
ws.onopen = () => {
|
|
214
|
+
try { term.reset(); } catch {}
|
|
215
|
+
setConnected(true);
|
|
216
|
+
reconnectDelay = 1000;
|
|
217
|
+
sendJSON({ type: 'resize', cols, rows });
|
|
218
|
+
};
|
|
219
|
+
ws.onmessage = (event) => {
|
|
220
|
+
if (typeof event.data === 'string') term.write(event.data);
|
|
221
|
+
};
|
|
222
|
+
ws.onclose = () => {
|
|
223
|
+
setConnected(false);
|
|
224
|
+
setTimeout(() => {
|
|
225
|
+
reconnectDelay = Math.min(reconnectDelay * 2, 10000);
|
|
226
|
+
connect();
|
|
227
|
+
}, reconnectDelay);
|
|
228
|
+
};
|
|
229
|
+
ws.onerror = () => ws.close();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
term.onData((data) => sendJSON({ type: 'input', data }));
|
|
233
|
+
|
|
234
|
+
document.addEventListener('keydown', (event) => {
|
|
235
|
+
if (!isSafari || event.key !== 'Escape') return;
|
|
236
|
+
if (event.defaultPrevented || event.repeat || event.metaKey || event.ctrlKey || event.altKey) return;
|
|
237
|
+
const active = document.activeElement;
|
|
238
|
+
const terminalFocused = active === container || active === term.textarea || container.contains(active);
|
|
239
|
+
if (!terminalFocused) return;
|
|
240
|
+
event.preventDefault();
|
|
241
|
+
event.stopPropagation();
|
|
242
|
+
sendJSON({ type: 'input', data: '\\x1b' });
|
|
243
|
+
}, true);
|
|
244
|
+
|
|
245
|
+
function dispatchTerminalWheel(deltaY, clientX, clientY) {
|
|
246
|
+
const target = container.querySelector('canvas') || container;
|
|
247
|
+
target.dispatchEvent(new WheelEvent('wheel', {
|
|
248
|
+
deltaY, deltaMode: WheelEvent.DOM_DELTA_PIXEL,
|
|
249
|
+
clientX, clientY, bubbles: true, cancelable: true,
|
|
250
|
+
}));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function focusTerminal() {
|
|
254
|
+
const active = term.textarea || container.querySelector('textarea') || container;
|
|
255
|
+
active?.focus?.();
|
|
256
|
+
scheduleKeyboardFit();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
container.addEventListener('touchstart', (event) => {
|
|
260
|
+
if (event.touches.length !== 1) { touchGesture = null; return; }
|
|
261
|
+
const touch = event.touches[0];
|
|
262
|
+
touchGesture = { startX: touch.clientX, startY: touch.clientY, lastX: touch.clientX, lastY: touch.clientY, scrolling: false };
|
|
263
|
+
event.stopPropagation();
|
|
264
|
+
}, { passive: true, capture: true });
|
|
265
|
+
|
|
266
|
+
container.addEventListener('touchmove', (event) => {
|
|
267
|
+
if (!touchGesture || event.touches.length !== 1) return;
|
|
268
|
+
const touch = event.touches[0];
|
|
269
|
+
const totalDy = touch.clientY - touchGesture.startY;
|
|
270
|
+
const totalDx = touch.clientX - touchGesture.startX;
|
|
271
|
+
if (!touchGesture.scrolling) {
|
|
272
|
+
if (Math.abs(totalDy) < 8 || Math.abs(totalDy) < Math.abs(totalDx)) return;
|
|
273
|
+
touchGesture.scrolling = true;
|
|
274
|
+
suppressTouchClickUntil = Date.now() + 500;
|
|
275
|
+
}
|
|
276
|
+
event.preventDefault();
|
|
277
|
+
event.stopPropagation();
|
|
278
|
+
dispatchTerminalWheel(-(touch.clientY - touchGesture.lastY), touch.clientX, touch.clientY);
|
|
279
|
+
touchGesture.lastX = touch.clientX;
|
|
280
|
+
touchGesture.lastY = touch.clientY;
|
|
281
|
+
}, { passive: false, capture: true });
|
|
282
|
+
|
|
283
|
+
container.addEventListener('touchend', (event) => {
|
|
284
|
+
if (!touchGesture) return;
|
|
285
|
+
const wasScrolling = touchGesture.scrolling;
|
|
286
|
+
touchGesture = null;
|
|
287
|
+
event.stopPropagation();
|
|
288
|
+
if (!wasScrolling) focusTerminal();
|
|
289
|
+
else { suppressTouchClickUntil = Date.now() + 500; event.preventDefault(); }
|
|
290
|
+
}, { passive: false, capture: true });
|
|
291
|
+
|
|
292
|
+
container.addEventListener('touchcancel', (event) => {
|
|
293
|
+
touchGesture = null;
|
|
294
|
+
event.stopPropagation();
|
|
295
|
+
}, { passive: true, capture: true });
|
|
296
|
+
|
|
297
|
+
container.addEventListener('pointerup', (event) => {
|
|
298
|
+
if (event.pointerType === 'touch' && Date.now() < suppressTouchClickUntil) {
|
|
299
|
+
event.preventDefault();
|
|
300
|
+
event.stopPropagation();
|
|
301
|
+
}
|
|
302
|
+
}, true);
|
|
303
|
+
|
|
304
|
+
connect();
|
|
305
|
+
</script>
|
|
306
|
+
</body>
|
|
307
|
+
</html>`;
|
|
308
|
+
}
|
|
309
|
+
export function renderLanding(sessions) {
|
|
310
|
+
const rows = sessions
|
|
311
|
+
.map((s) => `<a href="/s/${encodeURIComponent(s.name)}" class="session-row">
|
|
312
|
+
<span class="name">${s.name}</span>
|
|
313
|
+
<span class="meta">${s.windows} window${s.windows !== 1 ? "s" : ""}${s.attached ? " · attached" : ""}</span>
|
|
314
|
+
</a>`)
|
|
315
|
+
.join("\n");
|
|
316
|
+
const empty = sessions.length === 0
|
|
317
|
+
? `<p class="empty">No tmux sessions found.<br>Create one with <code>tmux new -s mysession</code></p>`
|
|
318
|
+
: "";
|
|
319
|
+
return /* html */ `<!DOCTYPE html>
|
|
320
|
+
<html lang="en">
|
|
321
|
+
<head>
|
|
322
|
+
<meta charset="UTF-8" />
|
|
323
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
324
|
+
<title>tmux-web</title>
|
|
325
|
+
<style>
|
|
326
|
+
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap');
|
|
327
|
+
:root {
|
|
328
|
+
--page-bg: #111111;
|
|
329
|
+
--page-fg: #d0d0d0;
|
|
330
|
+
--panel-bg: #11161d;
|
|
331
|
+
--panel-border: #243241;
|
|
332
|
+
--panel-muted: #8a97a6;
|
|
333
|
+
--panel-accent: #f3f7fb;
|
|
334
|
+
--panel-success: #73c991;
|
|
335
|
+
}
|
|
336
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
337
|
+
html, body { background: var(--page-bg); color: var(--page-fg); min-height: 100%; font-family: 'JetBrains Mono', 'SF Mono', 'Menlo', monospace; }
|
|
338
|
+
.container { max-width: 520px; margin: 80px auto; padding: 0 20px; }
|
|
339
|
+
h1 { font-size: 18px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--panel-accent); margin-bottom: 32px; }
|
|
340
|
+
.session-row {
|
|
341
|
+
display: flex; justify-content: space-between; align-items: center;
|
|
342
|
+
padding: 12px 16px; border: 1px solid var(--panel-border); border-radius: 8px;
|
|
343
|
+
margin-bottom: 8px; text-decoration: none; color: var(--page-fg);
|
|
344
|
+
background: var(--panel-bg); transition: border-color 0.15s;
|
|
345
|
+
}
|
|
346
|
+
.session-row:hover { border-color: var(--panel-success); }
|
|
347
|
+
.session-row .name { font-size: 14px; font-weight: 500; color: var(--panel-accent); }
|
|
348
|
+
.session-row .meta { font-size: 11px; color: var(--panel-muted); }
|
|
349
|
+
.empty { font-size: 13px; color: var(--panel-muted); line-height: 1.6; }
|
|
350
|
+
.empty code { background: rgba(255,255,255,0.06); padding: 2px 6px; border-radius: 4px; font-size: 12px; }
|
|
351
|
+
.refresh { display: inline-block; margin-top: 24px; font-size: 12px; color: var(--panel-muted); text-decoration: none; border: 1px solid var(--panel-border); padding: 6px 14px; border-radius: 6px; }
|
|
352
|
+
.refresh:hover { border-color: var(--panel-accent); color: var(--panel-accent); }
|
|
353
|
+
</style>
|
|
354
|
+
</head>
|
|
355
|
+
<body>
|
|
356
|
+
<div class="container">
|
|
357
|
+
<h1>tmux sessions</h1>
|
|
358
|
+
${rows}
|
|
359
|
+
${empty}
|
|
360
|
+
<a href="/" class="refresh">refresh</a>
|
|
361
|
+
</div>
|
|
362
|
+
</body>
|
|
363
|
+
</html>`;
|
|
364
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { serve } from "@hono/node-server";
|
|
4
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
5
|
+
import * as pty from "node-pty";
|
|
6
|
+
import { listSessions } from "./sessions.js";
|
|
7
|
+
import { renderLanding, renderTerminal } from "./frontend.js";
|
|
8
|
+
const activePtys = new Set();
|
|
9
|
+
const app = new Hono();
|
|
10
|
+
app.get("/", (c) => {
|
|
11
|
+
const sessions = listSessions();
|
|
12
|
+
return c.html(renderLanding(sessions));
|
|
13
|
+
});
|
|
14
|
+
app.get("/s/:session", (c) => {
|
|
15
|
+
const session = decodeURIComponent(c.req.param("session"));
|
|
16
|
+
return c.html(renderTerminal(session));
|
|
17
|
+
});
|
|
18
|
+
const port = parseInt(process.env.PORT || "3000", 10);
|
|
19
|
+
const server = serve({ fetch: app.fetch, port }, (info) => {
|
|
20
|
+
console.log(`tmux-web running at http://localhost:${info.port}`);
|
|
21
|
+
});
|
|
22
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
23
|
+
server.on("upgrade", (req, socket, head) => {
|
|
24
|
+
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
25
|
+
const match = url.pathname.match(/^\/ws\/(.+)$/);
|
|
26
|
+
if (!match) {
|
|
27
|
+
socket.destroy();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
31
|
+
wss.emit("connection", ws, req, decodeURIComponent(match[1]));
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
wss.on("connection", (ws, _req, sessionName) => {
|
|
35
|
+
let ptyProcess = null;
|
|
36
|
+
try {
|
|
37
|
+
ptyProcess = pty.spawn("tmux", ["attach-session", "-t", sessionName], {
|
|
38
|
+
name: "xterm-256color",
|
|
39
|
+
cols: 80,
|
|
40
|
+
rows: 24,
|
|
41
|
+
cwd: process.env.HOME || "/",
|
|
42
|
+
env: process.env,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
ws.send(`\r\n\x1b[31mFailed to attach to tmux session "${sessionName}": ${err.message}\x1b[0m\r\n`);
|
|
47
|
+
ws.close(1011, "pty spawn failed");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
activePtys.add(ptyProcess);
|
|
51
|
+
ptyProcess.onData((data) => {
|
|
52
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
53
|
+
ws.send(data);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
57
|
+
if (ptyProcess)
|
|
58
|
+
activePtys.delete(ptyProcess);
|
|
59
|
+
ptyProcess = null;
|
|
60
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
61
|
+
ws.send(`\r\n\x1b[2m--- tmux exited (code ${exitCode}) ---\x1b[0m\r\n`);
|
|
62
|
+
ws.close(1000, "pty exited");
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
ws.on("message", (raw) => {
|
|
66
|
+
if (!ptyProcess)
|
|
67
|
+
return;
|
|
68
|
+
const data = typeof raw === "string" ? raw : raw.toString("utf-8");
|
|
69
|
+
let msg;
|
|
70
|
+
try {
|
|
71
|
+
msg = JSON.parse(data);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (msg.type === "input" && typeof msg.data === "string") {
|
|
77
|
+
ptyProcess.write(msg.data);
|
|
78
|
+
}
|
|
79
|
+
else if (msg.type === "resize" &&
|
|
80
|
+
typeof msg.cols === "number" &&
|
|
81
|
+
typeof msg.rows === "number") {
|
|
82
|
+
ptyProcess.resize(Math.max(10, msg.cols), Math.max(5, msg.rows));
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
ws.on("close", () => {
|
|
86
|
+
if (ptyProcess) {
|
|
87
|
+
activePtys.delete(ptyProcess);
|
|
88
|
+
ptyProcess.kill();
|
|
89
|
+
ptyProcess = null;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
ws.on("error", () => {
|
|
93
|
+
if (ptyProcess) {
|
|
94
|
+
activePtys.delete(ptyProcess);
|
|
95
|
+
ptyProcess.kill();
|
|
96
|
+
ptyProcess = null;
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
function cleanup() {
|
|
101
|
+
for (const p of activePtys) {
|
|
102
|
+
try {
|
|
103
|
+
p.kill();
|
|
104
|
+
}
|
|
105
|
+
catch { }
|
|
106
|
+
}
|
|
107
|
+
activePtys.clear();
|
|
108
|
+
process.exit(0);
|
|
109
|
+
}
|
|
110
|
+
process.on("SIGINT", cleanup);
|
|
111
|
+
process.on("SIGTERM", cleanup);
|
package/dist/sessions.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
export function listSessions() {
|
|
3
|
+
try {
|
|
4
|
+
const output = execFileSync("tmux", ["list-sessions", "-F", "#{session_name}\t#{session_windows}\t#{session_attached}"], { encoding: "utf-8", timeout: 3000 });
|
|
5
|
+
return output
|
|
6
|
+
.trim()
|
|
7
|
+
.split("\n")
|
|
8
|
+
.filter(Boolean)
|
|
9
|
+
.map((line) => {
|
|
10
|
+
const [name, windows, attached] = line.split("\t");
|
|
11
|
+
return {
|
|
12
|
+
name,
|
|
13
|
+
windows: parseInt(windows, 10),
|
|
14
|
+
attached: attached !== "0",
|
|
15
|
+
};
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tmux-web",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Access tmux sessions from your browser via a web-based terminal",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tmux-web": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"postinstall": "find node_modules -path '*/node-pty/prebuilds/*/spawn-helper' -exec chmod +x {} + 2>/dev/null; true",
|
|
14
|
+
"prepublishOnly": "tsc",
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"start": "node dist/index.js",
|
|
17
|
+
"dev": "npx tsx src/index.ts"
|
|
18
|
+
},
|
|
19
|
+
"keywords": ["tmux", "terminal", "web", "pty", "websocket"],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/ashutoshpw/tmux-web"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@hono/node-server": "^1.19.12",
|
|
30
|
+
"hono": "^4.7.0",
|
|
31
|
+
"node-pty": "^1.0.0",
|
|
32
|
+
"ws": "^8.18.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^22.0.0",
|
|
36
|
+
"@types/ws": "^8.5.0",
|
|
37
|
+
"tsx": "^4.21.0",
|
|
38
|
+
"typescript": "^5.7.0"
|
|
39
|
+
}
|
|
40
|
+
}
|