termbeam 0.0.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/LICENSE +21 -0
- package/README.md +123 -0
- package/bin/termbeam.js +2 -0
- package/package.json +69 -0
- package/public/index.html +779 -0
- package/public/terminal.html +490 -0
- package/src/auth.js +124 -0
- package/src/cli.js +81 -0
- package/src/routes.js +87 -0
- package/src/server.js +122 -0
- package/src/sessions.js +90 -0
- package/src/tunnel.js +69 -0
- package/src/version.js +34 -0
- package/src/websocket.js +72 -0
package/src/routes.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
|
|
6
|
+
|
|
7
|
+
function setupRoutes(app, { auth, sessions, config }) {
|
|
8
|
+
// Login page
|
|
9
|
+
app.get('/login', (_req, res) => {
|
|
10
|
+
if (!auth.password) return res.redirect('/');
|
|
11
|
+
res.send(auth.loginHTML);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// Auth API
|
|
15
|
+
app.post('/api/auth', auth.rateLimit, (req, res) => {
|
|
16
|
+
const { password } = req.body || {};
|
|
17
|
+
if (password === auth.password) {
|
|
18
|
+
const token = auth.generateToken();
|
|
19
|
+
res.cookie('pty_token', token, {
|
|
20
|
+
httpOnly: true,
|
|
21
|
+
sameSite: 'lax',
|
|
22
|
+
maxAge: 24 * 60 * 60 * 1000,
|
|
23
|
+
secure: false,
|
|
24
|
+
});
|
|
25
|
+
res.json({ ok: true });
|
|
26
|
+
} else {
|
|
27
|
+
res.status(401).json({ error: 'wrong password' });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Version API
|
|
32
|
+
app.get('/api/version', (_req, res) => {
|
|
33
|
+
const { getVersion } = require('./version');
|
|
34
|
+
res.json({ version: getVersion() });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Pages
|
|
38
|
+
app.get('/', auth.middleware, (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'index.html')));
|
|
39
|
+
app.get('/terminal', auth.middleware, (_req, res) =>
|
|
40
|
+
res.sendFile(path.join(PUBLIC_DIR, 'terminal.html')),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
// Session API
|
|
44
|
+
app.get('/api/sessions', auth.middleware, (_req, res) => {
|
|
45
|
+
res.json(sessions.list());
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
app.post('/api/sessions', auth.middleware, (req, res) => {
|
|
49
|
+
const { name, shell, args: shellArgs, cwd } = req.body || {};
|
|
50
|
+
const id = sessions.create({
|
|
51
|
+
name: name || `Session ${sessions.sessions.size + 1}`,
|
|
52
|
+
shell: shell || config.defaultShell,
|
|
53
|
+
args: shellArgs || [],
|
|
54
|
+
cwd: cwd || config.cwd,
|
|
55
|
+
});
|
|
56
|
+
res.json({ id, url: `/terminal?id=${id}` });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
app.delete('/api/sessions/:id', auth.middleware, (req, res) => {
|
|
60
|
+
if (sessions.delete(req.params.id)) {
|
|
61
|
+
res.json({ ok: true });
|
|
62
|
+
} else {
|
|
63
|
+
res.status(404).json({ error: 'not found' });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Directory listing for folder browser
|
|
68
|
+
app.get('/api/dirs', auth.middleware, (req, res) => {
|
|
69
|
+
const query = req.query.q || os.homedir();
|
|
70
|
+
const dir = query.endsWith('/') ? query : path.dirname(query);
|
|
71
|
+
const prefix = query.endsWith('/') ? '' : path.basename(query);
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
75
|
+
const dirs = entries
|
|
76
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
|
77
|
+
.filter((e) => !prefix || e.name.toLowerCase().startsWith(prefix.toLowerCase()))
|
|
78
|
+
.slice(0, 50)
|
|
79
|
+
.map((e) => path.join(dir, e.name));
|
|
80
|
+
res.json({ base: dir, dirs });
|
|
81
|
+
} catch {
|
|
82
|
+
res.json({ base: dir, dirs: [] });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { setupRoutes };
|
package/src/server.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const express = require('express');
|
|
5
|
+
const cookieParser = require('cookie-parser');
|
|
6
|
+
const http = require('http');
|
|
7
|
+
const { WebSocketServer } = require('ws');
|
|
8
|
+
const QRCode = require('qrcode');
|
|
9
|
+
|
|
10
|
+
const { parseArgs } = require('./cli');
|
|
11
|
+
const { createAuth } = require('./auth');
|
|
12
|
+
const { SessionManager } = require('./sessions');
|
|
13
|
+
const { setupRoutes } = require('./routes');
|
|
14
|
+
const { setupWebSocket } = require('./websocket');
|
|
15
|
+
const { startTunnel, cleanupTunnel } = require('./tunnel');
|
|
16
|
+
|
|
17
|
+
// --- Config ---
|
|
18
|
+
const config = parseArgs();
|
|
19
|
+
const auth = createAuth(config.password);
|
|
20
|
+
const sessions = new SessionManager();
|
|
21
|
+
|
|
22
|
+
// --- Express ---
|
|
23
|
+
const app = express();
|
|
24
|
+
app.use(express.json());
|
|
25
|
+
app.use(cookieParser());
|
|
26
|
+
app.use((_req, res, next) => {
|
|
27
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
28
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
29
|
+
res.setHeader('X-XSS-Protection', '1; mode=block');
|
|
30
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
31
|
+
next();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const server = http.createServer(app);
|
|
35
|
+
const wss = new WebSocketServer({ server, path: '/ws' });
|
|
36
|
+
|
|
37
|
+
setupRoutes(app, { auth, sessions, config });
|
|
38
|
+
setupWebSocket(wss, { auth, sessions });
|
|
39
|
+
|
|
40
|
+
// --- Lifecycle ---
|
|
41
|
+
function shutdown() {
|
|
42
|
+
console.log('\n[termbeam] Shutting down...');
|
|
43
|
+
sessions.shutdown();
|
|
44
|
+
cleanupTunnel();
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
process.on('SIGINT', shutdown);
|
|
49
|
+
process.on('SIGTERM', shutdown);
|
|
50
|
+
process.on('uncaughtException', (err) => {
|
|
51
|
+
console.error('[termbeam] Uncaught exception:', err.message);
|
|
52
|
+
cleanupTunnel();
|
|
53
|
+
process.exit(1);
|
|
54
|
+
});
|
|
55
|
+
process.on('exit', cleanupTunnel);
|
|
56
|
+
|
|
57
|
+
// --- Start ---
|
|
58
|
+
function getLocalIP() {
|
|
59
|
+
const interfaces = os.networkInterfaces();
|
|
60
|
+
for (const name of Object.keys(interfaces)) {
|
|
61
|
+
for (const iface of interfaces[name]) {
|
|
62
|
+
if (iface.family === 'IPv4' && !iface.internal) return iface.address;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return '127.0.0.1';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
server.listen(config.port, config.host, async () => {
|
|
69
|
+
const ip = getLocalIP();
|
|
70
|
+
const localUrl = `http://${ip}:${config.port}`;
|
|
71
|
+
|
|
72
|
+
const defaultId = sessions.create({
|
|
73
|
+
name: path.basename(config.cwd),
|
|
74
|
+
shell: config.shell,
|
|
75
|
+
args: config.shellArgs,
|
|
76
|
+
cwd: config.cwd,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
console.log('');
|
|
80
|
+
console.log(' ████████╗███████╗██████╗ ███╗ ███╗██████╗ ███████╗ █████╗ ███╗ ███╗');
|
|
81
|
+
console.log(' ╚══██╔══╝██╔════╝██╔══██╗████╗ ████║██╔══██╗██╔════╝██╔══██╗████╗ ████║');
|
|
82
|
+
console.log(' ██║ █████╗ ██████╔╝██╔████╔██║██████╔╝█████╗ ███████║██╔████╔██║');
|
|
83
|
+
console.log(' ██║ ██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══██╗██╔══╝ ██╔══██║██║╚██╔╝██║');
|
|
84
|
+
console.log(' ██║ ███████╗██║ ██║██║ ╚═╝ ██║██████╔╝███████╗██║ ██║██║ ╚═╝ ██║');
|
|
85
|
+
console.log(' ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝');
|
|
86
|
+
console.log('');
|
|
87
|
+
console.log(` Beam your terminal to any device 📡 v${config.version}`);
|
|
88
|
+
console.log('');
|
|
89
|
+
console.log(` Local: http://localhost:${config.port}`);
|
|
90
|
+
const isLanReachable = config.host === '0.0.0.0' || config.host === '::' || config.host === ip;
|
|
91
|
+
if (isLanReachable) {
|
|
92
|
+
console.log(` LAN: ${localUrl}`);
|
|
93
|
+
}
|
|
94
|
+
console.log(` Shell: ${config.shell}`);
|
|
95
|
+
console.log(` Session: ${defaultId}`);
|
|
96
|
+
console.log(` Auth: ${config.password ? '🔒 password' : '🔓 none'}`);
|
|
97
|
+
|
|
98
|
+
let publicUrl = null;
|
|
99
|
+
if (config.useTunnel) {
|
|
100
|
+
publicUrl = await startTunnel(config.port);
|
|
101
|
+
if (publicUrl) {
|
|
102
|
+
console.log('');
|
|
103
|
+
console.log(` 🌐 Public: ${publicUrl}`);
|
|
104
|
+
} else {
|
|
105
|
+
console.log('');
|
|
106
|
+
console.log(' ⚠️ Tunnel failed to start. Using LAN only.');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const qrUrl = publicUrl || (isLanReachable ? localUrl : `http://localhost:${config.port}`);
|
|
111
|
+
console.log('');
|
|
112
|
+
try {
|
|
113
|
+
const qr = await QRCode.toString(qrUrl, { type: 'terminal', small: true });
|
|
114
|
+
console.log(qr);
|
|
115
|
+
} catch {
|
|
116
|
+
/* ignore */
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
console.log(` Scan the QR code or open: ${qrUrl}`);
|
|
120
|
+
if (config.password) console.log(` Password: ${config.password}`);
|
|
121
|
+
console.log('');
|
|
122
|
+
});
|
package/src/sessions.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const pty = require('node-pty');
|
|
3
|
+
|
|
4
|
+
class SessionManager {
|
|
5
|
+
constructor() {
|
|
6
|
+
this.sessions = new Map();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
create({ name, shell, args = [], cwd }) {
|
|
10
|
+
const id = crypto.randomBytes(4).toString('hex');
|
|
11
|
+
const ptyProcess = pty.spawn(shell, args, {
|
|
12
|
+
name: 'xterm-256color',
|
|
13
|
+
cols: 120,
|
|
14
|
+
rows: 30,
|
|
15
|
+
cwd,
|
|
16
|
+
env: { ...process.env, TERM: 'xterm-256color' },
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const session = {
|
|
20
|
+
pty: ptyProcess,
|
|
21
|
+
name,
|
|
22
|
+
shell,
|
|
23
|
+
cwd,
|
|
24
|
+
createdAt: new Date().toISOString(),
|
|
25
|
+
clients: new Set(),
|
|
26
|
+
scrollback: [],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
ptyProcess.onData((data) => {
|
|
30
|
+
session.scrollback.push(data);
|
|
31
|
+
if (session.scrollback.length > 2000) session.scrollback.shift();
|
|
32
|
+
for (const ws of session.clients) {
|
|
33
|
+
if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'output', data }));
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
38
|
+
console.log(`[termbeam] Session "${name}" (${id}) exited (code ${exitCode})`);
|
|
39
|
+
for (const ws of session.clients) {
|
|
40
|
+
if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'exit', code: exitCode }));
|
|
41
|
+
}
|
|
42
|
+
this.sessions.delete(id);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
this.sessions.set(id, session);
|
|
46
|
+
console.log(`[termbeam] Session "${name}" created (id=${id}, pid=${ptyProcess.pid})`);
|
|
47
|
+
return id;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get(id) {
|
|
51
|
+
return this.sessions.get(id);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
delete(id) {
|
|
55
|
+
const s = this.sessions.get(id);
|
|
56
|
+
if (!s) return false;
|
|
57
|
+
s.pty.kill();
|
|
58
|
+
this.sessions.delete(id);
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
list() {
|
|
63
|
+
const list = [];
|
|
64
|
+
for (const [id, s] of this.sessions) {
|
|
65
|
+
list.push({
|
|
66
|
+
id,
|
|
67
|
+
name: s.name,
|
|
68
|
+
cwd: s.cwd,
|
|
69
|
+
shell: s.shell,
|
|
70
|
+
pid: s.pty.pid,
|
|
71
|
+
clients: s.clients.size,
|
|
72
|
+
createdAt: s.createdAt,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return list;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
shutdown() {
|
|
79
|
+
for (const [id, s] of this.sessions) {
|
|
80
|
+
try {
|
|
81
|
+
s.pty.kill();
|
|
82
|
+
} catch {
|
|
83
|
+
/* ignore */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
this.sessions.clear();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = { SessionManager };
|
package/src/tunnel.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const { execSync, spawn } = require('child_process');
|
|
2
|
+
|
|
3
|
+
let tunnelId = null;
|
|
4
|
+
let tunnelProc = null;
|
|
5
|
+
|
|
6
|
+
async function startTunnel(port) {
|
|
7
|
+
console.log('[termbeam] Starting devtunnel...');
|
|
8
|
+
try {
|
|
9
|
+
try {
|
|
10
|
+
execSync('devtunnel user show', { stdio: 'pipe' });
|
|
11
|
+
} catch {
|
|
12
|
+
console.log('[termbeam] devtunnel not logged in, launching login...');
|
|
13
|
+
execSync('devtunnel user login -g', { stdio: 'inherit' });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const createOut = execSync('devtunnel create --expiration 1d --json', { encoding: 'utf-8' });
|
|
17
|
+
const tunnelData = JSON.parse(createOut);
|
|
18
|
+
tunnelId = tunnelData.tunnel.tunnelId;
|
|
19
|
+
|
|
20
|
+
execSync(`devtunnel port create ${tunnelId} -p ${port} --protocol http`, { stdio: 'pipe' });
|
|
21
|
+
execSync(`devtunnel access create ${tunnelId} -p ${port} --anonymous`, { stdio: 'pipe' });
|
|
22
|
+
|
|
23
|
+
const hostProc = spawn('devtunnel', ['host', tunnelId], {
|
|
24
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
25
|
+
detached: true,
|
|
26
|
+
});
|
|
27
|
+
tunnelProc = hostProc;
|
|
28
|
+
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
let output = '';
|
|
31
|
+
const timeout = setTimeout(() => resolve(null), 15000);
|
|
32
|
+
|
|
33
|
+
hostProc.stdout.on('data', (data) => {
|
|
34
|
+
output += data.toString();
|
|
35
|
+
const match = output.match(/(https:\/\/[^\s]+devtunnels\.ms[^\s]*)/);
|
|
36
|
+
if (match) {
|
|
37
|
+
clearTimeout(timeout);
|
|
38
|
+
resolve(match[1]);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
hostProc.stderr.on('data', (data) => {
|
|
42
|
+
output += data.toString();
|
|
43
|
+
});
|
|
44
|
+
hostProc.on('error', () => {
|
|
45
|
+
clearTimeout(timeout);
|
|
46
|
+
resolve(null);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
} catch (e) {
|
|
50
|
+
console.error(`[termbeam] Tunnel error: ${e.message}`);
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function cleanupTunnel() {
|
|
56
|
+
if (tunnelId) {
|
|
57
|
+
try {
|
|
58
|
+
if (tunnelProc) tunnelProc.kill();
|
|
59
|
+
execSync(`devtunnel delete ${tunnelId} -f`, { stdio: 'pipe' });
|
|
60
|
+
console.log('[termbeam] Tunnel cleaned up');
|
|
61
|
+
} catch {
|
|
62
|
+
/* best effort */
|
|
63
|
+
}
|
|
64
|
+
tunnelId = null;
|
|
65
|
+
tunnelProc = null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { startTunnel, cleanupTunnel };
|
package/src/version.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const { execSync } = require('child_process');
|
|
3
|
+
|
|
4
|
+
function getVersion() {
|
|
5
|
+
const pkg = require(path.join(__dirname, '..', 'package.json'));
|
|
6
|
+
const base = pkg.version;
|
|
7
|
+
|
|
8
|
+
// If installed via npm (global or npx), use the package version as-is
|
|
9
|
+
if (process.env.npm_package_version || isInstalledGlobally()) {
|
|
10
|
+
return base;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Running from source — try git describe for a dev version
|
|
14
|
+
try {
|
|
15
|
+
const gitDesc = execSync('git describe --tags --always --dirty', {
|
|
16
|
+
cwd: path.join(__dirname, '..'),
|
|
17
|
+
encoding: 'utf-8',
|
|
18
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
19
|
+
}).trim();
|
|
20
|
+
// If we have a tag like v1.0.0, and we're exactly on it, return base
|
|
21
|
+
if (gitDesc === `v${base}`) return base;
|
|
22
|
+
// Otherwise return dev version
|
|
23
|
+
return `${base}-dev (${gitDesc})`;
|
|
24
|
+
} catch {
|
|
25
|
+
return `${base}-dev`;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isInstalledGlobally() {
|
|
30
|
+
// Check if we're running from a node_modules path (npm/npx install)
|
|
31
|
+
return __dirname.includes('node_modules');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { getVersion };
|
package/src/websocket.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
function setupWebSocket(wss, { auth, sessions }) {
|
|
2
|
+
wss.on('connection', (ws, req) => {
|
|
3
|
+
let authenticated = !auth.password;
|
|
4
|
+
let attached = null;
|
|
5
|
+
|
|
6
|
+
// Check cookie from upgrade request
|
|
7
|
+
if (auth.password) {
|
|
8
|
+
const cookies = auth.parseCookies(req.headers.cookie || '');
|
|
9
|
+
if (cookies.pty_token && auth.validateToken(cookies.pty_token)) {
|
|
10
|
+
authenticated = true;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
ws.on('message', (raw) => {
|
|
15
|
+
try {
|
|
16
|
+
const msg = JSON.parse(raw);
|
|
17
|
+
|
|
18
|
+
if (msg.type === 'auth') {
|
|
19
|
+
if (msg.password === auth.password || auth.validateToken(msg.token)) {
|
|
20
|
+
authenticated = true;
|
|
21
|
+
ws.send(JSON.stringify({ type: 'auth_ok' }));
|
|
22
|
+
} else {
|
|
23
|
+
ws.send(JSON.stringify({ type: 'error', message: 'Unauthorized' }));
|
|
24
|
+
ws.close();
|
|
25
|
+
}
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!authenticated) {
|
|
30
|
+
ws.send(JSON.stringify({ type: 'error', message: 'Unauthorized' }));
|
|
31
|
+
ws.close();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (msg.type === 'attach') {
|
|
36
|
+
const session = sessions.get(msg.sessionId);
|
|
37
|
+
if (!session) {
|
|
38
|
+
ws.send(JSON.stringify({ type: 'error', message: 'Session not found' }));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
attached = session;
|
|
42
|
+
session.clients.add(ws);
|
|
43
|
+
if (session.scrollback.length > 0) {
|
|
44
|
+
ws.send(JSON.stringify({ type: 'output', data: session.scrollback.join('') }));
|
|
45
|
+
}
|
|
46
|
+
ws.send(JSON.stringify({ type: 'attached', sessionId: msg.sessionId }));
|
|
47
|
+
console.log(`[termbeam] Client attached to session ${msg.sessionId}`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!attached) return;
|
|
52
|
+
|
|
53
|
+
if (msg.type === 'input') {
|
|
54
|
+
attached.pty.write(msg.data);
|
|
55
|
+
} else if (msg.type === 'resize') {
|
|
56
|
+
attached.pty.resize(msg.cols, msg.rows);
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
if (attached) attached.pty.write(raw.toString());
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
ws.on('close', () => {
|
|
64
|
+
if (attached) {
|
|
65
|
+
attached.clients.delete(ws);
|
|
66
|
+
console.log('[termbeam] Client detached');
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { setupWebSocket };
|