termbeam 1.1.1 → 1.2.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 CHANGED
@@ -65,6 +65,7 @@ termbeam --no-password # disable password protection
65
65
  - **QR code on startup** for instant phone connection
66
66
  - **Light/dark theme** with persistent preference
67
67
  - **Adjustable font size** via status bar controls, saved across sessions
68
+ - **Port preview** — reverse-proxy a single local web server port and preview it in the browser (HTTP only; no WebSocket/HMR; best with server-rendered apps)
68
69
  - **Remote access via [DevTunnel](#remote-access)** — ephemeral or persisted public URLs
69
70
 
70
71
  ## Remote Access
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termbeam",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
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": {
@@ -939,7 +939,8 @@
939
939
  background: var(--overlay-bg);
940
940
  z-index: 200;
941
941
  justify-content: center;
942
- align-items: center;
942
+ align-items: flex-start;
943
+ padding-top: 15vh;
943
944
  }
944
945
  .modal-overlay.visible {
945
946
  display: flex;
@@ -1405,6 +1406,14 @@
1405
1406
  <button class="bar-btn" id="zoom-out" title="Decrease font size">−</button>
1406
1407
  <button class="bar-btn" id="zoom-in" title="Increase font size">+</button>
1407
1408
  </div>
1409
+ <button
1410
+ class="bar-btn"
1411
+ id="preview-btn"
1412
+ title="Preview local port"
1413
+ onclick="openPreviewModal()"
1414
+ >
1415
+ 🌐
1416
+ </button>
1408
1417
  <button class="bar-btn" id="share-btn" title="Share link">
1409
1418
  <svg
1410
1419
  width="16"
@@ -1659,6 +1668,61 @@
1659
1668
  </div>
1660
1669
  </div>
1661
1670
 
1671
+ <!-- Preview Port Modal -->
1672
+ <div class="modal-overlay" id="preview-modal">
1673
+ <div class="modal">
1674
+ <h2>🌐 Preview Local Port</h2>
1675
+ <p
1676
+ style="
1677
+ color: var(--text-secondary);
1678
+ font-size: 13px;
1679
+ margin: -8px 0 16px;
1680
+ line-height: 1.4;
1681
+ "
1682
+ >
1683
+ Open a local server running on your machine in a new tab — works through the tunnel.
1684
+ </p>
1685
+ <label for="preview-port-input">Port</label>
1686
+ <div style="position: relative">
1687
+ <input
1688
+ type="number"
1689
+ id="preview-port-input"
1690
+ placeholder="e.g. 3000"
1691
+ min="1"
1692
+ max="65535"
1693
+ />
1694
+ <span
1695
+ id="preview-detect-status"
1696
+ style="
1697
+ position: absolute;
1698
+ right: 12px;
1699
+ top: 50%;
1700
+ transform: translateY(-50%);
1701
+ font-size: 12px;
1702
+ color: var(--text-secondary);
1703
+ "
1704
+ ></span>
1705
+ </div>
1706
+ <div
1707
+ id="preview-hint"
1708
+ style="
1709
+ font-size: 12px;
1710
+ color: var(--success);
1711
+ margin-top: 6px;
1712
+ display: none;
1713
+ align-items: center;
1714
+ gap: 4px;
1715
+ "
1716
+ >
1717
+ <span>✓</span> <span id="preview-hint-text"></span>
1718
+ </div>
1719
+ <div class="modal-actions">
1720
+ <button class="btn-cancel" id="preview-cancel">Cancel</button>
1721
+ <button class="btn-create" id="preview-open">Open Preview ↗</button>
1722
+ </div>
1723
+ </div>
1724
+ </div>
1725
+
1662
1726
  <!-- Folder Browser -->
1663
1727
  <div class="browser-overlay" id="ns-browser-overlay">
1664
1728
  <div class="browser-sheet">
@@ -1929,6 +1993,7 @@
1929
1993
  setupImagePaste();
1930
1994
  setupSelectMode();
1931
1995
  setupNewSessionModal();
1996
+ setupPreviewModal();
1932
1997
  loadShellsForModal();
1933
1998
  startPolling();
1934
1999
 
@@ -3357,6 +3422,64 @@
3357
3422
  input.select();
3358
3423
  }
3359
3424
 
