termbeam 1.10.0 → 1.10.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 CHANGED
@@ -109,6 +109,7 @@ flowchart LR
109
109
  | `--port <port>` | Server port | `3456` |
110
110
  | `--host <addr>` | Bind address | `127.0.0.1` |
111
111
  | `--lan` | Bind to all interfaces (LAN access) | Off |
112
+ | `--public` | Allow public tunnel access (no Microsoft login) | Off |
112
113
  | `-i, --interactive` | Interactive setup wizard | Off |
113
114
  | `--log-level <level>` | Log verbosity (error/warn/info/debug) | `info` |
114
115
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termbeam",
3
- "version": "1.10.0",
3
+ "version": "1.10.1",
4
4
  "description": "Beam your terminal to any device — mobile-optimized web terminal with multi-session support",
5
5
  "main": "src/server.js",
6
6
  "bin": {
package/public/index.html CHANGED
@@ -991,17 +991,22 @@
991
991
  }
992
992
 
993
993
  async function loadSessions() {
994
- const res = await fetch('/api/sessions');
995
- const sessions = await res.json();
994
+ try {
995
+ const res = await fetch('/api/sessions');
996
+ if (!res.ok) {
997
+ console.error(`Failed to load sessions: ${res.status}`);
998
+ return;
999
+ }
1000
+ const sessions = await res.json();
996
1001
 
997
- if (sessions.length === 0) {
998
- listEl.innerHTML = '<div class="empty-state">No active sessions</div>';
999
- return;
1000
- }
1002
+ if (sessions.length === 0) {
1003
+ listEl.innerHTML = '<div class="empty-state">No active sessions</div>';
1004
+ return;
1005
+ }
1001
1006
 
1002
- listEl.innerHTML = sessions
1003
- .map(
1004
- (s) => `
1007
+ listEl.innerHTML = sessions
1008
+ .map(
1009
+ (s) => `
1005
1010
  <div class="swipe-wrap" data-session-id="${esc(s.id)}">
1006
1011
  <div class="swipe-delete">
1007
1012
  <button data-delete-id="${esc(s.id)}"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg><span>Delete</span></button>
@@ -1031,22 +1036,25 @@
1031
1036
  </div>
1032
1037
  </div>
1033
1038
  `,
1034
- )
1035
- .join('');
1039
+ )
1040
+ .join('');
1036
1041
 
1037
- // Attach swipe handlers and click handlers after rendering
1038
- listEl.querySelectorAll('.swipe-wrap').forEach(initSwipe);
1039
- listEl.querySelectorAll('[data-delete-id]').forEach((btn) => {
1040
- btn.addEventListener('click', (e) => deleteSession(btn.dataset.deleteId, e));
1041
- });
1042
- listEl.querySelectorAll('[data-nav-id]').forEach((card) => {
1043
- card.addEventListener('click', () => {
1044
- location.href = '/terminal?id=' + encodeURIComponent(card.dataset.navId);
1042
+ // Attach swipe handlers and click handlers after rendering
1043
+ listEl.querySelectorAll('.swipe-wrap').forEach(initSwipe);
1044
+ listEl.querySelectorAll('[data-delete-id]').forEach((btn) => {
1045
+ btn.addEventListener('click', (e) => deleteSession(btn.dataset.deleteId, e));
1045
1046
  });
1046
- });
1047
- listEl.querySelectorAll('.dot[data-color]').forEach((dot) => {
1048
- dot.style.background = dot.dataset.color || 'var(--success)';
1049
- });
1047
+ listEl.querySelectorAll('[data-nav-id]').forEach((card) => {
1048
+ card.addEventListener('click', () => {
1049
+ location.href = '/terminal?id=' + encodeURIComponent(card.dataset.navId);
1050
+ });
1051
+ });
1052
+ listEl.querySelectorAll('.dot[data-color]').forEach((dot) => {
1053
+ dot.style.background = dot.dataset.color || 'var(--success)';
1054
+ });
1055
+ } catch (err) {
1056
+ console.error('Failed to load sessions:', err);
1057
+ }
1050
1058
  }
1051
1059
 
1052
1060
  document.getElementById('new-session-btn').addEventListener('click', () => {
@@ -1085,13 +1093,21 @@
1085
1093
  if (initialCommand) body.initialCommand = initialCommand;
1086
1094
  if (color) body.color = color;
1087
1095
 
1088
- const res = await fetch('/api/sessions', {
1089
- method: 'POST',
1090
- headers: { 'Content-Type': 'application/json' },
1091
- body: JSON.stringify(body),
1092
- });
1093
- const data = await res.json();
1094
- location.href = data.url;
1096
+ try {
1097
+ const res = await fetch('/api/sessions', {
1098
+ method: 'POST',
1099
+ headers: { 'Content-Type': 'application/json' },
1100
+ body: JSON.stringify(body),
1101
+ });
1102
+ if (!res.ok) {
1103
+ console.error(`Failed to create session: ${res.status}`);
1104
+ return;
1105
+ }
1106
+ const data = await res.json();
1107
+ location.href = data.url;
1108
+ } catch (err) {
1109
+ console.error('Failed to create session:', err);
1110
+ }
1095
1111
  });
1096
1112
 
1097
1113
  // --- Shell detection ---
@@ -1101,6 +1117,7 @@
1101
1117
  const shellSelect = document.getElementById('sess-shell');
1102
1118
  try {
1103
1119
  const res = await fetch('/api/shells');
1120
+ if (!res.ok) throw new Error(`Failed to load shells: ${res.status}`);
1104
1121
  const data = await res.json();
1105
1122
  if (data.cwd) {
1106
1123
  document.getElementById('sess-cwd').placeholder = data.cwd;
@@ -1225,7 +1242,10 @@
1225
1242
  document.getElementById('browse-btn').addEventListener('click', async () => {
1226
1243
  if (hubServerCwd === '/') {
1227
1244
  try {
1228
- const data = await fetch('/api/shells').then((r) => r.json());
1245
+ const data = await fetch('/api/shells').then((r) => {
1246
+ if (!r.ok) throw new Error(`${r.status}`);
1247
+ return r.json();
1248
+ });
1229
1249
  if (data.cwd) hubServerCwd = data.cwd;
1230
1250
  } catch {}
1231
1251
  }
@@ -1254,6 +1274,7 @@
1254
1274
 
1255
1275
  try {
1256
1276
  const res = await fetch(`/api/dirs?q=${encodeURIComponent(dir + '/')}`);
1277
+ if (!res.ok) throw new Error(`Failed to load directories: ${res.status}`);
1257
1278
  const data = await res.json();
1258
1279
  let items = '';
1259
1280
  // Add parent (..) entry unless at root
@@ -1310,7 +1331,10 @@
1310
1331
 
1311
1332
  // Fetch version
1312
1333
  fetch('/api/version')
1313
- .then((r) => r.json())
1334
+ .then((r) => {
1335
+ if (!r.ok) throw new Error(`${r.status}`);
1336
+ return r.json();
1337
+ })
1314
1338
  .then((d) => {
1315
1339
  document.getElementById('version').textContent = 'v' + d.version;
1316
1340
  })
@@ -2476,7 +2476,14 @@
2476
2476
 
2477
2477
  // ===== Init =====
2478
2478
  async function init() {
2479
- const sessionList = await fetch('/api/sessions').then((r) => r.json());
2479
+ let sessionList = [];
2480
+ try {
2481
+ const res = await fetch('/api/sessions');
2482
+ if (!res.ok) throw new Error(`${res.status}`);
2483
+ sessionList = await res.json();
2484
+ } catch (err) {
2485
+ console.error('Failed to load sessions:', err);
2486
+ }
2480
2487
  const initialId = new URLSearchParams(location.search).get('id');
2481
2488
 
2482
2489
  for (const s of sessionList) addSession(s);
@@ -2678,7 +2685,10 @@
2678
2685
 
2679
2686
  // Version
2680
2687
  fetch('/api/version')
2681
- .then((r) => r.json())
2688
+ .then((r) => {
2689
+ if (!r.ok) throw new Error(`${r.status}`);
2690
+ return r.json();
2691
+ })
2682
2692
  .then((d) => {
2683
2693
  window._termbeamVersion = 'v' + d.version;
2684
2694
  document.getElementById('side-panel-version').textContent = 'v' + d.version;
@@ -3884,6 +3894,7 @@
3884
3894
  const res = await fetch('/api/dirs?q=' + encodeURIComponent(q ? q + '/' : ''), {
3885
3895
  credentials: 'same-origin',
3886
3896
  });
3897
+ if (!res.ok) throw new Error(`Failed to browse directories: ${res.status}`);
3887
3898
  const data = await res.json();
3888
3899
  if (!data.dirs || !data.dirs.length) return;
3889
3900
  browseDropdown = document.createElement('div');
@@ -3997,7 +4008,10 @@
3997
4008
  document.getElementById('ns-browse-btn').addEventListener('click', async () => {
3998
4009
  if (serverCwd === '/') {
3999
4010
  try {
4000
- const data = await fetch('/api/shells').then((r) => r.json());
4011
+ const data = await fetch('/api/shells').then((r) => {
4012
+ if (!r.ok) throw new Error(`${r.status}`);
4013
+ return r.json();
4014
+ });
4001
4015
  if (data.cwd) serverCwd = data.cwd;
4002
4016
  } catch {}
4003
4017
  }
@@ -4025,6 +4039,7 @@
4025
4039
  nsBrowserList.innerHTML = '<div class="browser-empty">Loading…</div>';
4026
4040
  try {
4027
4041
  const res = await fetch(`/api/dirs?q=${encodeURIComponent(dir + '/')}`);
4042
+ if (!res.ok) throw new Error(`Failed to load directories: ${res.status}`);
4028
4043
  const data = await res.json();
4029
4044
  let items = '';
4030
4045
  // Add parent (..) entry unless at root
@@ -4082,7 +4097,10 @@
4082
4097
  if (shellsLoaded) return;
4083
4098
  const sel = document.getElementById('ns-shell');
4084
4099
  try {
4085
- const data = await fetch('/api/shells').then((r) => r.json());
4100
+ const data = await fetch('/api/shells').then((r) => {
4101
+ if (!r.ok) throw new Error(`${r.status}`);
4102
+ return r.json();
4103
+ });
4086
4104
  if (data.cwd) {
4087
4105
  serverCwd = data.cwd;
4088
4106
  document.getElementById('ns-cwd').placeholder = data.cwd;
@@ -4140,10 +4158,16 @@
4140
4158
  headers: { 'Content-Type': 'application/json' },
4141
4159
  body: JSON.stringify(body),
4142
4160
  });
4161
+ if (!res.ok) {
4162
+ console.error(`Failed to create session: ${res.status}`);
4163
+ return;
4164
+ }
4143
4165
  const data = await res.json();
4144
4166
 
4145
4167
  // Fetch full session list to get the new session data
4146
- const list = await fetch('/api/sessions').then((r) => r.json());
4168
+ const listRes = await fetch('/api/sessions');
4169
+ if (!listRes.ok) throw new Error(`Failed to list sessions: ${listRes.status}`);
4170
+ const list = await listRes.json();
4147
4171
  const newSession = list.find((s) => s.id === data.id);
4148
4172
  if (newSession) {
4149
4173
  addSession(newSession);
@@ -4163,7 +4187,9 @@
4163
4187
  function startPolling() {
4164
4188
  setInterval(async () => {
4165
4189
  try {
4166
- const list = await fetch('/api/sessions').then((r) => r.json());
4190
+ const pollRes = await fetch('/api/sessions');
4191
+ if (!pollRes.ok) throw new Error(`${pollRes.status}`);
4192
+ const list = await pollRes.json();
4167
4193
  const serverIds = new Set(list.map((s) => s.id));
4168
4194
 
4169
4195
  // Add new sessions created elsewhere
package/src/auth.js CHANGED
@@ -177,7 +177,7 @@ function createAuth(password) {
177
177
  const shareTokens = new Map(); // share tokens: token -> expiry
178
178
 
179
179
  // Periodically clean up expired tokens and stale rate-limit entries
180
- setInterval(
180
+ const cleanupInterval = setInterval(
181
181
  () => {
182
182
  const now = Date.now();
183
183
  for (const [token, expiry] of tokens) {
@@ -296,6 +296,7 @@ function createAuth(password) {
296
296
  rateLimit,
297
297
  parseCookies,
298
298
  loginHTML: LOGIN_HTML,
299
+ cleanup: () => clearInterval(cleanupInterval),
299
300
  };
300
301
  }
301
302
 
package/src/cli.js CHANGED
@@ -270,6 +270,10 @@ function parseArgs() {
270
270
  publicTunnel = true;
271
271
  } else if (args[i].startsWith('--password=')) {
272
272
  password = args[i].split('=')[1];
273
+ if (!password) {
274
+ console.error('Error: --password= requires a non-empty value\n');
275
+ process.exit(1);
276
+ }
273
277
  explicitPassword = true;
274
278
  } else if (args[i] === '--help' || args[i] === '-h') {
275
279
  printHelp();
@@ -286,6 +290,10 @@ function parseArgs() {
286
290
  explicitPassword = true;
287
291
  } else if (args[i] === '--port' && args[i + 1]) {
288
292
  port = parseInt(args[++i], 10);
293
+ if (!Number.isFinite(port) || port < 1 || port > 65535) {
294
+ console.error('Error: --port must be a number between 1 and 65535\n');
295
+ process.exit(1);
296
+ }
289
297
  } else if (args[i] === '--lan') {
290
298
  host = '0.0.0.0';
291
299
  } else if (args[i] === '--host' && args[i + 1]) {
@@ -307,6 +315,12 @@ function parseArgs() {
307
315
  }
308
316
  }
309
317
 
318
+ const validLogLevels = ['error', 'warn', 'info', 'debug'];
319
+ if (!validLogLevels.includes(logLevel)) {
320
+ console.error(`Error: --log-level must be one of: ${validLogLevels.join(', ')}\n`);
321
+ process.exit(1);
322
+ }
323
+
310
324
  // Default: auto-generate password if none specified
311
325
  if (!explicitPassword && !password) {
312
326
  password = crypto.randomBytes(16).toString('base64url');
package/src/client.js CHANGED
@@ -80,7 +80,7 @@ function createTerminalClient({
80
80
  try {
81
81
  msg = JSON.parse(raw);
82
82
  } catch {
83
- return;
83
+ return; // Silently drop unparseable messages from server
84
84
  }
85
85
 
86
86
  if (msg.type === 'auth_ok') {
package/src/preview.js CHANGED
@@ -96,7 +96,7 @@ function createPreviewProxy() {
96
96
  proxyReq.on('error', (err) => {
97
97
  log.warn(`Preview proxy error (port ${port}): ${err.message}`);
98
98
  if (!res.headersSent) {
99
- res.status(502).json({ error: `Bad gateway: ${err.message}` });
99
+ res.status(502).json({ error: 'Bad gateway: upstream server is not responding' });
100
100
  }
101
101
  });
102
102
 
package/src/routes.js CHANGED
@@ -185,9 +185,10 @@ function setupRoutes(app, { auth, sessions, config, state }) {
185
185
  rows: typeof rows === 'number' && rows > 0 && rows <= 200 ? Math.floor(rows) : undefined,
186
186
  });
187
187
  } catch (err) {
188
- return res.status(400).json({ error: err.message || 'Failed to create session' });
188
+ log.warn(`Session creation failed: ${err.message}`);
189
+ return res.status(400).json({ error: 'Failed to create session' });
189
190
  }
190
- res.json({ id, url: `/terminal?id=${id}` });
191
+ res.status(201).json({ id, url: `/terminal?id=${id}` });
191
192
  });
192
193
 
193
194
  // Available shells
@@ -220,7 +221,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
220
221
 
221
222
  app.delete('/api/sessions/:id', auth.middleware, (req, res) => {
222
223
  if (sessions.delete(req.params.id)) {
223
- res.json({ ok: true });
224
+ res.status(204).end();
224
225
  } else {
225
226
  res.status(404).json({ error: 'not found' });
226
227
  }
@@ -288,7 +289,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
288
289
  fs.writeFileSync(filepath, buffer);
289
290
  uploadedFiles.set(id, filepath);
290
291
  log.info(`Upload: ${filename} (${buffer.length} bytes)`);
291
- res.json({ id, url: `/uploads/${id}`, path: filepath });
292
+ res.status(201).json({ id, url: `/uploads/${id}`, path: filepath });
292
293
  });
293
294
 
294
295
  req.on('error', (err) => {
@@ -405,7 +406,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
405
406
  }
406
407
  const finalName = path.basename(destPath);
407
408
  log.info(`File upload: ${finalName} → ${targetDir} (${buffer.length} bytes)`);
408
- res.json({ name: finalName, path: destPath, size: buffer.length });
409
+ res.status(201).json({ name: finalName, path: destPath, size: buffer.length });
409
410
  });
410
411
 
411
412
  req.on('error', (err) => {
package/src/server.js CHANGED
@@ -83,10 +83,14 @@ function createTermBeamServer(overrides = {}) {
83
83
  function shutdown() {
84
84
  if (shuttingDown) return;
85
85
  shuttingDown = true;
86
+ auth.cleanup();
86
87
  sessions.shutdown();
87
88
  cleanupUploadedFiles();
88
89
  cleanupTunnel();
89
90
  removeConnectionConfig();
91
+ for (const client of wss.clients) {
92
+ client.close(1001, 'Server shutting down');
93
+ }
90
94
  server.close();
91
95
  wss.close();
92
96
  }
package/src/sessions.js CHANGED
@@ -1,30 +1,10 @@
1
1
  const crypto = require('crypto');
2
2
  const path = require('path');
3
- const { execSync, exec } = require('child_process');
4
- const fs = require('fs');
3
+ const { exec } = require('child_process');
5
4
  const pty = require('node-pty');
6
5
  const log = require('./logger');
7
6
  const { getGitInfo } = require('./git');
8
7
 
9
- function _getProcessCwd(pid) {
10
- try {
11
- if (process.platform === 'linux') {
12
- return fs.readlinkSync(`/proc/${pid}/cwd`);
13
- }
14
- if (process.platform === 'darwin') {
15
- const out = execSync(`lsof -a -p ${pid} -d cwd -Fn`, {
16
- stdio: 'pipe',
17
- timeout: 2000,
18
- }).toString();
19
- const match = out.match(/\nn(.+)/);
20
- if (match) return match[1];
21
- }
22
- } catch {
23
- /* process may have exited */
24
- }
25
- return null;
26
- }
27
-
28
8
  // Cache git info per session to avoid blocking the event loop on every list() call.
29
9
  // lsof + git commands take ~200-500ms and block WebSocket traffic, causing
30
10
  // xterm.js cursor position report responses to leak as visible text.
@@ -69,6 +49,7 @@ function scheduleGitRefresh(sessionId, pid, originalCwd) {
69
49
  }
70
50
 
71
51
  exec(cmd, { timeout: 2000 }, (err, stdout) => {
52
+ if (err) log.debug(`Git cwd detection failed: ${err.message}`);
72
53
  let liveCwd = originalCwd;
73
54
  if (!err && stdout) {
74
55
  if (process.platform === 'darwin') {
package/src/tunnel.js CHANGED
@@ -67,21 +67,6 @@ function savePersistedTunnel(id) {
67
67
  );
68
68
  }
69
69
 
70
- function _deletePersisted() {
71
- const persisted = loadPersistedTunnel();
72
- if (persisted) {
73
- try {
74
- if (SAFE_ID_RE.test(persisted.tunnelId)) {
75
- execFileSync(devtunnelCmd, ['delete', persisted.tunnelId, '-f'], { stdio: 'pipe' });
76
- log.info(`Deleted persisted tunnel ${persisted.tunnelId}`);
77
- }
78
- } catch {}
79
- try {
80
- fs.unlinkSync(TUNNEL_CONFIG_PATH);
81
- } catch {}
82
- }
83
- }
84
-
85
70
  function isTunnelValid(id) {
86
71
  try {
87
72
  if (!SAFE_ID_RE.test(id)) return false;
package/src/websocket.js CHANGED
@@ -17,6 +17,10 @@ function recalcPtySize(session) {
17
17
  }
18
18
 
19
19
  function setupWebSocket(wss, { auth, sessions }) {
20
+ const wsAuthAttempts = new Map(); // ip -> [timestamps]
21
+ const WS_AUTH_WINDOW = 60 * 1000; // 1 minute
22
+ const WS_MAX_AUTH_ATTEMPTS = 5;
23
+
20
24
  wss.on('connection', (ws, req) => {
21
25
  const origin = req.headers.origin;
22
26
  if (origin) {
@@ -51,11 +55,26 @@ function setupWebSocket(wss, { auth, sessions }) {
51
55
  const msg = JSON.parse(raw);
52
56
 
53
57
  if (msg.type === 'auth') {
58
+ const ip = req.socket.remoteAddress;
59
+ const now = Date.now();
60
+ const attempts = (wsAuthAttempts.get(ip) || []).filter((t) => now - t < WS_AUTH_WINDOW);
61
+
62
+ if (attempts.length >= WS_MAX_AUTH_ATTEMPTS) {
63
+ log.warn(`WS: rate limit exceeded for ${ip}`);
64
+ ws.send(
65
+ JSON.stringify({ type: 'error', message: 'Too many attempts. Try again later.' }),
66
+ );
67
+ ws.close();
68
+ return;
69
+ }
70
+
54
71
  if (msg.password === auth.password || auth.validateToken(msg.token)) {
55
72
  authenticated = true;
56
73
  ws.send(JSON.stringify({ type: 'auth_ok' }));
57
74
  log.info('WS: auth success');
58
75
  } else {
76
+ attempts.push(now);
77
+ wsAuthAttempts.set(ip, attempts);
59
78
  log.warn('WS: auth failed');
60
79
  ws.send(JSON.stringify({ type: 'error', message: 'Unauthorized' }));
61
80
  ws.close();