Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion demo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ Works on **Linux** and **macOS** (no Windows support yet).
## 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
- Serves WebSocket PTY on the same port at `/ws` endpoint
- Opens a real shell session (bash, zsh, etc.)
- Provides full PTY support (colors, cursor positioning, resize, etc.)
- Supports reverse proxies (ngrok, nginx, etc.) via X-Forwarded-\* headers

## Usage

Expand All @@ -30,6 +31,49 @@ PORT=3000 npx @ghostty-web/demo@next

Then open http://localhost:8080 in your browser.

## Reverse Proxy Support

The server now supports reverse proxies like ngrok, nginx, and others by:

- Serving WebSocket on the same HTTP port (no separate port needed)
- Using relative WebSocket URLs on the client side
- Automatic protocol detection (HTTP/HTTPS, WS/WSS)

This means the WebSocket connection automatically adapts to use the same protocol and host as the HTTP connection, making it work seamlessly through any reverse proxy.

### Example with ngrok

```bash
# Start the demo server
npx @ghostty-web/demo@next

# In another terminal, expose it via ngrok
ngrok http 8080
```

The terminal will work seamlessly through the ngrok URL! Both HTTP and WebSocket traffic will be properly proxied.

### Example with nginx

```nginx
server {
listen 80;
server_name example.com;

location / {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```

## Security Warning

⚠️ **This server provides full shell access.**
Expand Down
54 changes: 48 additions & 6 deletions demo/bin/demo.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ const __dirname = path.dirname(__filename);

const DEV_MODE = process.argv.includes('--dev');
const HTTP_PORT = process.env.PORT || (DEV_MODE ? 8000 : 8080);
const WS_PORT = 3001;

// ============================================================================
// Locate ghostty-web assets
Expand Down Expand Up @@ -239,8 +238,9 @@ const HTML_TEMPLATE = `<!doctype html>
statusText.textContent = text;
}

// Connect to WebSocket PTY server
const wsUrl = 'ws://' + window.location.hostname + ':${WS_PORT}/ws?cols=' + term.cols + '&rows=' + term.rows;
// Connect to WebSocket PTY server (use same origin as HTTP server)
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = protocol + '//' + window.location.host + '/ws?cols=' + term.cols + '&rows=' + term.rows;
let ws;

function connect() {
Expand Down Expand Up @@ -386,8 +386,23 @@ function createPtySession(cols, rows) {
return ptyProcess;
}

// WebSocket server using ws package
const wss = new WebSocketServer({ port: WS_PORT, path: '/ws' });
// WebSocket server attached to HTTP server (same port)
const wss = new WebSocketServer({ noServer: true });

// Handle HTTP upgrade for WebSocket connections
httpServer.on('upgrade', (req, socket, head) => {
Comment on lines +389 to +393

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore WebSocket handling in --dev mode

The WebSocket server is now attached to httpServer via noServer/on('upgrade'), but in --dev mode we never call httpServer.listen (we start Vite instead), so no upgrade requests reach this handler and the PTY WebSocket never opens. Running node demo/bin/demo.js --dev leaves the demo stuck at “Connecting…” because the WS endpoint is unreachable. The WSS needs to be wired into the dev server or keep its own listener to keep dev mode usable.

Useful? React with 👍 / 👎.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, that may actually be true @kylecarbs changing PR back to DRAFT, apols!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed now!

const url = new URL(req.url, `http://${req.headers.host}`);

if (url.pathname === '/ws') {
// In production, consider validating req.headers.origin to prevent CSRF
// For development/demo purposes, we allow all origins
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req);
});
} else {
socket.destroy();
}
});

wss.on('connection', (ws, req) => {
const url = new URL(req.url, `http://${req.headers.host}`);
Expand Down Expand Up @@ -474,7 +489,7 @@ function printBanner(url) {
console.log(' 🚀 ghostty-web demo server' + (DEV_MODE ? ' (dev mode)' : ''));
console.log('═'.repeat(60));
console.log(`\n 📺 Open: ${url}`);
console.log(` 📡 WebSocket PTY: ws://localhost:${WS_PORT}/ws`);
console.log(` 📡 WebSocket PTY: same endpoint /ws`);
console.log(` 🐚 Shell: ${getShell()}`);
console.log(` 📁 Home: ${homedir()}`);
if (DEV_MODE) {
Expand Down Expand Up @@ -510,7 +525,34 @@ if (DEV_MODE) {
strictPort: true,
},
});

await vite.listen();

// Attach WebSocket handler AFTER Vite has fully initialized
// Use prependListener (not prependOnceListener) so it runs for every request
// This ensures our handler runs BEFORE Vite's handlers
if (vite.httpServer) {
vite.httpServer.prependListener('upgrade', (req, socket, head) => {
const pathname = req.url?.split('?')[0] || req.url || '';

// ONLY handle /ws - everything else passes through unchanged to Vite
if (pathname === '/ws') {
if (!socket.destroyed && !socket.readableEnded) {
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req);
});
}
// Stop here - we handled it, socket is consumed
// Don't call other listeners
return;
}

// For non-/ws paths, explicitly do nothing and let the event propagate
// The key is: don't return, don't touch the socket, just let it pass through
// Vite's handlers (which were added before ours via prependListener) will process it
});
}

printBanner(`http://localhost:${HTTP_PORT}/demo/`);
} else {
// Production mode: static file server
Expand Down
2 changes: 1 addition & 1 deletion demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@

function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.hostname}:3001/ws?cols=${term.cols}&rows=${term.rows}`;
const wsUrl = `${protocol}//${window.location.host}/ws?cols=${term.cols}&rows=${term.rows}`;

ws = new WebSocket(wsUrl);

Expand Down
Loading