3425
+ function openPreviewModal() {
3426
+ const modal = document.getElementById('preview-modal');
3427
+ const input = document.getElementById('preview-port-input');
3428
+ const status = document.getElementById('preview-detect-status');
3429
+ const hint = document.getElementById('preview-hint');
3430
+ const hintText = document.getElementById('preview-hint-text');
3431
+ input.value = '';
3432
+ hint.style.display = 'none';
3433
+ status.textContent = '';
3434
+ modal.classList.add('visible');
3435
+ input.focus();
3436
+
3437
+ if (activeId) {
3438
+ status.textContent = 'detecting…';
3439
+ fetch('/api/sessions/' + activeId + '/detect-port')
3440
+ .then((r) => (r.ok ? r.json() : null))
3441
+ .then((data) => {
3442
+ status.textContent = '';
3443
+ if (data && data.detected) {
3444
+ input.value = data.port;
3445
+ input.select();
3446
+ hintText.textContent = 'Detected port ' + data.port + ' from terminal output';
3447
+ hint.style.display = 'flex';
3448
+ }
3449
+ })
3450
+ .catch(() => {
3451
+ status.textContent = '';
3452
+ });
3453
+ }
3454
+ }
3455
+
3456
+ function submitPreview() {
3457
+ const input = document.getElementById('preview-port-input');
3458
+ const port = parseInt(input.value, 10);
3459
+ if (isNaN(port) || port < 1 || port > 65535) {
3460
+ input.style.borderColor = '#f87171';
3461
+ input.focus();
3462
+ setTimeout(() => (input.style.borderColor = ''), 1500);
3463
+ return;
3464
+ }
3465
+ window.open('/preview/' + port + '/', '_blank');
3466
+ document.getElementById('preview-modal').classList.remove('visible');
3467
+ }
3468
+
3469
+ function setupPreviewModal() {
3470
+ document.getElementById('preview-cancel').addEventListener('click', () => {
3471
+ document.getElementById('preview-modal').classList.remove('visible');
3472
+ });
3473
+ document.getElementById('preview-open').addEventListener('click', submitPreview);
3474
+ document.getElementById('preview-port-input').addEventListener('keydown', (e) => {
3475
+ if (e.key === 'Enter') submitPreview();
3476
+ });
3477
+ document.getElementById('preview-modal').addEventListener('click', (e) => {
3478
+ if (e.target.id === 'preview-modal')
3479
+ document.getElementById('preview-modal').classList.remove('visible');
3480
+ });
3481
+ }
3482
+
3360
3483
  document.getElementById('share-btn').addEventListener('click', async () => {
3361
3484
  const urlPromise = fetch('/api/share-token')
3362
3485
  .then((r) => (r.ok ? r.json() : null))
package/src/preview.js ADDED
@@ -0,0 +1,112 @@
1
+ const http = require('http');
2
+ const express = require('express');
3
+ const log = require('./logger');
4
+
5
+ const PROXY_TIMEOUT = 10_000;
6
+
7
+ // Rewrite absolute paths in HTML/CSS so they route through the proxy
8
+ function rewriteAbsolutePaths(body, prefix, isHtml) {
9
+ if (isHtml) {
10
+ // Rewrite HTML attributes: href="/...", src="/...", action="/...", etc.
11
+ body = body.replace(
12
+ /((?:href|src|action|srcset|poster|data|formaction)\s*=\s*["'])\/(?!\/|preview\/)/gi,
13
+ `$1${prefix}/`,
14
+ );
15
+ // Rewrite meta content URLs: content="/..."
16
+ body = body.replace(/(content\s*=\s*["'])\/(?!\/|preview\/)/gi, `$1${prefix}/`);
17
+ }
18
+ // Rewrite CSS url() references: url("/...") or url('/...') or url(/...)
19
+ body = body.replace(/(url\(\s*["']?)\/(?!\/|preview\/)/gi, `$1${prefix}/`);
20
+ return body;
21
+ }
22
+
23
+ function createPreviewProxy() {
24
+ const router = express.Router();
25
+
26
+ function proxyRequest(req, res) {
27
+ const port = Number(req.params.port);
28
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
29
+ return res
30
+ .status(400)
31
+ .json({ error: 'Invalid port: must be an integer between 1 and 65535' });
32
+ }
33
+
34
+ // Strip /preview/:port prefix, keep the rest (or default to /)
35
+ // Express 5 *path returns an array of segments — join them back
36
+ const segments = req.params.path;
37
+ const forwardPath = segments ? `/${[].concat(segments).join('/')}` : '/';
38
+ const search = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
39
+
40
+ const fwdHeaders = { ...req.headers, host: `127.0.0.1:${port}` };
41
+ // Request uncompressed so we can rewrite HTML content
42
+ delete fwdHeaders['accept-encoding'];
43
+
44
+ const options = {
45
+ hostname: '127.0.0.1',
46
+ port,
47
+ path: forwardPath + search,
48
+ method: req.method,
49
+ headers: fwdHeaders,
50
+ };
51
+
52
+ log.debug(`Preview proxy: ${req.method} ${forwardPath}${search} → 127.0.0.1:${port}`);
53
+
54
+ const prefix = `/preview/${port}`;
55
+
56
+ const proxyReq = http.request(options, (proxyRes) => {
57
+ const headers = { ...proxyRes.headers };
58
+
59
+ // Rewrite Location headers so redirects stay inside the proxy
60
+ if (headers.location) {
61
+ const loc = headers.location;
62
+ if (loc.startsWith('/') && !loc.startsWith(prefix)) {
63
+ headers.location = prefix + loc;
64
+ }
65
+ }
66
+
67
+ const contentType = (headers['content-type'] || '').toLowerCase();
68
+ const isHtml = contentType.includes('text/html');
69
+ const isCss = contentType.includes('text/css');
70
+
71
+ if (isHtml || isCss) {
72
+ // Buffer response to rewrite absolute paths
73
+ const chunks = [];
74
+ proxyRes.on('data', (chunk) => chunks.push(chunk));
75
+ proxyRes.on('end', () => {
76
+ let body = Buffer.concat(chunks).toString();
77
+ body = rewriteAbsolutePaths(body, prefix, isHtml);
78
+ delete headers['content-length'];
79
+ headers['transfer-encoding'] = 'chunked';
80
+ res.writeHead(proxyRes.statusCode, headers);
81
+ res.end(body);
82
+ });
83
+ } else {
84
+ res.writeHead(proxyRes.statusCode, headers);
85
+ proxyRes.pipe(res);
86
+ }
87
+ });
88
+
89
+ proxyReq.setTimeout(PROXY_TIMEOUT, () => {
90
+ proxyReq.destroy();
91
+ if (!res.headersSent) {
92
+ res.status(504).json({ error: 'Gateway timeout: upstream server did not respond in time' });
93
+ }
94
+ });
95
+
96
+ proxyReq.on('error', (err) => {
97
+ log.warn(`Preview proxy error (port ${port}): ${err.message}`);
98
+ if (!res.headersSent) {
99
+ res.status(502).json({ error: `Bad gateway: ${err.message}` });
100
+ }
101
+ });
102
+
103
+ req.pipe(proxyReq);
104
+ }
105
+
106
+ router.all('/:port', proxyRequest);
107
+ router.all('/:port/*path', proxyRequest);
108
+
109
+ return router;
110
+ }
111
+
112
+ module.exports = { createPreviewProxy };
package/src/routes.js CHANGED
@@ -132,6 +132,26 @@ function setupRoutes(app, { auth, sessions, config, state }) {
132
132
  res.json({ shells, default: config.defaultShell, cwd: config.cwd });
133
133
  });
134
134
 
135
+ app.get('/api/sessions/:id/detect-port', auth.middleware, (req, res) => {
136
+ const session = sessions.get(req.params.id);
137
+ if (!session) return res.status(404).json({ error: 'not found' });
138
+
139
+ const buf = session.scrollbackBuf || '';
140
+ const regex = /https?:\/\/(?:localhost|127\.0\.0\.1):(\d+)/g;
141
+ let lastPort = null;
142
+ let match;
143
+ while ((match = regex.exec(buf)) !== null) {
144
+ const port = parseInt(match[1], 10);
145
+ if (port >= 1 && port <= 65535) lastPort = port;
146
+ }
147
+
148
+ if (lastPort !== null) {
149
+ res.json({ detected: true, port: lastPort });
150
+ } else {
151
+ res.json({ detected: false });
152
+ }
153
+ });
154
+
135
155
  app.delete('/api/sessions/:id', auth.middleware, (req, res) => {
136
156
  if (sessions.delete(req.params.id)) {
137
157
  res.json({ ok: true });
package/src/server.js CHANGED
@@ -13,6 +13,7 @@ const { SessionManager } = require('./sessions');
13
13
  const { setupRoutes, cleanupUploadedFiles } = require('./routes');
14
14
  const { setupWebSocket } = require('./websocket');
15
15
  const { startTunnel, cleanupTunnel, findDevtunnel } = require('./tunnel');
16
+ const { createPreviewProxy } = require('./preview');
16
17
 
17
18
  // --- Helpers ---
18
19
  function getLocalIP() {
@@ -43,7 +44,9 @@ function createTermBeamServer(overrides = {}) {
43
44
  app.set('trust proxy', 'loopback');
44
45
  app.use(express.json());
45
46
  app.use(cookieParser());
46
- app.use((_req, res, next) => {
47
+ app.use((req, res, next) => {
48
+ // Don't apply TermBeam's security headers to proxied preview content
49
+ if (req.path.startsWith('/preview/')) return next();
47
50
  res.setHeader('X-Content-Type-Options', 'nosniff');
48
51
  res.setHeader('X-Frame-Options', 'DENY');
49
52
  res.setHeader('Referrer-Policy', 'no-referrer');
@@ -59,6 +62,7 @@ function createTermBeamServer(overrides = {}) {
59
62
  const wss = new WebSocketServer({ server, path: '/ws', maxPayload: 1 * 1024 * 1024 });
60
63
 
61
64
  const state = { shareBaseUrl: null };
65
+ app.use('/preview', auth.middleware, createPreviewProxy());
62
66
  setupRoutes(app, { auth, sessions, config, state });
63
67
  setupWebSocket(wss, { auth, sessions });
64
68