@ghostty-web/demo 0.2.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 +50 -0
- package/bin/demo.js +580 -0
- package/package.json +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# @ghostty-web/demo
|
|
2
|
+
|
|
3
|
+
Cross-platform demo server for [ghostty-web](https://github.com/coder/ghostty-web) terminal emulator.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @ghostty-web/demo
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
This starts a local web server with a fully functional terminal connected to your shell.
|
|
12
|
+
Works on **Linux**, **macOS**, and **Windows**.
|
|
13
|
+
|
|
14
|
+
## What it does
|
|
15
|
+
|
|
16
|
+
- Starts an HTTP server on port 8080 (configurable via `PORT` env var)
|
|
17
|
+
- Starts a WebSocket server on port 3001 for PTY communication
|
|
18
|
+
- Opens a real shell session (bash, zsh, cmd.exe, or PowerShell)
|
|
19
|
+
- Provides full PTY support (colors, cursor positioning, resize, etc.)
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Default (port 8080)
|
|
25
|
+
npx @ghostty-web/demo
|
|
26
|
+
|
|
27
|
+
# Custom port
|
|
28
|
+
PORT=3000 npx @ghostty-web/demo
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Then open http://localhost:8080 in your browser.
|
|
32
|
+
|
|
33
|
+
## Features
|
|
34
|
+
|
|
35
|
+
- 🖥️ Real shell sessions with full PTY support
|
|
36
|
+
- 🎨 True color (24-bit) and 256 color support
|
|
37
|
+
- ⌨️ Full keyboard support including special keys
|
|
38
|
+
- 📐 Dynamic terminal resizing
|
|
39
|
+
- 🔄 Auto-reconnection on disconnect
|
|
40
|
+
- 🌐 Cross-platform (Linux, macOS, Windows)
|
|
41
|
+
|
|
42
|
+
## Security Warning
|
|
43
|
+
|
|
44
|
+
⚠️ **This server provides full shell access.**
|
|
45
|
+
|
|
46
|
+
Only use for local development and demos. Do not expose to untrusted networks.
|
|
47
|
+
|
|
48
|
+
## License
|
|
49
|
+
|
|
50
|
+
MIT
|
package/bin/demo.js
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @ghostty-web/demo - Cross-platform demo server
|
|
5
|
+
*
|
|
6
|
+
* Starts a local HTTP server with WebSocket PTY support.
|
|
7
|
+
* Run with: npx @ghostty-web/demo
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import crypto from 'crypto';
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import http from 'http';
|
|
13
|
+
import { homedir } from 'os';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import { fileURLToPath } from 'url';
|
|
16
|
+
|
|
17
|
+
// Node-pty for cross-platform PTY support
|
|
18
|
+
import pty from '@lydell/node-pty';
|
|
19
|
+
|
|
20
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
21
|
+
const __dirname = path.dirname(__filename);
|
|
22
|
+
|
|
23
|
+
const HTTP_PORT = process.env.PORT || 8080;
|
|
24
|
+
const WS_PORT = 3001;
|
|
25
|
+
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// Locate ghostty-web assets
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
function findGhosttyWeb() {
|
|
31
|
+
const possiblePaths = [
|
|
32
|
+
// Development: running from repo root (demo/bin/demo.js -> ../../dist)
|
|
33
|
+
path.join(__dirname, '..', '..', 'dist'),
|
|
34
|
+
// When installed as dependency (demo/node_modules/ghostty-web/dist)
|
|
35
|
+
path.join(__dirname, '..', 'node_modules', 'ghostty-web', 'dist'),
|
|
36
|
+
// When in a monorepo or hoisted
|
|
37
|
+
path.join(__dirname, '..', '..', 'node_modules', 'ghostty-web', 'dist'),
|
|
38
|
+
path.join(__dirname, '..', '..', '..', 'node_modules', 'ghostty-web', 'dist'),
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
for (const p of possiblePaths) {
|
|
42
|
+
const jsPath = path.join(p, 'ghostty-web.js');
|
|
43
|
+
if (fs.existsSync(jsPath)) {
|
|
44
|
+
// Find WASM file - check both dist/ and parent directory
|
|
45
|
+
let wasmPath = path.join(p, 'ghostty-vt.wasm');
|
|
46
|
+
if (!fs.existsSync(wasmPath)) {
|
|
47
|
+
wasmPath = path.join(path.dirname(p), 'ghostty-vt.wasm');
|
|
48
|
+
}
|
|
49
|
+
if (fs.existsSync(wasmPath)) {
|
|
50
|
+
return { distPath: p, wasmPath };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.error('Error: Could not find ghostty-web package.');
|
|
56
|
+
console.error('');
|
|
57
|
+
console.error('If developing locally, run: bun run build');
|
|
58
|
+
console.error('If using npx, the package should install automatically.');
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const { distPath, wasmPath } = findGhosttyWeb();
|
|
63
|
+
const isDev =
|
|
64
|
+
distPath.includes(path.join('demo', '..', 'dist')) ||
|
|
65
|
+
distPath === path.join(__dirname, '..', '..', 'dist');
|
|
66
|
+
|
|
67
|
+
// ============================================================================
|
|
68
|
+
// HTML Template
|
|
69
|
+
// ============================================================================
|
|
70
|
+
|
|
71
|
+
const HTML_TEMPLATE = `<!doctype html>
|
|
72
|
+
<html lang="en">
|
|
73
|
+
<head>
|
|
74
|
+
<meta charset="UTF-8" />
|
|
75
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
76
|
+
<title>ghostty-web</title>
|
|
77
|
+
<style>
|
|
78
|
+
* {
|
|
79
|
+
margin: 0;
|
|
80
|
+
padding: 0;
|
|
81
|
+
box-sizing: border-box;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
body {
|
|
85
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
86
|
+
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
|
87
|
+
min-height: 100vh;
|
|
88
|
+
display: flex;
|
|
89
|
+
align-items: center;
|
|
90
|
+
justify-content: center;
|
|
91
|
+
padding: 40px 20px;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.terminal-window {
|
|
95
|
+
width: 100%;
|
|
96
|
+
max-width: 1000px;
|
|
97
|
+
background: #1e1e1e;
|
|
98
|
+
border-radius: 12px;
|
|
99
|
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
|
100
|
+
overflow: hidden;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.title-bar {
|
|
104
|
+
background: #2d2d2d;
|
|
105
|
+
padding: 12px 16px;
|
|
106
|
+
display: flex;
|
|
107
|
+
align-items: center;
|
|
108
|
+
gap: 12px;
|
|
109
|
+
border-bottom: 1px solid #1a1a1a;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.traffic-lights {
|
|
113
|
+
display: flex;
|
|
114
|
+
gap: 8px;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.light {
|
|
118
|
+
width: 12px;
|
|
119
|
+
height: 12px;
|
|
120
|
+
border-radius: 50%;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.light.red { background: #ff5f56; }
|
|
124
|
+
.light.yellow { background: #ffbd2e; }
|
|
125
|
+
.light.green { background: #27c93f; }
|
|
126
|
+
|
|
127
|
+
.title {
|
|
128
|
+
color: #e5e5e5;
|
|
129
|
+
font-size: 13px;
|
|
130
|
+
font-weight: 500;
|
|
131
|
+
letter-spacing: 0.3px;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.connection-status {
|
|
135
|
+
margin-left: auto;
|
|
136
|
+
font-size: 11px;
|
|
137
|
+
color: #888;
|
|
138
|
+
display: flex;
|
|
139
|
+
align-items: center;
|
|
140
|
+
gap: 6px;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.status-dot {
|
|
144
|
+
width: 8px;
|
|
145
|
+
height: 8px;
|
|
146
|
+
border-radius: 50%;
|
|
147
|
+
background: #888;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.status-dot.connected { background: #27c93f; }
|
|
151
|
+
.status-dot.disconnected { background: #ff5f56; }
|
|
152
|
+
.status-dot.connecting { background: #ffbd2e; animation: pulse 1s infinite; }
|
|
153
|
+
|
|
154
|
+
@keyframes pulse {
|
|
155
|
+
0%, 100% { opacity: 1; }
|
|
156
|
+
50% { opacity: 0.5; }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.terminal-content {
|
|
160
|
+
padding: 0;
|
|
161
|
+
min-height: 400px;
|
|
162
|
+
height: 60vh;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#terminal {
|
|
166
|
+
width: 100%;
|
|
167
|
+
height: 100%;
|
|
168
|
+
}
|
|
169
|
+
</style>
|
|
170
|
+
</head>
|
|
171
|
+
<body>
|
|
172
|
+
<div class="terminal-window">
|
|
173
|
+
<div class="title-bar">
|
|
174
|
+
<div class="traffic-lights">
|
|
175
|
+
<div class="light red"></div>
|
|
176
|
+
<div class="light yellow"></div>
|
|
177
|
+
<div class="light green"></div>
|
|
178
|
+
</div>
|
|
179
|
+
<span class="title">ghostty-web — shell</span>
|
|
180
|
+
<div class="connection-status">
|
|
181
|
+
<div class="status-dot connecting" id="status-dot"></div>
|
|
182
|
+
<span id="status-text">Connecting...</span>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
<div class="terminal-content">
|
|
186
|
+
<div id="terminal"></div>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
<script type="module">
|
|
191
|
+
import { Terminal, FitAddon } from '/dist/ghostty-web.js';
|
|
192
|
+
|
|
193
|
+
const term = new Terminal({
|
|
194
|
+
cols: 80,
|
|
195
|
+
rows: 24,
|
|
196
|
+
fontFamily: 'JetBrains Mono, Menlo, Monaco, monospace',
|
|
197
|
+
fontSize: 14,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const fitAddon = new FitAddon();
|
|
201
|
+
term.loadAddon(fitAddon);
|
|
202
|
+
|
|
203
|
+
const container = document.getElementById('terminal');
|
|
204
|
+
await term.open(container);
|
|
205
|
+
fitAddon.fit();
|
|
206
|
+
|
|
207
|
+
// Status elements
|
|
208
|
+
const statusDot = document.getElementById('status-dot');
|
|
209
|
+
const statusText = document.getElementById('status-text');
|
|
210
|
+
|
|
211
|
+
function setStatus(status, text) {
|
|
212
|
+
statusDot.className = 'status-dot ' + status;
|
|
213
|
+
statusText.textContent = text;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Connect to WebSocket PTY server
|
|
217
|
+
const wsUrl = 'ws://' + window.location.hostname + ':${WS_PORT}/ws?cols=' + term.cols + '&rows=' + term.rows;
|
|
218
|
+
let ws;
|
|
219
|
+
|
|
220
|
+
function connect() {
|
|
221
|
+
setStatus('connecting', 'Connecting...');
|
|
222
|
+
ws = new WebSocket(wsUrl);
|
|
223
|
+
|
|
224
|
+
ws.onopen = () => {
|
|
225
|
+
setStatus('connected', 'Connected');
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
ws.onmessage = (event) => {
|
|
229
|
+
term.write(event.data);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
ws.onclose = () => {
|
|
233
|
+
setStatus('disconnected', 'Disconnected');
|
|
234
|
+
term.write('\\r\\n\\x1b[31mConnection closed. Reconnecting in 2s...\\x1b[0m\\r\\n');
|
|
235
|
+
setTimeout(connect, 2000);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
ws.onerror = () => {
|
|
239
|
+
setStatus('disconnected', 'Error');
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
connect();
|
|
244
|
+
|
|
245
|
+
// Send terminal input to server
|
|
246
|
+
term.onData((data) => {
|
|
247
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
248
|
+
ws.send(data);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// Handle resize
|
|
253
|
+
window.addEventListener('resize', () => {
|
|
254
|
+
fitAddon.fit();
|
|
255
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
256
|
+
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
</script>
|
|
260
|
+
</body>
|
|
261
|
+
</html>`;
|
|
262
|
+
|
|
263
|
+
// ============================================================================
|
|
264
|
+
// MIME Types
|
|
265
|
+
// ============================================================================
|
|
266
|
+
|
|
267
|
+
const MIME_TYPES = {
|
|
268
|
+
'.html': 'text/html',
|
|
269
|
+
'.js': 'application/javascript',
|
|
270
|
+
'.mjs': 'application/javascript',
|
|
271
|
+
'.css': 'text/css',
|
|
272
|
+
'.json': 'application/json',
|
|
273
|
+
'.wasm': 'application/wasm',
|
|
274
|
+
'.png': 'image/png',
|
|
275
|
+
'.svg': 'image/svg+xml',
|
|
276
|
+
'.ico': 'image/x-icon',
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// ============================================================================
|
|
280
|
+
// HTTP Server
|
|
281
|
+
// ============================================================================
|
|
282
|
+
|
|
283
|
+
const httpServer = http.createServer((req, res) => {
|
|
284
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
285
|
+
const pathname = url.pathname;
|
|
286
|
+
|
|
287
|
+
// Serve index page
|
|
288
|
+
if (pathname === '/' || pathname === '/index.html') {
|
|
289
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
290
|
+
res.end(HTML_TEMPLATE);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Serve dist files
|
|
295
|
+
if (pathname.startsWith('/dist/')) {
|
|
296
|
+
const filePath = path.join(distPath, pathname.slice(6));
|
|
297
|
+
serveFile(filePath, res);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Serve WASM file
|
|
302
|
+
if (pathname === '/ghostty-vt.wasm') {
|
|
303
|
+
serveFile(wasmPath, res);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 404
|
|
308
|
+
res.writeHead(404);
|
|
309
|
+
res.end('Not Found');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
function serveFile(filePath, res) {
|
|
313
|
+
const ext = path.extname(filePath);
|
|
314
|
+
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
315
|
+
|
|
316
|
+
fs.readFile(filePath, (err, data) => {
|
|
317
|
+
if (err) {
|
|
318
|
+
res.writeHead(404);
|
|
319
|
+
res.end('Not Found');
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
res.writeHead(200, { 'Content-Type': contentType });
|
|
323
|
+
res.end(data);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ============================================================================
|
|
328
|
+
// WebSocket Server (using native WebSocket upgrade)
|
|
329
|
+
// ============================================================================
|
|
330
|
+
|
|
331
|
+
const sessions = new Map();
|
|
332
|
+
|
|
333
|
+
function getShell() {
|
|
334
|
+
if (process.platform === 'win32') {
|
|
335
|
+
return process.env.COMSPEC || 'cmd.exe';
|
|
336
|
+
}
|
|
337
|
+
return process.env.SHELL || '/bin/bash';
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function createPtySession(cols, rows) {
|
|
341
|
+
const shell = getShell();
|
|
342
|
+
const shellArgs = process.platform === 'win32' ? [] : [];
|
|
343
|
+
|
|
344
|
+
const ptyProcess = pty.spawn(shell, shellArgs, {
|
|
345
|
+
name: 'xterm-256color',
|
|
346
|
+
cols: cols,
|
|
347
|
+
rows: rows,
|
|
348
|
+
cwd: homedir(),
|
|
349
|
+
env: {
|
|
350
|
+
...process.env,
|
|
351
|
+
TERM: 'xterm-256color',
|
|
352
|
+
COLORTERM: 'truecolor',
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
return ptyProcess;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// WebSocket server
|
|
360
|
+
const wsServer = http.createServer();
|
|
361
|
+
|
|
362
|
+
wsServer.on('upgrade', (req, socket, head) => {
|
|
363
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
364
|
+
|
|
365
|
+
if (url.pathname !== '/ws') {
|
|
366
|
+
socket.destroy();
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const cols = Number.parseInt(url.searchParams.get('cols') || '80');
|
|
371
|
+
const rows = Number.parseInt(url.searchParams.get('rows') || '24');
|
|
372
|
+
|
|
373
|
+
// Parse WebSocket key and create accept key
|
|
374
|
+
const key = req.headers['sec-websocket-key'];
|
|
375
|
+
const acceptKey = crypto
|
|
376
|
+
.createHash('sha1')
|
|
377
|
+
.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
|
|
378
|
+
.digest('base64');
|
|
379
|
+
|
|
380
|
+
// Send WebSocket handshake response
|
|
381
|
+
socket.write(
|
|
382
|
+
'HTTP/1.1 101 Switching Protocols\r\n' +
|
|
383
|
+
'Upgrade: websocket\r\n' +
|
|
384
|
+
'Connection: Upgrade\r\n' +
|
|
385
|
+
'Sec-WebSocket-Accept: ' +
|
|
386
|
+
acceptKey +
|
|
387
|
+
'\r\n\r\n'
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
const sessionId = crypto.randomUUID().slice(0, 8);
|
|
391
|
+
|
|
392
|
+
// Create PTY
|
|
393
|
+
const ptyProcess = createPtySession(cols, rows);
|
|
394
|
+
sessions.set(socket, { id: sessionId, pty: ptyProcess });
|
|
395
|
+
|
|
396
|
+
// PTY -> WebSocket
|
|
397
|
+
ptyProcess.onData((data) => {
|
|
398
|
+
if (socket.writable) {
|
|
399
|
+
sendWebSocketFrame(socket, data);
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
404
|
+
sendWebSocketFrame(socket, `\r\n\x1b[33mShell exited (code: ${exitCode})\x1b[0m\r\n`);
|
|
405
|
+
socket.end();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// WebSocket -> PTY
|
|
409
|
+
let buffer = Buffer.alloc(0);
|
|
410
|
+
|
|
411
|
+
socket.on('data', (chunk) => {
|
|
412
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
413
|
+
|
|
414
|
+
while (buffer.length >= 2) {
|
|
415
|
+
const fin = (buffer[0] & 0x80) !== 0;
|
|
416
|
+
const opcode = buffer[0] & 0x0f;
|
|
417
|
+
const masked = (buffer[1] & 0x80) !== 0;
|
|
418
|
+
let payloadLength = buffer[1] & 0x7f;
|
|
419
|
+
|
|
420
|
+
let offset = 2;
|
|
421
|
+
|
|
422
|
+
if (payloadLength === 126) {
|
|
423
|
+
if (buffer.length < 4) break;
|
|
424
|
+
payloadLength = buffer.readUInt16BE(2);
|
|
425
|
+
offset = 4;
|
|
426
|
+
} else if (payloadLength === 127) {
|
|
427
|
+
if (buffer.length < 10) break;
|
|
428
|
+
payloadLength = Number(buffer.readBigUInt64BE(2));
|
|
429
|
+
offset = 10;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const maskKeyOffset = offset;
|
|
433
|
+
if (masked) offset += 4;
|
|
434
|
+
|
|
435
|
+
const totalLength = offset + payloadLength;
|
|
436
|
+
if (buffer.length < totalLength) break;
|
|
437
|
+
|
|
438
|
+
// Handle different opcodes
|
|
439
|
+
if (opcode === 0x8) {
|
|
440
|
+
// Close frame
|
|
441
|
+
socket.end();
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (opcode === 0x1 || opcode === 0x2) {
|
|
446
|
+
// Text or binary frame
|
|
447
|
+
let payload = buffer.slice(offset, totalLength);
|
|
448
|
+
|
|
449
|
+
if (masked) {
|
|
450
|
+
const maskKey = buffer.slice(maskKeyOffset, maskKeyOffset + 4);
|
|
451
|
+
payload = Buffer.from(payload);
|
|
452
|
+
for (let i = 0; i < payload.length; i++) {
|
|
453
|
+
payload[i] ^= maskKey[i % 4];
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const data = payload.toString('utf8');
|
|
458
|
+
|
|
459
|
+
// Check for resize message
|
|
460
|
+
if (data.startsWith('{')) {
|
|
461
|
+
try {
|
|
462
|
+
const msg = JSON.parse(data);
|
|
463
|
+
if (msg.type === 'resize') {
|
|
464
|
+
ptyProcess.resize(msg.cols, msg.rows);
|
|
465
|
+
buffer = buffer.slice(totalLength);
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
} catch (e) {
|
|
469
|
+
// Not JSON, treat as input
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Send to PTY
|
|
474
|
+
ptyProcess.write(data);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
buffer = buffer.slice(totalLength);
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
socket.on('close', () => {
|
|
482
|
+
const session = sessions.get(socket);
|
|
483
|
+
if (session) {
|
|
484
|
+
session.pty.kill();
|
|
485
|
+
sessions.delete(socket);
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
socket.on('error', () => {
|
|
490
|
+
// Ignore socket errors (connection reset, etc.)
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// Send welcome message
|
|
494
|
+
setTimeout(() => {
|
|
495
|
+
const C = '\x1b[1;36m'; // Cyan
|
|
496
|
+
const G = '\x1b[1;32m'; // Green
|
|
497
|
+
const Y = '\x1b[1;33m'; // Yellow
|
|
498
|
+
const R = '\x1b[0m'; // Reset
|
|
499
|
+
sendWebSocketFrame(
|
|
500
|
+
socket,
|
|
501
|
+
`${C}╔══════════════════════════════════════════════════════════════╗${R}\r\n`
|
|
502
|
+
);
|
|
503
|
+
sendWebSocketFrame(
|
|
504
|
+
socket,
|
|
505
|
+
`${C}║${R} ${G}Welcome to ghostty-web!${R} ${C}║${R}\r\n`
|
|
506
|
+
);
|
|
507
|
+
sendWebSocketFrame(
|
|
508
|
+
socket,
|
|
509
|
+
`${C}║${R} ${C}║${R}\r\n`
|
|
510
|
+
);
|
|
511
|
+
sendWebSocketFrame(
|
|
512
|
+
socket,
|
|
513
|
+
`${C}║${R} You have a real shell session with full PTY support. ${C}║${R}\r\n`
|
|
514
|
+
);
|
|
515
|
+
sendWebSocketFrame(
|
|
516
|
+
socket,
|
|
517
|
+
`${C}║${R} Try: ${Y}ls${R}, ${Y}cd${R}, ${Y}top${R}, ${Y}vim${R}, or any command! ${C}║${R}\r\n`
|
|
518
|
+
);
|
|
519
|
+
sendWebSocketFrame(
|
|
520
|
+
socket,
|
|
521
|
+
`${C}╚══════════════════════════════════════════════════════════════╝${R}\r\n\r\n`
|
|
522
|
+
);
|
|
523
|
+
}, 100);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
function sendWebSocketFrame(socket, data) {
|
|
527
|
+
const payload = Buffer.from(data, 'utf8');
|
|
528
|
+
let header;
|
|
529
|
+
|
|
530
|
+
if (payload.length < 126) {
|
|
531
|
+
header = Buffer.alloc(2);
|
|
532
|
+
header[0] = 0x81; // FIN + text frame
|
|
533
|
+
header[1] = payload.length;
|
|
534
|
+
} else if (payload.length < 65536) {
|
|
535
|
+
header = Buffer.alloc(4);
|
|
536
|
+
header[0] = 0x81;
|
|
537
|
+
header[1] = 126;
|
|
538
|
+
header.writeUInt16BE(payload.length, 2);
|
|
539
|
+
} else {
|
|
540
|
+
header = Buffer.alloc(10);
|
|
541
|
+
header[0] = 0x81;
|
|
542
|
+
header[1] = 127;
|
|
543
|
+
header.writeBigUInt64BE(BigInt(payload.length), 2);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
socket.write(Buffer.concat([header, payload]));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ============================================================================
|
|
550
|
+
// Startup
|
|
551
|
+
// ============================================================================
|
|
552
|
+
|
|
553
|
+
httpServer.listen(HTTP_PORT, () => {
|
|
554
|
+
console.log('\n' + '═'.repeat(60));
|
|
555
|
+
console.log(' 🚀 ghostty-web demo server' + (isDev ? ' (dev mode)' : ''));
|
|
556
|
+
console.log('═'.repeat(60));
|
|
557
|
+
console.log(`\n 📺 Open: http://localhost:${HTTP_PORT}`);
|
|
558
|
+
console.log(` 📡 WebSocket PTY: ws://localhost:${WS_PORT}/ws`);
|
|
559
|
+
console.log(` 🐚 Shell: ${getShell()}`);
|
|
560
|
+
console.log(` 📁 Home: ${homedir()}`);
|
|
561
|
+
if (isDev) {
|
|
562
|
+
console.log(` 📦 Using local build: ${distPath}`);
|
|
563
|
+
}
|
|
564
|
+
console.log('\n ⚠️ This server provides shell access.');
|
|
565
|
+
console.log(' Only use for local development.\n');
|
|
566
|
+
console.log('═'.repeat(60));
|
|
567
|
+
console.log(' Press Ctrl+C to stop.\n');
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
wsServer.listen(WS_PORT);
|
|
571
|
+
|
|
572
|
+
// Graceful shutdown
|
|
573
|
+
process.on('SIGINT', () => {
|
|
574
|
+
console.log('\n\nShutting down...');
|
|
575
|
+
for (const [socket, session] of sessions.entries()) {
|
|
576
|
+
session.pty.kill();
|
|
577
|
+
socket.destroy();
|
|
578
|
+
}
|
|
579
|
+
process.exit(0);
|
|
580
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ghostty-web/demo",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Cross-platform demo server for ghostty-web terminal emulator",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ghostty-web-demo": "./bin/demo.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node bin/demo.js",
|
|
11
|
+
"dev": "bun run server/pty-server.ts"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@lydell/node-pty": "^1.0.1",
|
|
15
|
+
"ghostty-web": "^0.2.1"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"bin",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"keywords": [
|
|
22
|
+
"terminal",
|
|
23
|
+
"terminal-emulator",
|
|
24
|
+
"ghostty",
|
|
25
|
+
"demo",
|
|
26
|
+
"pty",
|
|
27
|
+
"shell"
|
|
28
|
+
],
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/coder/ghostty-web.git",
|
|
32
|
+
"directory": "demo"
|
|
33
|
+
},
|
|
34
|
+
"bugs": "https://github.com/coder/ghostty-web/issues",
|
|
35
|
+
"homepage": "https://github.com/coder/ghostty-web/tree/main/demo#readme",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"author": "Coder",
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
}
|
|
41
|
+
}
|