termbeam 1.1.0 → 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 +1 -0
- package/package.json +1 -1
- package/public/terminal.html +124 -1
- package/src/preview.js +112 -0
- package/src/routes.js +20 -0
- package/src/server.js +29 -2
- package/src/tunnel.js +20 -14
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
package/public/terminal.html
CHANGED
|
@@ -939,7 +939,8 @@
|
|
|
939
939
|
background: var(--overlay-bg);
|
|
940
940
|
z-index: 200;
|
|
941
941
|
justify-content: center;
|
|
942
|
-
align-items:
|
|
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
|
@@ -12,7 +12,8 @@ const { createAuth } = require('./auth');
|
|
|
12
12
|
const { SessionManager } = require('./sessions');
|
|
13
13
|
const { setupRoutes, cleanupUploadedFiles } = require('./routes');
|
|
14
14
|
const { setupWebSocket } = require('./websocket');
|
|
15
|
-
const { startTunnel, cleanupTunnel } = require('./tunnel');
|
|
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((
|
|
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
|
|
|
@@ -75,6 +79,29 @@ function createTermBeamServer(overrides = {}) {
|
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
function start() {
|
|
82
|
+
// Fail early if tunnel mode is on but devtunnel CLI is not installed
|
|
83
|
+
if (config.useTunnel && !findDevtunnel()) {
|
|
84
|
+
log.error('❌ devtunnel CLI is not installed.');
|
|
85
|
+
log.error('');
|
|
86
|
+
log.error(' TermBeam uses tunnels by default for remote access.');
|
|
87
|
+
log.error(' Install the Azure Dev Tunnels CLI, or use --no-tunnel for LAN-only mode.');
|
|
88
|
+
log.error('');
|
|
89
|
+
log.error(' Install it:');
|
|
90
|
+
log.error(' Windows: winget install Microsoft.devtunnel');
|
|
91
|
+
log.error(
|
|
92
|
+
' or: Invoke-WebRequest -Uri https://aka.ms/TunnelsCliDownload/win-x64 -OutFile devtunnel.exe',
|
|
93
|
+
);
|
|
94
|
+
log.error(' macOS: brew install --cask devtunnel');
|
|
95
|
+
log.error(' Linux: curl -sL https://aka.ms/DevTunnelCliInstall | bash');
|
|
96
|
+
log.error('');
|
|
97
|
+
log.error(' Then restart your terminal and try again.');
|
|
98
|
+
log.error(
|
|
99
|
+
' Docs: https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/get-started',
|
|
100
|
+
);
|
|
101
|
+
log.error('');
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
|
|
78
105
|
return new Promise((resolve) => {
|
|
79
106
|
server.listen(config.port, config.host, async () => {
|
|
80
107
|
const ip = getLocalIP();
|
package/src/tunnel.js
CHANGED
|
@@ -89,7 +89,8 @@ async function startTunnel(port, options = {}) {
|
|
|
89
89
|
if (!found) {
|
|
90
90
|
log.error('❌ devtunnel CLI is not installed.');
|
|
91
91
|
log.error('');
|
|
92
|
-
log.error('
|
|
92
|
+
log.error(' TermBeam uses tunnels by default for remote access.');
|
|
93
|
+
log.error(' Install the Azure Dev Tunnels CLI, or use --no-tunnel for LAN-only mode.');
|
|
93
94
|
log.error('');
|
|
94
95
|
log.error(' Install it:');
|
|
95
96
|
log.error(' Windows: winget install Microsoft.devtunnel');
|
|
@@ -120,19 +121,24 @@ async function startTunnel(port, options = {}) {
|
|
|
120
121
|
} catch {}
|
|
121
122
|
|
|
122
123
|
if (!loggedIn) {
|
|
123
|
-
log.info('devtunnel not logged in, launching login...');
|
|
124
|
-
log.info('A browser window will open for authentication.');
|
|
124
|
+
log.info('devtunnel not logged in, launching browser login (30s timeout)...');
|
|
125
125
|
try {
|
|
126
|
-
execFileSync(devtunnelCmd, ['user', 'login'], { stdio: 'inherit' });
|
|
127
|
-
} catch
|
|
128
|
-
log.
|
|
129
|
-
log.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
126
|
+
execFileSync(devtunnelCmd, ['user', 'login'], { stdio: 'inherit', timeout: 30000 });
|
|
127
|
+
} catch {
|
|
128
|
+
log.info('Browser login failed or unavailable, falling back to device code flow...');
|
|
129
|
+
log.info('A code will be displayed — open the URL on any device to authenticate.');
|
|
130
|
+
try {
|
|
131
|
+
execFileSync(devtunnelCmd, ['user', 'login', '-d'], { stdio: 'inherit' });
|
|
132
|
+
} catch (loginErr) {
|
|
133
|
+
log.error('');
|
|
134
|
+
log.error(' DevTunnel login failed. To use tunnels, run:');
|
|
135
|
+
log.error(' devtunnel user login');
|
|
136
|
+
log.error('');
|
|
137
|
+
log.error(' Or start without a tunnel:');
|
|
138
|
+
log.error(' termbeam --no-tunnel');
|
|
139
|
+
log.error('');
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
136
142
|
}
|
|
137
143
|
}
|
|
138
144
|
|
|
@@ -257,4 +263,4 @@ function cleanupTunnel() {
|
|
|
257
263
|
}
|
|
258
264
|
}
|
|
259
265
|
|
|
260
|
-
module.exports = { startTunnel, cleanupTunnel };
|
|
266
|
+
module.exports = { startTunnel, cleanupTunnel, findDevtunnel };
|