diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 2670db9..8ad1d8f 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -168,3 +168,83 @@ 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"
+ 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")
+ 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
+
+ # 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
+ 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
+
+
+
+
+
+
+
+`;
+
+// ============================================================================
+// 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..bc37c5c
--- /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": "latest"
+ },
+ "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": [