diff --git a/bin/ghostty-web.js b/bin/ghostty-web.js
new file mode 100755
index 0000000..ef71ec5
--- /dev/null
+++ b/bin/ghostty-web.js
@@ -0,0 +1,669 @@
+#!/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/bun.lock b/bun.lock
index c0aa35b..7835f71 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
+ "configVersion": 0,
"workspaces": {
"": {
"name": "@cmux/ghostty-terminal",
diff --git a/package.json b/package.json
index 6289003..7923862 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,9 @@
"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",
@@ -17,6 +20,7 @@
"files": [
"dist",
"ghostty-vt.wasm",
+ "bin",
"README.md"
],
"keywords": [