webssh 1.0.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 +48 -0
- package/package.json +49 -0
- package/terminals.js +63 -0
- package/webssh.js +276 -0
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# webssh
|
|
2
|
+
|
|
3
|
+
Web-based SSH terminal server and client with xterm.js integration. Access remote shells through your browser or CLI.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- 🌐 **Browser-based terminal** – Full xterm.js integration with syntax highlighting and themes
|
|
8
|
+
- 🔌 **WebSocket transport** – Real-time bidirectional communication
|
|
9
|
+
- 🖥️ **CLI client** – Connect from command line with interactive prompt
|
|
10
|
+
- 📏 **Auto-resize** – Terminal dimensions sync between client and server
|
|
11
|
+
- 🔒 **Session management** – Unique session IDs with automatic cleanup
|
|
12
|
+
- ⚡ **Lightweight** – Minimal dependencies, fast startup
|
|
13
|
+
- 🚀 **Modern ES modules** – Native ESM with async/await support
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install webssh
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
#server mode
|
|
22
|
+
|
|
23
|
+
import webssh from 'webssh';
|
|
24
|
+
|
|
25
|
+
// Using async IIFE for top-level await
|
|
26
|
+
const { serve } = webssh;
|
|
27
|
+
|
|
28
|
+
const server = serve({
|
|
29
|
+
port: 3000,
|
|
30
|
+
host: 'localhost'
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
server.start(() => {
|
|
34
|
+
console.log('🚀 WebSSH server running at http://localhost:3000');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
#client mode
|
|
39
|
+
|
|
40
|
+
import webssh from 'webssh';
|
|
41
|
+
|
|
42
|
+
const { connect } = webssh;
|
|
43
|
+
|
|
44
|
+
// Connect with defaults (localhost:3000)
|
|
45
|
+
await connect();
|
|
46
|
+
|
|
47
|
+
// Or specify custom host/port
|
|
48
|
+
await connect('remote.server.com', 8080);
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "webssh",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Web-based SSH terminal server and client with xterm.js integration",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "webssh.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./webssh.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"webssh.js",
|
|
12
|
+
"terminals.js",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"start": "node webssh.js",
|
|
18
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"ssh",
|
|
22
|
+
"terminal",
|
|
23
|
+
"web-terminal",
|
|
24
|
+
"xterm",
|
|
25
|
+
"websocket",
|
|
26
|
+
"remote-shell",
|
|
27
|
+
"browser-terminal",
|
|
28
|
+
"pty",
|
|
29
|
+
"web-ssh"
|
|
30
|
+
],
|
|
31
|
+
"author": "littlejustnode",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/littlejustnode/webssh.git"
|
|
36
|
+
},
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/littlejustnode/webssh/issues"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/littlejustnode/webssh#readme",
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=18.0.0"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"express": "^4.18.2",
|
|
46
|
+
"just-prompt": "^1.0.0",
|
|
47
|
+
"ws": "^8.14.2"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/terminals.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import * as os from 'node:os';
|
|
2
|
+
import * as pty from 'node-pty';
|
|
3
|
+
|
|
4
|
+
class TerminalManager {
|
|
5
|
+
constructor() {
|
|
6
|
+
this.sessions = new Map();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Creates a new terminal instance and stores it in the Map.
|
|
11
|
+
*/
|
|
12
|
+
createSession(name, options = {}) {
|
|
13
|
+
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
|
|
14
|
+
|
|
15
|
+
const ptyProcess = pty.spawn(shell, [], {
|
|
16
|
+
name: 'xterm-color',
|
|
17
|
+
cols: options.cols || process.stdout.columns || 80,
|
|
18
|
+
rows: options.rows || process.stdout.rows || 24,
|
|
19
|
+
cwd: options.cwd || process.env.HOME || process.cwd(),
|
|
20
|
+
env: options.env || process.env
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// Define the session object with the methods you requested
|
|
24
|
+
const session = {
|
|
25
|
+
id: name,
|
|
26
|
+
process: ptyProcess,
|
|
27
|
+
|
|
28
|
+
// Listen for output
|
|
29
|
+
onOut: (callback) => {
|
|
30
|
+
ptyProcess.onData((data) => callback(data));
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
// Send commands (automatically adds newline if missing)
|
|
34
|
+
send: (data) => {
|
|
35
|
+
const command = data.endsWith('\r') || data.endsWith('\n') ? data : `${data}\r`;
|
|
36
|
+
ptyProcess.write(command);
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
// Kill this specific session and remove from memory
|
|
40
|
+
exit: () => {
|
|
41
|
+
ptyProcess.kill();
|
|
42
|
+
this.sessions.delete(name);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Store it
|
|
47
|
+
this.sessions.set(name, session);
|
|
48
|
+
return session;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Terminates every session stored in memory.
|
|
53
|
+
*/
|
|
54
|
+
close() {
|
|
55
|
+
this.sessions.forEach((session) => {
|
|
56
|
+
session.exit();
|
|
57
|
+
});
|
|
58
|
+
this.sessions.clear();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Export a single instance to be used as 'terminals'
|
|
63
|
+
export const terminals = new TerminalManager();
|
package/webssh.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { WebSocketServer } from 'ws';
|
|
5
|
+
import { terminals } from './terminals.js';
|
|
6
|
+
import jp from 'just-prompt';
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
// ────────────────────────────────────────────────
|
|
10
|
+
// SERVER BUILDER – returns chainable object
|
|
11
|
+
// ────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
function serve(options = {}) {
|
|
14
|
+
const config = {
|
|
15
|
+
port: 3000,
|
|
16
|
+
host: 'localhost',
|
|
17
|
+
path: '/terminal',
|
|
18
|
+
cors: false,
|
|
19
|
+
sessionPrefix: 'ws-',
|
|
20
|
+
...options,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const app = express();
|
|
24
|
+
const httpServer = createServer(app);
|
|
25
|
+
const wss = new WebSocketServer({ server: httpServer, path: config.path });
|
|
26
|
+
|
|
27
|
+
if (config.cors) {
|
|
28
|
+
app.use((req, res, next) => {
|
|
29
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
30
|
+
res.setHeader('Access-Control-Allow-Headers', '*');
|
|
31
|
+
next();
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Serve browser terminal
|
|
36
|
+
app.get('/', (req, res) => {
|
|
37
|
+
res.type('html').send(getBrowserHTML(config.path));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const clientToSessionId = new WeakMap();
|
|
41
|
+
|
|
42
|
+
wss.on('connection', (ws) => {
|
|
43
|
+
let session = null;
|
|
44
|
+
let sessionId = null;
|
|
45
|
+
|
|
46
|
+
ws.on('message', (raw) => {
|
|
47
|
+
try {
|
|
48
|
+
const data = raw.toString();
|
|
49
|
+
|
|
50
|
+
if (data.startsWith('{')) {
|
|
51
|
+
try {
|
|
52
|
+
const msg = JSON.parse(data);
|
|
53
|
+
if (msg.type === 'resize' && session) {
|
|
54
|
+
session.process.resize(msg.cols, msg.rows);
|
|
55
|
+
}
|
|
56
|
+
} catch {}
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!session) {
|
|
61
|
+
sessionId = `${config.sessionPrefix}${Date.now()}-${Math.random().toString(36).slice(2,8)}`;
|
|
62
|
+
session = terminals.createSession(sessionId, {
|
|
63
|
+
cols: 80,
|
|
64
|
+
rows: 24,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
clientToSessionId.set(ws, sessionId);
|
|
68
|
+
|
|
69
|
+
session.onOut((chunk) => {
|
|
70
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(chunk);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
ws.send('\r\nWeb terminal ready.\r\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
session.send(data);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
console.error('WS message error:', err);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
ws.on('close', () => {
|
|
83
|
+
if (sessionId) {
|
|
84
|
+
const sess = terminals.sessions.get(sessionId);
|
|
85
|
+
if (sess) {
|
|
86
|
+
sess.exit();
|
|
87
|
+
terminals.sessions.delete(sessionId);
|
|
88
|
+
}
|
|
89
|
+
clientToSessionId.delete(ws);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
ws.on('error', (err) => console.error('WS error:', err));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Chainable start method
|
|
97
|
+
function start(callback) {
|
|
98
|
+
httpServer.listen(config.port, config.host, () => {
|
|
99
|
+
const displayHost = config.host === '0.0.0.0' ? 'localhost' : config.host;
|
|
100
|
+
console.log(`Web terminal server listening at http://${displayHost}:${config.port}`);
|
|
101
|
+
console.log(` → Browser: http://${displayHost}:${config.port}/`);
|
|
102
|
+
console.log(` → WS: ws://${displayHost}:${config.port}${config.path}`);
|
|
103
|
+
if (callback) callback();
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Also expose close (optional)
|
|
108
|
+
function close() {
|
|
109
|
+
terminals.close();
|
|
110
|
+
httpServer.close();
|
|
111
|
+
wss.close();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
start,
|
|
116
|
+
close,
|
|
117
|
+
// for advanced usage if someone needs direct access
|
|
118
|
+
app,
|
|
119
|
+
httpServer,
|
|
120
|
+
wss,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ────────────────────────────────────────────────
|
|
125
|
+
// CLIENT – single host string (unchanged)
|
|
126
|
+
// ────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
async function connect(host = 'localhost', port = 3000) {
|
|
129
|
+
const path = '/terminal';
|
|
130
|
+
const url = `ws://${host}:${port}${path}`;
|
|
131
|
+
|
|
132
|
+
const promptMessage = '→ ';
|
|
133
|
+
const exitCommand = 'exit';
|
|
134
|
+
|
|
135
|
+
const WebSocket = (await import('ws')).default;
|
|
136
|
+
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
console.log(`Connecting to ${url} ...`);
|
|
139
|
+
|
|
140
|
+
const socket = new WebSocket(url);
|
|
141
|
+
|
|
142
|
+
socket.on('open', () => {
|
|
143
|
+
console.clear();
|
|
144
|
+
console.log(`Connected to ${host}`);
|
|
145
|
+
console.log(`(type "${exitCommand}" to quit)\n`);
|
|
146
|
+
|
|
147
|
+
const prompt = jp.app(promptMessage, exitCommand, async (input) => {
|
|
148
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
149
|
+
socket.send(input + '\r');
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
prompt.onExit(() => {
|
|
154
|
+
socket.close();
|
|
155
|
+
resolve();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
socket.on('message', (data) => {
|
|
159
|
+
process.stdout.write(data.toString());
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
socket.on('close', () => {
|
|
163
|
+
console.log(`\nDisconnected from ${host}.`);
|
|
164
|
+
prompt.stop();
|
|
165
|
+
resolve();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
socket.on('error', (err) => {
|
|
169
|
+
console.error(`Connection error: ${err.message}`);
|
|
170
|
+
prompt.stop();
|
|
171
|
+
reject(err);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
prompt.start().catch(reject);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
socket.on('error', (err) => {
|
|
178
|
+
console.error(`Cannot connect to ${host}: ${err.message}`);
|
|
179
|
+
reject(err);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ────────────────────────────────────────────────
|
|
185
|
+
// Browser HTML (unchanged)
|
|
186
|
+
// ────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
function getBrowserHTML(wsPath = '/terminal') {
|
|
189
|
+
return `
|
|
190
|
+
<!DOCTYPE html>
|
|
191
|
+
<html lang="en">
|
|
192
|
+
<head>
|
|
193
|
+
<meta charset="UTF-8"/>
|
|
194
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
195
|
+
<title>Web Terminal</title>
|
|
196
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css"/>
|
|
197
|
+
<style>
|
|
198
|
+
body { margin:0; background:#000; height:100vh; overflow:hidden; }
|
|
199
|
+
#terminal { width:100%; height:100%; }
|
|
200
|
+
</style>
|
|
201
|
+
</head>
|
|
202
|
+
<body>
|
|
203
|
+
<div id="terminal"></div>
|
|
204
|
+
|
|
205
|
+
<script type="module">
|
|
206
|
+
import { Terminal } from 'https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/+esm';
|
|
207
|
+
import { FitAddon } from 'https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/+esm';
|
|
208
|
+
|
|
209
|
+
const term = new Terminal({
|
|
210
|
+
cursorBlink: true,
|
|
211
|
+
theme: { background: '#1e1e1e', foreground: '#e0e0e0' }
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const fitAddon = new FitAddon();
|
|
215
|
+
term.loadAddon(fitAddon);
|
|
216
|
+
term.open(document.getElementById('terminal'));
|
|
217
|
+
fitAddon.fit();
|
|
218
|
+
|
|
219
|
+
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
220
|
+
const ws = new WebSocket(protocol + '//' + location.host + '${wsPath}');
|
|
221
|
+
|
|
222
|
+
let inputBuffer = "";
|
|
223
|
+
|
|
224
|
+
term.onData(data => {
|
|
225
|
+
switch (data) {
|
|
226
|
+
case '\\r':
|
|
227
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(inputBuffer + '\\r');
|
|
228
|
+
inputBuffer = "";
|
|
229
|
+
term.write('\\r\\n');
|
|
230
|
+
break;
|
|
231
|
+
case '\\u007F':
|
|
232
|
+
if (inputBuffer.length > 0) {
|
|
233
|
+
inputBuffer = inputBuffer.slice(0, -1);
|
|
234
|
+
term.write('\\b \\b');
|
|
235
|
+
}
|
|
236
|
+
break;
|
|
237
|
+
default:
|
|
238
|
+
if (data >= ' ' && data <= '~') {
|
|
239
|
+
inputBuffer += data;
|
|
240
|
+
term.write(data);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
ws.onmessage = e => term.write(e.data);
|
|
246
|
+
|
|
247
|
+
function sendResize() {
|
|
248
|
+
fitAddon.fit();
|
|
249
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
250
|
+
ws.send(JSON.stringify({
|
|
251
|
+
type: 'resize',
|
|
252
|
+
cols: term.cols,
|
|
253
|
+
rows: term.rows
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
window.addEventListener('resize', sendResize);
|
|
259
|
+
ws.onopen = () => {
|
|
260
|
+
sendResize();
|
|
261
|
+
term.writeln('Connecting...');
|
|
262
|
+
term.focus();
|
|
263
|
+
};
|
|
264
|
+
</script>
|
|
265
|
+
</body>
|
|
266
|
+
</html>`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ────────────────────────────────────────────────
|
|
270
|
+
// Public API
|
|
271
|
+
// ────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
export default {
|
|
274
|
+
serve, // ← new main server entry point
|
|
275
|
+
connect, // simple client
|
|
276
|
+
};
|