From 973b47d5aa579d0f8362493b2a2a2d54bb128959 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 25 Nov 2025 14:27:56 +0000 Subject: [PATCH 1/3] feat: create @ghostty-web/demo package - Remove bin from root package (ghostty-web library stays zero-dependency) - Create @ghostty-web/demo package in demo/ directory - Cross-platform PTY support via @lydell/node-pty (Linux, macOS, Windows) - Auto-detects local dev build vs npm installed dependency - Single command to run demo: npx @ghostty-web/demo - Add publish-demo job to CI workflow Usage: npx @ghostty-web/demo # For end users cd demo && npm start # For local development --- .github/workflows/publish.yml | 71 ++++ README.md | 19 +- bin/ghostty-web.js | 669 ---------------------------------- demo/README.md | 50 +++ demo/bin/demo.js | 580 +++++++++++++++++++++++++++++ demo/bun.lock | 30 ++ demo/package-lock.json | 118 ++++++ demo/package.json | 41 +++ demo/server/package.json | 14 - package.json | 4 - 10 files changed, 905 insertions(+), 691 deletions(-) delete mode 100755 bin/ghostty-web.js create mode 100644 demo/README.md create mode 100644 demo/bin/demo.js create mode 100644 demo/bun.lock create mode 100644 demo/package-lock.json create mode 100644 demo/package.json delete mode 100644 demo/server/package.json diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2670db9..78270fd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -168,3 +168,74 @@ jobs: if: steps.check-exists.outputs.exists == 'true' && steps.detect.outputs.is_tag != 'true' run: | echo "ā­ļø Pre-release version already exists, skipping" + + publish-demo: + name: publish @ghostty-web/demo to npm + runs-on: ubuntu-latest + needs: publish + if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'push' }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + fetch-depth: 0 + + - name: Setup Node.js for npm + uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + + - run: npm install -g npm@latest + + - name: Detect trigger type + id: detect + run: | + if [[ $GITHUB_REF == refs/tags/* ]]; then + echo "is_tag=true" >> $GITHUB_OUTPUT + echo "trigger_name=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + else + echo "is_tag=false" >> $GITHUB_OUTPUT + echo "trigger_name=${GITHUB_REF#refs/heads/}" >> $GITHUB_OUTPUT + fi + + - name: Generate demo version + id: version + working-directory: demo + run: | + BASE_VERSION=$(jq -r .version package.json) + + if [[ "${{ steps.detect.outputs.is_tag }}" == "true" ]]; then + NPM_VERSION="${BASE_VERSION}" + NPM_TAG="latest" + else + GIT_COMMIT=$(git rev-parse --short HEAD) + COMMITS_SINCE_TAG=$(git rev-list --count HEAD ^$(git describe --tags --abbrev=0 2>/dev/null || echo HEAD) 2>/dev/null || echo "0") + NPM_VERSION="${BASE_VERSION}-next.${COMMITS_SINCE_TAG}.g${GIT_COMMIT}" + NPM_TAG="next" + fi + + echo "version=${NPM_VERSION}" >> $GITHUB_OUTPUT + echo "tag=${NPM_TAG}" >> $GITHUB_OUTPUT + + node -e "const fs = require('fs'); const pkg = JSON.parse(fs.readFileSync('package.json')); pkg.version = '${NPM_VERSION}'; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');" + echo "Updated demo/server/package.json to version ${NPM_VERSION}" + + - name: Check if demo version exists + id: check-exists + working-directory: demo + run: | + PACKAGE_NAME=$(node -p "require('./package.json').name") + VERSION="${{ steps.version.outputs.version }}" + + if npm view "${PACKAGE_NAME}@${VERSION}" version &>/dev/null; then + echo "exists=true" >> $GITHUB_OUTPUT + else + echo "exists=false" >> $GITHUB_OUTPUT + fi + + - name: Publish demo to npm + if: steps.check-exists.outputs.exists == 'false' + working-directory: demo + run: npm publish --tag ${{ steps.version.outputs.tag }} --provenance --access public diff --git a/README.md b/README.md index 3de3ad1..76b29a9 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,16 @@ cases it is a drop-in replacement for xterm.js. ## Live Demo -You can try ghostty-web yourself: +Try ghostty-web instantly with: + +```bash +npx @ghostty-web/demo +``` + +This starts a local demo server with a real shell session. Works on Linux, macOS, and Windows. + +
+Development setup (building from source) > [!NOTE] > Requires Zig and Bun, see [Development](#development) @@ -21,14 +30,16 @@ bun install bun run build # Builds the WASM module and library # Terminal 1: Start PTY Server -cd demo/server +cd demo bun install -bun run start +bun run dev # Terminal 2: Start web server -bun dev # http://localhost:8000/demo/ +bun run dev # http://localhost:8000/demo/ ``` +
+ ## Getting Started Install the module via npm diff --git a/bin/ghostty-web.js b/bin/ghostty-web.js deleted file mode 100755 index ef71ec5..0000000 --- a/bin/ghostty-web.js +++ /dev/null @@ -1,669 +0,0 @@ -#!/usr/bin/env node - -/** - * ghostty-web demo launcher - * - * Starts a local HTTP server with WebSocket PTY support. - * Run with: npx ghostty-web - */ - -import { spawn } from 'child_process'; -import { exec } from 'child_process'; -import crypto from 'crypto'; -import fs from 'fs'; -import http from 'http'; -import { homedir } from 'os'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const packageRoot = path.join(__dirname, '..'); - -const PORT = 8080; - -// ============================================================================ -// HTML Template (inline everything) -// ============================================================================ - -const HTML_TEMPLATE = ` - - - - - ghostty-web - - - -
-
-
- - - -
-
ghostty-web
-
- - Disconnected -
-
-
-
- - - -`; - -// ============================================================================ -// Minimal WebSocket Implementation -// ============================================================================ - -class MinimalWebSocket { - constructor(socket) { - this.socket = socket; - this.buffer = Buffer.alloc(0); - this.listeners = {}; - - socket.on('data', (data) => this.handleData(data)); - socket.on('close', () => this.emit('close')); - socket.on('error', (err) => this.emit('error', err)); - } - - handleData(data) { - this.buffer = Buffer.concat([this.buffer, data]); - - while (this.buffer.length >= 2) { - const frame = this.parseFrame(); - if (!frame) break; - - if (frame.opcode === 0x01) { - // Text frame - this.emit('message', frame.payload.toString('utf8')); - } else if (frame.opcode === 0x08) { - // Close frame - this.close(); - break; - } else if (frame.opcode === 0x09) { - // Ping frame - respond with pong - this.sendPong(frame.payload); - } - } - } - - parseFrame() { - if (this.buffer.length < 2) return null; - - const byte1 = this.buffer[0]; - const byte2 = this.buffer[1]; - - const fin = (byte1 & 0x80) !== 0; - const opcode = byte1 & 0x0f; - const masked = (byte2 & 0x80) !== 0; - let payloadLen = byte2 & 0x7f; - - let offset = 2; - - // Extended payload length - if (payloadLen === 126) { - if (this.buffer.length < 4) return null; - payloadLen = this.buffer.readUInt16BE(2); - offset = 4; - } else if (payloadLen === 127) { - if (this.buffer.length < 10) return null; - payloadLen = Number(this.buffer.readBigUInt64BE(2)); - offset = 10; - } - - // Check if we have full frame - const maskLen = masked ? 4 : 0; - const totalLen = offset + maskLen + payloadLen; - if (this.buffer.length < totalLen) return null; - - // Read mask and payload - let payload; - if (masked) { - const mask = this.buffer.slice(offset, offset + 4); - const maskedPayload = this.buffer.slice(offset + 4, totalLen); - payload = Buffer.alloc(payloadLen); - for (let i = 0; i < payloadLen; i++) { - payload[i] = maskedPayload[i] ^ mask[i % 4]; - } - } else { - payload = this.buffer.slice(offset, totalLen); - } - - // Consume frame from buffer - this.buffer = this.buffer.slice(totalLen); - - return { fin, opcode, payload }; - } - - send(data) { - const payload = Buffer.from(data, 'utf8'); - const len = payload.length; - - let frame; - if (len < 126) { - frame = Buffer.alloc(2 + len); - frame[0] = 0x81; // FIN + text opcode - frame[1] = len; - payload.copy(frame, 2); - } else if (len < 65536) { - frame = Buffer.alloc(4 + len); - frame[0] = 0x81; - frame[1] = 126; - frame.writeUInt16BE(len, 2); - payload.copy(frame, 4); - } else { - frame = Buffer.alloc(10 + len); - frame[0] = 0x81; - frame[1] = 127; - frame.writeBigUInt64BE(BigInt(len), 2); - payload.copy(frame, 10); - } - - try { - this.socket.write(frame); - } catch (err) { - // Socket may be closed - } - } - - sendPong(data) { - const len = data.length; - const frame = Buffer.alloc(2 + len); - frame[0] = 0x8a; // FIN + pong opcode - frame[1] = len; - data.copy(frame, 2); - - try { - this.socket.write(frame); - } catch (err) { - // Socket may be closed - } - } - - close() { - const frame = Buffer.from([0x88, 0x00]); // Close frame - try { - this.socket.write(frame); - this.socket.end(); - } catch (err) { - // Socket may already be closed - } - } - - on(event, handler) { - if (!this.listeners[event]) this.listeners[event] = []; - this.listeners[event].push(handler); - } - - emit(event, data) { - const handlers = this.listeners[event] || []; - for (const h of handlers) { - try { - h(data); - } catch (err) { - console.error('WebSocket event handler error:', err); - } - } - } -} - -// ============================================================================ -// PTY Session Handler -// ============================================================================ - -function handlePTYSession(ws, req) { - const url = new URL(req.url, 'http://localhost'); - const cols = Number.parseInt(url.searchParams.get('cols')) || 80; - const rows = Number.parseInt(url.searchParams.get('rows')) || 24; - - const shell = process.env.SHELL || '/bin/bash'; - - // Use 'script' command to create a real PTY (same as demo/server) - // This is the key to getting proper shell behavior without node-pty - const ptyProcess = spawn('script', ['-qfc', shell, '/dev/null'], { - cwd: homedir(), - env: { - ...process.env, - TERM: 'xterm-256color', - COLORTERM: 'truecolor', - COLUMNS: String(cols), - LINES: String(rows), - }, - }); - - // Set PTY size via stty command (same as demo/server) - // This ensures the shell knows the correct terminal dimensions - setTimeout(() => { - ptyProcess.stdin.write(`stty cols ${cols} rows ${rows}; clear\n`); - }, 100); - - // PTY -> WebSocket - ptyProcess.stdout.on('data', (data) => { - try { - let str = data.toString(); - - // Filter out OSC sequences that cause artifacts (same as demo/server) - str = str.replace(/\x1b\]0;[^\x07]*\x07/g, ''); // OSC 0 - icon + title - str = str.replace(/\x1b\]1;[^\x07]*\x07/g, ''); // OSC 1 - icon - str = str.replace(/\x1b\]2;[^\x07]*\x07/g, ''); // OSC 2 - title - - ws.send(str); - } catch (err) { - // WebSocket may be closed - } - }); - - ptyProcess.stderr.on('data', (data) => { - try { - // Send stderr in red (same as demo/server) - ws.send(`\\x1b[31m${data.toString()}\\x1b[0m`); - } catch (err) { - // WebSocket may be closed - } - }); - - // WebSocket -> PTY - ws.on('message', (data) => { - // Check if it's a resize message (must be object with type field) - try { - const msg = JSON.parse(data); - if (msg && typeof msg === 'object' && msg.type === 'resize') { - // Resize PTY using stty command (same as demo/server) - console.log(`[PTY resize] ${msg.cols}x${msg.rows}`); - ptyProcess.stdin.write(`stty cols ${msg.cols} rows ${msg.rows}\n`); - return; - } - } catch { - // Not JSON, will be treated as input below - } - - // Treat as terminal input - try { - ptyProcess.stdin.write(data); - } catch (err) { - // Process may be closed - } - }); - - // Cleanup - ws.on('close', () => { - try { - ptyProcess.kill(); - } catch (err) { - // Process may already be terminated - } - }); - - ptyProcess.on('exit', () => { - try { - ws.close(); - } catch (err) { - // WebSocket may already be closed - } - }); - - ptyProcess.on('error', (err) => { - console.error('PTY process error:', err); - try { - ws.close(); - } catch (e) { - // Ignore - } - }); -} - -// ============================================================================ -// HTTP Server -// ============================================================================ - -const server = http.createServer((req, res) => { - const routes = { - '/': { content: HTML_TEMPLATE, type: 'text/html' }, - '/ghostty-web.js': { - file: path.join(packageRoot, 'dist', 'ghostty-web.js'), - type: 'application/javascript', - }, - '/ghostty-vt.wasm': { - file: path.join(packageRoot, 'ghostty-vt.wasm'), - type: 'application/wasm', - }, - '/__vite-browser-external-2447137e.js': { - file: path.join(packageRoot, 'dist', '__vite-browser-external-2447137e.js'), - type: 'application/javascript', - }, - }; - - const route = routes[req.url]; - - if (!route) { - res.writeHead(404, { 'Content-Type': 'text/plain' }); - res.end('Not found'); - return; - } - - if (route.content) { - res.writeHead(200, { 'Content-Type': route.type }); - res.end(route.content); - } else if (route.file) { - try { - const content = fs.readFileSync(route.file); - res.writeHead(200, { 'Content-Type': route.type }); - res.end(content); - } catch (err) { - console.error('Error reading file:', route.file, err.message); - res.writeHead(500, { 'Content-Type': 'text/plain' }); - res.end('Error reading file. Make sure you have run: npm run build'); - } - } -}); - -// ============================================================================ -// WebSocket Upgrade Handler -// ============================================================================ - -server.on('upgrade', (req, socket, head) => { - if (req.url.startsWith('/ws')) { - const key = req.headers['sec-websocket-key']; - if (!key) { - socket.end('HTTP/1.1 400 Bad Request\r\n\r\n'); - return; - } - - const hash = crypto - .createHash('sha1') - .update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11') - .digest('base64'); - - socket.write( - 'HTTP/1.1 101 Switching Protocols\r\n' + - 'Upgrade: websocket\r\n' + - 'Connection: Upgrade\r\n' + - `Sec-WebSocket-Accept: ${hash}\r\n\r\n` - ); - - const ws = new MinimalWebSocket(socket); - handlePTYSession(ws, req); - } else { - socket.end('HTTP/1.1 404 Not Found\r\n\r\n'); - } -}); - -// ============================================================================ -// Startup & Cleanup -// ============================================================================ - -function openBrowser(url) { - const cmd = - process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'; - - exec(`${cmd} ${url}`, (err) => { - if (err) { - // Silently fail if browser can't be opened - } - }); -} - -const activeSessions = new Set(); - -server.on('upgrade', (req, socket) => { - activeSessions.add(socket); - socket.on('close', () => activeSessions.delete(socket)); -}); - -function cleanup() { - console.log('\n\nšŸ‘‹ Shutting down...'); - - // Close all active WebSocket connections - for (const socket of activeSessions) { - try { - socket.end(); - } catch (err) { - // Ignore errors during cleanup - } - } - - server.close(() => { - console.log('āœ“ Server closed'); - process.exit(0); - }); - - // Force exit after 2 seconds - setTimeout(() => { - process.exit(0); - }, 2000); -} - -process.on('SIGINT', cleanup); -process.on('SIGTERM', cleanup); - -// Start server -server.listen(PORT, () => { - console.log(''); - console.log('šŸš€ ghostty-web demo'); - console.log(''); - console.log(` āœ“ http://localhost:${PORT}`); - console.log(''); - console.log('šŸ“ Note: This demo uses basic shell I/O (not full PTY).'); - console.log(' For full features, see: https://github.com/coder/ghostty-web'); - console.log(''); - console.log('Press Ctrl+C to stop'); - console.log(''); - - // Auto-open browser after a short delay - setTimeout(() => { - openBrowser(`http://localhost:${PORT}`); - }, 500); -}); - -server.on('error', (err) => { - if (err.code === 'EADDRINUSE') { - console.error(`\nāŒ Error: Port ${PORT} is already in use.`); - console.error(' Please close the other application or try a different port.\n'); - process.exit(1); - } else { - console.error('\nāŒ Server error:', err.message, '\n'); - process.exit(1); - } -}); diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000..b6a3a6c --- /dev/null +++ b/demo/README.md @@ -0,0 +1,50 @@ +# @ghostty-web/demo + +Cross-platform demo server for [ghostty-web](https://github.com/coder/ghostty-web) terminal emulator. + +## Quick Start + +```bash +npx @ghostty-web/demo +``` + +This starts a local web server with a fully functional terminal connected to your shell. +Works on **Linux**, **macOS**, and **Windows**. + +## What it does + +- Starts an HTTP server on port 8080 (configurable via `PORT` env var) +- Starts a WebSocket server on port 3001 for PTY communication +- Opens a real shell session (bash, zsh, cmd.exe, or PowerShell) +- Provides full PTY support (colors, cursor positioning, resize, etc.) + +## Usage + +```bash +# Default (port 8080) +npx @ghostty-web/demo + +# Custom port +PORT=3000 npx @ghostty-web/demo +``` + +Then open http://localhost:8080 in your browser. + +## Features + +- šŸ–„ļø Real shell sessions with full PTY support +- šŸŽØ True color (24-bit) and 256 color support +- āŒØļø Full keyboard support including special keys +- šŸ“ Dynamic terminal resizing +- šŸ”„ Auto-reconnection on disconnect +- 🌐 Cross-platform (Linux, macOS, Windows) + +## Security Warning + +āš ļø **This server provides full shell access.** + +Only use for local development and demos. Do not expose to untrusted networks. + +## License + +MIT diff --git a/demo/bin/demo.js b/demo/bin/demo.js new file mode 100644 index 0000000..75d2c58 --- /dev/null +++ b/demo/bin/demo.js @@ -0,0 +1,580 @@ +#!/usr/bin/env node + +/** + * @ghostty-web/demo - Cross-platform demo server + * + * Starts a local HTTP server with WebSocket PTY support. + * Run with: npx @ghostty-web/demo + */ + +import crypto from 'crypto'; +import fs from 'fs'; +import http from 'http'; +import { homedir } from 'os'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +// Node-pty for cross-platform PTY support +import pty from '@lydell/node-pty'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const HTTP_PORT = process.env.PORT || 8080; +const WS_PORT = 3001; + +// ============================================================================ +// Locate ghostty-web assets +// ============================================================================ + +function findGhosttyWeb() { + const possiblePaths = [ + // Development: running from repo root (demo/bin/demo.js -> ../../dist) + path.join(__dirname, '..', '..', 'dist'), + // When installed as dependency (demo/node_modules/ghostty-web/dist) + path.join(__dirname, '..', 'node_modules', 'ghostty-web', 'dist'), + // When in a monorepo or hoisted + path.join(__dirname, '..', '..', 'node_modules', 'ghostty-web', 'dist'), + path.join(__dirname, '..', '..', '..', 'node_modules', 'ghostty-web', 'dist'), + ]; + + for (const p of possiblePaths) { + const jsPath = path.join(p, 'ghostty-web.js'); + if (fs.existsSync(jsPath)) { + // Find WASM file - check both dist/ and parent directory + let wasmPath = path.join(p, 'ghostty-vt.wasm'); + if (!fs.existsSync(wasmPath)) { + wasmPath = path.join(path.dirname(p), 'ghostty-vt.wasm'); + } + if (fs.existsSync(wasmPath)) { + return { distPath: p, wasmPath }; + } + } + } + + console.error('Error: Could not find ghostty-web package.'); + console.error(''); + console.error('If developing locally, run: bun run build'); + console.error('If using npx, the package should install automatically.'); + process.exit(1); +} + +const { distPath, wasmPath } = findGhosttyWeb(); +const isDev = + distPath.includes(path.join('demo', '..', 'dist')) || + distPath === path.join(__dirname, '..', '..', 'dist'); + +// ============================================================================ +// HTML Template +// ============================================================================ + +const HTML_TEMPLATE = ` + + + + + ghostty-web + + + +
+
+
+
+
+
+
+ ghostty-web — shell +
+
+ Connecting... +
+
+
+
+
+
+ + + +`; + +// ============================================================================ +// MIME Types +// ============================================================================ + +const MIME_TYPES = { + '.html': 'text/html', + '.js': 'application/javascript', + '.mjs': 'application/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.wasm': 'application/wasm', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', +}; + +// ============================================================================ +// HTTP Server +// ============================================================================ + +const httpServer = http.createServer((req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const pathname = url.pathname; + + // Serve index page + if (pathname === '/' || pathname === '/index.html') { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(HTML_TEMPLATE); + return; + } + + // Serve dist files + if (pathname.startsWith('/dist/')) { + const filePath = path.join(distPath, pathname.slice(6)); + serveFile(filePath, res); + return; + } + + // Serve WASM file + if (pathname === '/ghostty-vt.wasm') { + serveFile(wasmPath, res); + return; + } + + // 404 + res.writeHead(404); + res.end('Not Found'); +}); + +function serveFile(filePath, res) { + const ext = path.extname(filePath); + const contentType = MIME_TYPES[ext] || 'application/octet-stream'; + + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404); + res.end('Not Found'); + return; + } + res.writeHead(200, { 'Content-Type': contentType }); + res.end(data); + }); +} + +// ============================================================================ +// WebSocket Server (using native WebSocket upgrade) +// ============================================================================ + +const sessions = new Map(); + +function getShell() { + if (process.platform === 'win32') { + return process.env.COMSPEC || 'cmd.exe'; + } + return process.env.SHELL || '/bin/bash'; +} + +function createPtySession(cols, rows) { + const shell = getShell(); + const shellArgs = process.platform === 'win32' ? [] : []; + + const ptyProcess = pty.spawn(shell, shellArgs, { + name: 'xterm-256color', + cols: cols, + rows: rows, + cwd: homedir(), + env: { + ...process.env, + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + }, + }); + + return ptyProcess; +} + +// WebSocket server +const wsServer = http.createServer(); + +wsServer.on('upgrade', (req, socket, head) => { + const url = new URL(req.url, `http://${req.headers.host}`); + + if (url.pathname !== '/ws') { + socket.destroy(); + return; + } + + const cols = Number.parseInt(url.searchParams.get('cols') || '80'); + const rows = Number.parseInt(url.searchParams.get('rows') || '24'); + + // Parse WebSocket key and create accept key + const key = req.headers['sec-websocket-key']; + const acceptKey = crypto + .createHash('sha1') + .update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11') + .digest('base64'); + + // Send WebSocket handshake response + socket.write( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + 'Sec-WebSocket-Accept: ' + + acceptKey + + '\r\n\r\n' + ); + + const sessionId = crypto.randomUUID().slice(0, 8); + + // Create PTY + const ptyProcess = createPtySession(cols, rows); + sessions.set(socket, { id: sessionId, pty: ptyProcess }); + + // PTY -> WebSocket + ptyProcess.onData((data) => { + if (socket.writable) { + sendWebSocketFrame(socket, data); + } + }); + + ptyProcess.onExit(({ exitCode }) => { + sendWebSocketFrame(socket, `\r\n\x1b[33mShell exited (code: ${exitCode})\x1b[0m\r\n`); + socket.end(); + }); + + // WebSocket -> PTY + let buffer = Buffer.alloc(0); + + socket.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + + while (buffer.length >= 2) { + const fin = (buffer[0] & 0x80) !== 0; + const opcode = buffer[0] & 0x0f; + const masked = (buffer[1] & 0x80) !== 0; + let payloadLength = buffer[1] & 0x7f; + + let offset = 2; + + if (payloadLength === 126) { + if (buffer.length < 4) break; + payloadLength = buffer.readUInt16BE(2); + offset = 4; + } else if (payloadLength === 127) { + if (buffer.length < 10) break; + payloadLength = Number(buffer.readBigUInt64BE(2)); + offset = 10; + } + + const maskKeyOffset = offset; + if (masked) offset += 4; + + const totalLength = offset + payloadLength; + if (buffer.length < totalLength) break; + + // Handle different opcodes + if (opcode === 0x8) { + // Close frame + socket.end(); + break; + } + + if (opcode === 0x1 || opcode === 0x2) { + // Text or binary frame + let payload = buffer.slice(offset, totalLength); + + if (masked) { + const maskKey = buffer.slice(maskKeyOffset, maskKeyOffset + 4); + payload = Buffer.from(payload); + for (let i = 0; i < payload.length; i++) { + payload[i] ^= maskKey[i % 4]; + } + } + + const data = payload.toString('utf8'); + + // Check for resize message + if (data.startsWith('{')) { + try { + const msg = JSON.parse(data); + if (msg.type === 'resize') { + ptyProcess.resize(msg.cols, msg.rows); + buffer = buffer.slice(totalLength); + continue; + } + } catch (e) { + // Not JSON, treat as input + } + } + + // Send to PTY + ptyProcess.write(data); + } + + buffer = buffer.slice(totalLength); + } + }); + + socket.on('close', () => { + const session = sessions.get(socket); + if (session) { + session.pty.kill(); + sessions.delete(socket); + } + }); + + socket.on('error', () => { + // Ignore socket errors (connection reset, etc.) + }); + + // Send welcome message + setTimeout(() => { + const C = '\x1b[1;36m'; // Cyan + const G = '\x1b[1;32m'; // Green + const Y = '\x1b[1;33m'; // Yellow + const R = '\x1b[0m'; // Reset + sendWebSocketFrame( + socket, + `${C}╔══════════════════════════════════════════════════════════════╗${R}\r\n` + ); + sendWebSocketFrame( + socket, + `${C}ā•‘${R} ${G}Welcome to ghostty-web!${R} ${C}ā•‘${R}\r\n` + ); + sendWebSocketFrame( + socket, + `${C}ā•‘${R} ${C}ā•‘${R}\r\n` + ); + sendWebSocketFrame( + socket, + `${C}ā•‘${R} You have a real shell session with full PTY support. ${C}ā•‘${R}\r\n` + ); + sendWebSocketFrame( + socket, + `${C}ā•‘${R} Try: ${Y}ls${R}, ${Y}cd${R}, ${Y}top${R}, ${Y}vim${R}, or any command! ${C}ā•‘${R}\r\n` + ); + sendWebSocketFrame( + socket, + `${C}ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•${R}\r\n\r\n` + ); + }, 100); +}); + +function sendWebSocketFrame(socket, data) { + const payload = Buffer.from(data, 'utf8'); + let header; + + if (payload.length < 126) { + header = Buffer.alloc(2); + header[0] = 0x81; // FIN + text frame + header[1] = payload.length; + } else if (payload.length < 65536) { + header = Buffer.alloc(4); + header[0] = 0x81; + header[1] = 126; + header.writeUInt16BE(payload.length, 2); + } else { + header = Buffer.alloc(10); + header[0] = 0x81; + header[1] = 127; + header.writeBigUInt64BE(BigInt(payload.length), 2); + } + + socket.write(Buffer.concat([header, payload])); +} + +// ============================================================================ +// Startup +// ============================================================================ + +httpServer.listen(HTTP_PORT, () => { + console.log('\n' + '═'.repeat(60)); + console.log(' šŸš€ ghostty-web demo server' + (isDev ? ' (dev mode)' : '')); + console.log('═'.repeat(60)); + console.log(`\n šŸ“ŗ Open: http://localhost:${HTTP_PORT}`); + console.log(` šŸ“” WebSocket PTY: ws://localhost:${WS_PORT}/ws`); + console.log(` 🐚 Shell: ${getShell()}`); + console.log(` šŸ“ Home: ${homedir()}`); + if (isDev) { + console.log(` šŸ“¦ Using local build: ${distPath}`); + } + console.log('\n āš ļø This server provides shell access.'); + console.log(' Only use for local development.\n'); + console.log('═'.repeat(60)); + console.log(' Press Ctrl+C to stop.\n'); +}); + +wsServer.listen(WS_PORT); + +// Graceful shutdown +process.on('SIGINT', () => { + console.log('\n\nShutting down...'); + for (const [socket, session] of sessions.entries()) { + session.pty.kill(); + socket.destroy(); + } + process.exit(0); +}); diff --git a/demo/bun.lock b/demo/bun.lock new file mode 100644 index 0000000..6503f07 --- /dev/null +++ b/demo/bun.lock @@ -0,0 +1,30 @@ +{ + "lockfileVersion": 1, + "configVersion": 0, + "workspaces": { + "": { + "name": "@ghostty-web/demo", + "dependencies": { + "@lydell/node-pty": "^1.0.1", + "ghostty-web": "^0.2.1", + }, + }, + }, + "packages": { + "@lydell/node-pty": ["@lydell/node-pty@1.1.0", "", { "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.1.0", "@lydell/node-pty-darwin-x64": "1.1.0", "@lydell/node-pty-linux-arm64": "1.1.0", "@lydell/node-pty-linux-x64": "1.1.0", "@lydell/node-pty-win32-arm64": "1.1.0", "@lydell/node-pty-win32-x64": "1.1.0" } }, "sha512-VDD8LtlMTOrPKWMXUAcB9+LTktzuunqrMwkYR1DMRBkS6LQrCt+0/Ws1o2rMml/n3guePpS7cxhHF7Nm5K4iMw=="], + + "@lydell/node-pty-darwin-arm64": ["@lydell/node-pty-darwin-arm64@1.1.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7kFD+owAA61qmhJCtoMbqj3Uvff3YHDiU+4on5F2vQdcMI3MuwGi7dM6MkFG/yuzpw8LF2xULpL71tOPUfxs0w=="], + + "@lydell/node-pty-darwin-x64": ["@lydell/node-pty-darwin-x64@1.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-XZdvqj5FjAMjH8bdp0YfaZjur5DrCIDD1VYiE9EkkYVMDQqRUPHYV3U8BVEQVT9hYfjmpr7dNaELF2KyISWSNA=="], + + "@lydell/node-pty-linux-arm64": ["@lydell/node-pty-linux-arm64@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yyDBmalCfHpLiQMT2zyLcqL2Fay4Xy7rIs8GH4dqKLnEviMvPGOK7LADVkKAsbsyXBSISL3Lt1m1MtxhPH6ckg=="], + + "@lydell/node-pty-linux-x64": ["@lydell/node-pty-linux-x64@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-NcNqRTD14QT+vXcEuqSSvmWY+0+WUBn2uRE8EN0zKtDpIEr9d+YiFj16Uqds6QfcLCHfZmC+Ls7YzwTaqDnanA=="], + + "@lydell/node-pty-win32-arm64": ["@lydell/node-pty-win32-arm64@1.1.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-JOMbCou+0fA7d/m97faIIfIU0jOv8sn2OR7tI45u3AmldKoKoLP8zHY6SAvDDnI3fccO1R2HeR1doVjpS7HM0w=="], + + "@lydell/node-pty-win32-x64": ["@lydell/node-pty-win32-x64@1.1.0", "", { "os": "win32", "cpu": "x64" }, "sha512-3N56BZ+WDFnUMYRtsrr7Ky2mhWGl9xXcyqR6cexfuCqcz9RNWL+KoXRv/nZylY5dYaXkft4JaR1uVu+roiZDAw=="], + + "ghostty-web": ["ghostty-web@0.2.1", "", {}, "sha512-wrovbPlHcl+nIkp7S7fY7vOTsmBjwMFihZEe2PJe/M6G4/EwuyJnwaWTTzNfuY7RcM/lVlN+PvGWqJIhKSB5hw=="], + } +} diff --git a/demo/package-lock.json b/demo/package-lock.json new file mode 100644 index 0000000..d4903da --- /dev/null +++ b/demo/package-lock.json @@ -0,0 +1,118 @@ +{ + "name": "@ghostty-web/demo", + "version": "0.2.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@ghostty-web/demo", + "version": "0.2.1", + "license": "MIT", + "dependencies": { + "@lydell/node-pty": "^1.0.1", + "ghostty-web": "^0.2.1" + }, + "bin": { + "ghostty-web-demo": "bin/demo.js" + } + }, + "node_modules/@lydell/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-VDD8LtlMTOrPKWMXUAcB9+LTktzuunqrMwkYR1DMRBkS6LQrCt+0/Ws1o2rMml/n3guePpS7cxhHF7Nm5K4iMw==", + "license": "MIT", + "optionalDependencies": { + "@lydell/node-pty-darwin-arm64": "1.1.0", + "@lydell/node-pty-darwin-x64": "1.1.0", + "@lydell/node-pty-linux-arm64": "1.1.0", + "@lydell/node-pty-linux-x64": "1.1.0", + "@lydell/node-pty-win32-arm64": "1.1.0", + "@lydell/node-pty-win32-x64": "1.1.0" + } + }, + "node_modules/@lydell/node-pty-darwin-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-arm64/-/node-pty-darwin-arm64-1.1.0.tgz", + "integrity": "sha512-7kFD+owAA61qmhJCtoMbqj3Uvff3YHDiU+4on5F2vQdcMI3MuwGi7dM6MkFG/yuzpw8LF2xULpL71tOPUfxs0w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lydell/node-pty-darwin-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-x64/-/node-pty-darwin-x64-1.1.0.tgz", + "integrity": "sha512-XZdvqj5FjAMjH8bdp0YfaZjur5DrCIDD1VYiE9EkkYVMDQqRUPHYV3U8BVEQVT9hYfjmpr7dNaELF2KyISWSNA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lydell/node-pty-linux-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-arm64/-/node-pty-linux-arm64-1.1.0.tgz", + "integrity": "sha512-yyDBmalCfHpLiQMT2zyLcqL2Fay4Xy7rIs8GH4dqKLnEviMvPGOK7LADVkKAsbsyXBSISL3Lt1m1MtxhPH6ckg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lydell/node-pty-linux-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-x64/-/node-pty-linux-x64-1.1.0.tgz", + "integrity": "sha512-NcNqRTD14QT+vXcEuqSSvmWY+0+WUBn2uRE8EN0zKtDpIEr9d+YiFj16Uqds6QfcLCHfZmC+Ls7YzwTaqDnanA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lydell/node-pty-win32-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-arm64/-/node-pty-win32-arm64-1.1.0.tgz", + "integrity": "sha512-JOMbCou+0fA7d/m97faIIfIU0jOv8sn2OR7tI45u3AmldKoKoLP8zHY6SAvDDnI3fccO1R2HeR1doVjpS7HM0w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@lydell/node-pty-win32-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-x64/-/node-pty-win32-x64-1.1.0.tgz", + "integrity": "sha512-3N56BZ+WDFnUMYRtsrr7Ky2mhWGl9xXcyqR6cexfuCqcz9RNWL+KoXRv/nZylY5dYaXkft4JaR1uVu+roiZDAw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/ghostty-web": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/ghostty-web/-/ghostty-web-0.2.1.tgz", + "integrity": "sha512-wrovbPlHcl+nIkp7S7fY7vOTsmBjwMFihZEe2PJe/M6G4/EwuyJnwaWTTzNfuY7RcM/lVlN+PvGWqJIhKSB5hw==", + "license": "MIT" + } + } +} diff --git a/demo/package.json b/demo/package.json new file mode 100644 index 0000000..79b0c31 --- /dev/null +++ b/demo/package.json @@ -0,0 +1,41 @@ +{ + "name": "@ghostty-web/demo", + "version": "0.2.1-next.0.g1680deb", + "description": "Cross-platform demo server for ghostty-web terminal emulator", + "type": "module", + "bin": { + "ghostty-web-demo": "./bin/demo.js" + }, + "scripts": { + "start": "node bin/demo.js", + "dev": "bun run server/pty-server.ts" + }, + "dependencies": { + "@lydell/node-pty": "^1.0.1", + "ghostty-web": "^0.2.1" + }, + "files": [ + "bin", + "README.md" + ], + "keywords": [ + "terminal", + "terminal-emulator", + "ghostty", + "demo", + "pty", + "shell" + ], + "repository": { + "type": "git", + "url": "https://github.com/coder/ghostty-web.git", + "directory": "demo" + }, + "bugs": "https://github.com/coder/ghostty-web/issues", + "homepage": "https://github.com/coder/ghostty-web/tree/main/demo#readme", + "license": "MIT", + "author": "Coder", + "publishConfig": { + "access": "public" + } +} diff --git a/demo/server/package.json b/demo/server/package.json deleted file mode 100644 index d45ac10..0000000 --- a/demo/server/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "ghostty-terminal-server", - "version": "0.1.0", - "type": "module", - "description": "WebSocket server for terminal demo", - "scripts": { - "start": "bun run pty-server.ts", - "pty": "bun run pty-server.ts", - "pty:watch": "bun --watch pty-server.ts", - "file-browser": "bash start.sh", - "file-browser:direct": "bun run file-browser-server.ts" - }, - "dependencies": {} -} diff --git a/package.json b/package.json index 7923862..6289003 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,6 @@ "main": "./dist/ghostty-web.umd.cjs", "module": "./dist/ghostty-web.js", "types": "./dist/index.d.ts", - "bin": { - "ghostty-web": "./bin/ghostty-web.js" - }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -20,7 +17,6 @@ "files": [ "dist", "ghostty-vt.wasm", - "bin", "README.md" ], "keywords": [ From 4f3d54f2587438ebd62aff8fd6be691f898352db Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 25 Nov 2025 14:30:52 +0000 Subject: [PATCH 2/3] fix: use ghostty-web@next dependency when publishing demo@next --- .github/workflows/publish.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 78270fd..7f86075 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -209,18 +209,27 @@ jobs: if [[ "${{ steps.detect.outputs.is_tag }}" == "true" ]]; then NPM_VERSION="${BASE_VERSION}" NPM_TAG="latest" + GHOSTTY_WEB_DEP="^${BASE_VERSION}" else GIT_COMMIT=$(git rev-parse --short HEAD) COMMITS_SINCE_TAG=$(git rev-list --count HEAD ^$(git describe --tags --abbrev=0 2>/dev/null || echo HEAD) 2>/dev/null || echo "0") NPM_VERSION="${BASE_VERSION}-next.${COMMITS_SINCE_TAG}.g${GIT_COMMIT}" NPM_TAG="next" + GHOSTTY_WEB_DEP="next" fi echo "version=${NPM_VERSION}" >> $GITHUB_OUTPUT echo "tag=${NPM_TAG}" >> $GITHUB_OUTPUT - node -e "const fs = require('fs'); const pkg = JSON.parse(fs.readFileSync('package.json')); pkg.version = '${NPM_VERSION}'; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');" - echo "Updated demo/server/package.json to version ${NPM_VERSION}" + # Update version and ghostty-web dependency + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json')); + pkg.version = '${NPM_VERSION}'; + pkg.dependencies['ghostty-web'] = '${GHOSTTY_WEB_DEP}'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + " + echo "Updated demo package.json: version=${NPM_VERSION}, ghostty-web=${GHOSTTY_WEB_DEP}" - name: Check if demo version exists id: check-exists From 27b231d729f3b46f364bc6e3bc9b78d18abec92f Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 25 Nov 2025 14:32:06 +0000 Subject: [PATCH 3/3] fix: use ghostty-web@latest for stable demo releases --- .github/workflows/publish.yml | 2 +- demo/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7f86075..8ad1d8f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -209,7 +209,7 @@ jobs: if [[ "${{ steps.detect.outputs.is_tag }}" == "true" ]]; then NPM_VERSION="${BASE_VERSION}" NPM_TAG="latest" - GHOSTTY_WEB_DEP="^${BASE_VERSION}" + GHOSTTY_WEB_DEP="latest" else GIT_COMMIT=$(git rev-parse --short HEAD) COMMITS_SINCE_TAG=$(git rev-list --count HEAD ^$(git describe --tags --abbrev=0 2>/dev/null || echo HEAD) 2>/dev/null || echo "0") diff --git a/demo/package.json b/demo/package.json index 79b0c31..bc37c5c 100644 --- a/demo/package.json +++ b/demo/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@lydell/node-pty": "^1.0.1", - "ghostty-web": "^0.2.1" + "ghostty-web": "latest" }, "files": [ "bin",