@ghostty-web/demo 0.2.1 → 0.3.0-next.2.gfcdee7f

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 +5 -18
  2. package/bin/demo.js +139 -199
  3. package/package.json +4 -3
package/README.md CHANGED
@@ -5,46 +5,33 @@ Cross-platform demo server for [ghostty-web](https://github.com/coder/ghostty-we
5
5
  ## Quick Start
6
6
 
7
7
  ```bash
8
- npx @ghostty-web/demo
8
+ npx @ghostty-web/demo@next
9
9
  ```
10
10
 
11
11
  This starts a local web server with a fully functional terminal connected to your shell.
12
- Works on **Linux**, **macOS**, and **Windows**.
12
+ Works on **Linux** and **macOS** (no Windows support yet).
13
13
 
14
14
  ## What it does
15
15
 
16
16
  - Starts an HTTP server on port 8080 (configurable via `PORT` env var)
17
17
  - Starts a WebSocket server on port 3001 for PTY communication
18
- - Opens a real shell session (bash, zsh, cmd.exe, or PowerShell)
18
+ - Opens a real shell session (bash, zsh, etc.)
19
19
  - Provides full PTY support (colors, cursor positioning, resize, etc.)
20
20
 
21
21
  ## Usage
22
22
 
23
23
  ```bash
24
24
  # Default (port 8080)
25
- npx @ghostty-web/demo
25
+ npx @ghostty-web/demo@next
26
26
 
27
27
  # Custom port
28
- PORT=3000 npx @ghostty-web/demo
28
+ PORT=3000 npx @ghostty-web/demo@next
29
29
  ```
30
30
 
31
31
  Then open http://localhost:8080 in your browser.
32
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
33
  ## Security Warning
43
34
 
44
35
  ⚠️ **This server provides full shell access.**
45
36
 
46
37
  Only use for local development and demos. Do not expose to untrusted networks.
47
-
48
- ## License
49
-
50
- MIT
package/bin/demo.js CHANGED
@@ -7,7 +7,6 @@
7
7
  * Run with: npx @ghostty-web/demo
8
8
  */
9
9
 
10
- import crypto from 'crypto';
11
10
  import fs from 'fs';
12
11
  import http from 'http';
13
12
  import { homedir } from 'os';
@@ -16,40 +15,58 @@ import { fileURLToPath } from 'url';
16
15
 
17
16
  // Node-pty for cross-platform PTY support
18
17
  import pty from '@lydell/node-pty';
18
+ // WebSocket server
19
+ import { WebSocketServer } from 'ws';
19
20
 
20
21
  const __filename = fileURLToPath(import.meta.url);
21
22
  const __dirname = path.dirname(__filename);
22
23
 
23
- const HTTP_PORT = process.env.PORT || 8080;
24
+ const DEV_MODE = process.argv.includes('--dev');
25
+ const HTTP_PORT = process.env.PORT || (DEV_MODE ? 8000 : 8080);
24
26
  const WS_PORT = 3001;
25
27
 
26
28
  // ============================================================================
27
29
  // Locate ghostty-web assets
28
30
  // ============================================================================
29
31
 
32
+ import { createRequire } from 'module';
33
+ const require = createRequire(import.meta.url);
34
+
30
35
  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
- }
36
+ // In dev mode, we use Vite - no need to find built assets
37
+ if (DEV_MODE) {
38
+ const repoRoot = path.join(__dirname, '..', '..');
39
+ const wasmPath = path.join(repoRoot, 'ghostty-vt.wasm');
40
+ if (!fs.existsSync(wasmPath)) {
41
+ console.error('Error: ghostty-vt.wasm not found.');
42
+ console.error('Run: bun run build:wasm');
43
+ process.exit(1);
52
44
  }
45
+ return { distPath: null, wasmPath, repoRoot };
46
+ }
47
+
48
+ // First, check for local development (repo root dist/)
49
+ const localDist = path.join(__dirname, '..', '..', 'dist');
50
+ const localJs = path.join(localDist, 'ghostty-web.js');
51
+ const localWasm = path.join(__dirname, '..', '..', 'ghostty-vt.wasm');
52
+
53
+ if (fs.existsSync(localJs) && fs.existsSync(localWasm)) {
54
+ return { distPath: localDist, wasmPath: localWasm, repoRoot: path.join(__dirname, '..', '..') };
55
+ }
56
+
57
+ // Use require.resolve to find the installed ghostty-web package
58
+ try {
59
+ const ghosttyWebMain = require.resolve('ghostty-web');
60
+ // Strip dist/... from path to get package root (regex already gives us the root)
61
+ const ghosttyWebRoot = ghosttyWebMain.replace(/[/\\]dist[/\\].*$/, '');
62
+ const distPath = path.join(ghosttyWebRoot, 'dist');
63
+ const wasmPath = path.join(ghosttyWebRoot, 'ghostty-vt.wasm');
64
+
65
+ if (fs.existsSync(path.join(distPath, 'ghostty-web.js')) && fs.existsSync(wasmPath)) {
66
+ return { distPath, wasmPath, repoRoot: null };
67
+ }
68
+ } catch (e) {
69
+ // require.resolve failed, package not found
53
70
  }
