@ghostty-web/demo 0.2.1-next.0.g1680deb

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.
Files changed (3) hide show
  1. package/README.md +50 -0
  2. package/bin/demo.js +580 -0
  3. 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-next.0.g1680deb",
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
+ }