54
71
 
55
72
  console.error('Error: Could not find ghostty-web package.');
@@ -59,10 +76,7 @@ function findGhosttyWeb() {
59
76
  process.exit(1);
60
77
  }
61
78
 
62
- const { distPath, wasmPath } = findGhosttyWeb();
63
- const isDev =
64
- distPath.includes(path.join('demo', '..', 'dist')) ||
65
- distPath === path.join(__dirname, '..', '..', 'dist');
79
+ const { distPath, wasmPath, repoRoot } = findGhosttyWeb();
66
80
 
67
81
  // ============================================================================
68
82
  // HTML Template
@@ -157,14 +171,22 @@ const HTML_TEMPLATE = `<!doctype html>
157
171
  }
158
172
 
159
173
  .terminal-content {
160
- padding: 0;
161
- min-height: 400px;
162
- height: 60vh;
174
+ height: 600px;
175
+ padding: 16px;
176
+ background: #1e1e1e;
177
+ position: relative;
178
+ overflow: hidden;
163
179
  }
164
180
 
165
- #terminal {
166
- width: 100%;
167
- height: 100%;
181
+ /* Ensure terminal canvas can handle scrolling */
182
+ .terminal-content canvas {
183
+ display: block;
184
+ }
185
+
186
+ @media (max-width: 768px) {
187
+ .terminal-content {
188
+ height: 500px;
189
+ }
168
190
  }
169
191
  </style>
170
192
  </head>
@@ -176,25 +198,28 @@ const HTML_TEMPLATE = `<!doctype html>
176
198
  <div class="light yellow"></div>
177
199
  <div class="light green"></div>
178
200
  </div>
179
- <span class="title">ghostty-web — shell</span>
201
+ <span class="title">ghostty-web</span>
180
202
  <div class="connection-status">
181
203
  <div class="status-dot connecting" id="status-dot"></div>
182
204
  <span id="status-text">Connecting...</span>
183
205
  </div>
184
206
  </div>
185
- <div class="terminal-content">
186
- <div id="terminal"></div>
187
- </div>
207
+ <div class="terminal-content" id="terminal"></div>
188
208
  </div>
189
209
 
190
210
  <script type="module">
191
- import { Terminal, FitAddon } from '/dist/ghostty-web.js';
211
+ import { init, Terminal, FitAddon } from '/dist/ghostty-web.js';
192
212
 
213
+ await init();
193
214
  const term = new Terminal({
194
215
  cols: 80,
195
216
  rows: 24,
196
217
  fontFamily: 'JetBrains Mono, Menlo, Monaco, monospace',
197
218
  fontSize: 14,
219
+ theme: {
220
+ background: '#1e1e1e',
221
+ foreground: '#d4d4d4',
222
+ },
198
223
  });
199
224
 
200
225
  const fitAddon = new FitAddon();
@@ -203,6 +228,7 @@ const HTML_TEMPLATE = `<!doctype html>
203
228
  const container = document.getElementById('terminal');
204
229
  await term.open(container);
205
230
  fitAddon.fit();
231
+ fitAddon.observeResize(); // Auto-fit when container resizes
206
232
 
207
233
  // Status elements
208
234
  const statusDot = document.getElementById('status-dot');
@@ -249,13 +275,17 @@ const HTML_TEMPLATE = `<!doctype html>
249
275
  }
250
276
  });
251
277
 
252
- // Handle resize
253
- window.addEventListener('resize', () => {
254
- fitAddon.fit();
278
+ // Handle resize - notify PTY when terminal dimensions change
279
+ term.onResize(({ cols, rows }) => {
255
280
  if (ws && ws.readyState === WebSocket.OPEN) {
256
- ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
281
+ ws.send(JSON.stringify({ type: 'resize', cols, rows }));
257
282
  }
258
283
  });
284
+
285
+ // Also handle window resize (for browsers that don't trigger ResizeObserver on window resize)
286
+ window.addEventListener('resize', () => {
287
+ fitAddon.fit();
288
+ });
259
289
  </script>
260
290
  </body>
261
291
  </html>`;
@@ -325,7 +355,7 @@ function serveFile(filePath, res) {
325
355
  }
326
356
 
327
357
  // ============================================================================
328
- // WebSocket Server (using native WebSocket upgrade)
358
+ // WebSocket Server (using ws package)
329
359
  // ============================================================================
330
360
 
331
361
  const sessions = new Map();
@@ -356,225 +386,135 @@ function createPtySession(cols, rows) {
356
386
  return ptyProcess;
357
387
  }
358
388
 
359
- // WebSocket server
360
- const wsServer = http.createServer();
389
+ // WebSocket server using ws package
390
+ const wss = new WebSocketServer({ port: WS_PORT, path: '/ws' });
361
391
 
362
- wsServer.on('upgrade', (req, socket, head) => {
392
+ wss.on('connection', (ws, req) => {
363
393
  const url = new URL(req.url, `http://${req.headers.host}`);
364
-
365
- if (url.pathname !== '/ws') {
366
- socket.destroy();
367
- return;
368
- }
369
-
370
394
  const cols = Number.parseInt(url.searchParams.get('cols') || '80');
371
395
  const rows = Number.parseInt(url.searchParams.get('rows') || '24');
372
396
 
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
397
  // Create PTY
393
398
  const ptyProcess = createPtySession(cols, rows);
394
- sessions.set(socket, { id: sessionId, pty: ptyProcess });
399
+ sessions.set(ws, { pty: ptyProcess });
395
400
 
396
401
  // PTY -> WebSocket
397
402
  ptyProcess.onData((data) => {
398
- if (socket.writable) {
399
- sendWebSocketFrame(socket, data);
403
+ if (ws.readyState === ws.OPEN) {
404
+ ws.send(data);
400
405
  }
401
406
  });
402
407
 
403
408
  ptyProcess.onExit(({ exitCode }) => {
404
- sendWebSocketFrame(socket, `\r\n\x1b[33mShell exited (code: ${exitCode})\x1b[0m\r\n`);
405
- socket.end();
409
+ if (ws.readyState === ws.OPEN) {
410
+ ws.send(`\r\n\x1b[33mShell exited (code: ${exitCode})\x1b[0m\r\n`);
411
+ ws.close();
412
+ }
406
413
  });
407
414
 
408
415
  // 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
- }
416
+ ws.on('message', (data) => {
417
+ const message = data.toString('utf8');
418
+
419
+ // Check for resize message
420
+ if (message.startsWith('{')) {
421
+ try {
422
+ const msg = JSON.parse(message);
423
+ if (msg.type === 'resize') {
424
+ ptyProcess.resize(msg.cols, msg.rows);
425
+ return;
471
426
  }
472
-
473
- // Send to PTY
474
- ptyProcess.write(data);
427
+ } catch (e) {
428
+ // Not JSON, treat as input
475
429
  }
476
-
477
- buffer = buffer.slice(totalLength);
478
430
  }
431
+
432
+ // Send to PTY
433
+ ptyProcess.write(message);
479
434
  });
480
435
 
481
- socket.on('close', () => {
482
- const session = sessions.get(socket);
436
+ ws.on('close', () => {
437
+ const session = sessions.get(ws);
483
438
  if (session) {
484
439
  session.pty.kill();
485
- sessions.delete(socket);
440
+ sessions.delete(ws);
486
441
  }
487
442
  });
488
443
 
489
- socket.on('error', () => {
444
+ ws.on('error', () => {
490
445
  // Ignore socket errors (connection reset, etc.)
491
446
  });
492
447
 
493
448
  // Send welcome message
494
449
  setTimeout(() => {
450
+ if (ws.readyState !== ws.OPEN) return;
495
451
  const C = '\x1b[1;36m'; // Cyan
496
452
  const G = '\x1b[1;32m'; // Green
497
453
  const Y = '\x1b[1;33m'; // Yellow
498
454
  const R = '\x1b[0m'; // Reset
499
- sendWebSocketFrame(
500
- socket,
501
- `${C}╔══════════════════════════════════════════════════════════════╗${R}\r\n`
502
- );
503
- sendWebSocketFrame(
504
- socket,
455
+ ws.send(`${C}╔══════════════════════════════════════════════════════════════╗${R}\r\n`);
456
+ ws.send(
505
457
  `${C}║${R} ${G}Welcome to ghostty-web!${R} ${C}║${R}\r\n`
506
458
  );
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,
459
+ ws.send(`${C}║${R} ${C}║${R}\r\n`);
460
+ ws.send(`${C}║${R} You have a real shell session with full PTY support. ${C}║${R}\r\n`);
461
+ ws.send(
517
462
  `${C}║${R} Try: ${Y}ls${R}, ${Y}cd${R}, ${Y}top${R}, ${Y}vim${R}, or any command! ${C}║${R}\r\n`
518
463
  );
519
- sendWebSocketFrame(
520
- socket,
521
- `${C}╚══════════════════════════════════════════════════════════════╝${R}\r\n\r\n`
522
- );
464
+ ws.send(`${C}╚══════════════════════════════════════════════════════════════╝${R}\r\n\r\n`);
523
465
  }, 100);
524
466
  });
525
467
 
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
468
  // ============================================================================
550
469
  // Startup
551
470
  // ============================================================================
552
471
 
553
- httpServer.listen(HTTP_PORT, () => {
472
+ function printBanner(url) {
554
473
  console.log('\n' + '═'.repeat(60));
555
- console.log(' 🚀 ghostty-web demo server' + (isDev ? ' (dev mode)' : ''));
474
+ console.log(' 🚀 ghostty-web demo server' + (DEV_MODE ? ' (dev mode)' : ''));
556
475
  console.log('═'.repeat(60));
557
- console.log(`\n 📺 Open: http://localhost:${HTTP_PORT}`);
476
+ console.log(`\n 📺 Open: ${url}`);
558
477
  console.log(` 📡 WebSocket PTY: ws://localhost:${WS_PORT}/ws`);
559
478
  console.log(` 🐚 Shell: ${getShell()}`);
560
479
  console.log(` 📁 Home: ${homedir()}`);
561
- if (isDev) {
480
+ if (DEV_MODE) {
481
+ console.log(` 🔥 Hot reload enabled via Vite`);
482
+ } else if (repoRoot) {
562
483
  console.log(` 📦 Using local build: ${distPath}`);
563
484
  }
564
485
  console.log('\n ⚠️ This server provides shell access.');
565
486
  console.log(' Only use for local development.\n');
566
487
  console.log('═'.repeat(60));
567
488
  console.log(' Press Ctrl+C to stop.\n');
568
- });
569
-
570
- wsServer.listen(WS_PORT);
489
+ }
571
490
 
572
491
  // Graceful shutdown
573
492
  process.on('SIGINT', () => {
574
493
  console.log('\n\nShutting down...');
575
- for (const [socket, session] of sessions.entries()) {
494
+ for (const [ws, session] of sessions.entries()) {
576
495
  session.pty.kill();
577
- socket.destroy();
496
+ ws.close();
578
497
  }
498
+ wss.close();
579
499
  process.exit(0);
580
500
  });
501
+
502
+ // Start HTTP/Vite server
503
+ if (DEV_MODE) {
504
+ // Dev mode: use Vite for hot reload
505
+ const { createServer } = await import('vite');
506
+ const vite = await createServer({
507
+ root: repoRoot,
508
+ server: {
509
+ port: HTTP_PORT,
510
+ strictPort: true,
511
+ },
512
+ });
513
+ await vite.listen();
514
+ printBanner(`http://localhost:${HTTP_PORT}/demo/`);
515
+ } else {
516
+ // Production mode: static file server
517
+ httpServer.listen(HTTP_PORT, () => {
518
+ printBanner(`http://localhost:${HTTP_PORT}`);
519
+ });
520
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghostty-web/demo",
3
- "version": "0.2.1",
3
+ "version": "0.3.0-next.2.gfcdee7f",
4
4
  "description": "Cross-platform demo server for ghostty-web terminal emulator",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,11 +8,12 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/demo.js",
11
- "dev": "bun run server/pty-server.ts"
11
+ "dev": "node bin/demo.js --dev"
12
12
  },
13
13
  "dependencies": {
14
14
  "@lydell/node-pty": "^1.0.1",
15
- "ghostty-web": "^0.2.1"
15
+ "ghostty-web": "0.3.0-next.2.gfcdee7f",
16
+ "ws": "^8.18.0"
16
17
  },
17
18
  "files": [
18
19
  "bin",