Skip to content

Commit b0028ee

Browse files
HageMaster3108CopilotPatrick Hagemeisterkylecarbs
authored
feat: Unify HTTP/WebSocket demo server for reverse proxy compatibility (#74)
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: Patrick Hagemeister <Patrick.Hagemeister@krankikom.de> Co-authored-by: Kyle Carberry <kyle@carberry.com>
1 parent ce706c1 commit b0028ee

File tree

3 files changed

+94
-8
lines changed

3 files changed

+94
-8
lines changed

demo/README.md

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ Works on **Linux** and **macOS** (no Windows support yet).
1414
## What it does
1515

1616
- Starts an HTTP server on port 8080 (configurable via `PORT` env var)
17-
- Starts a WebSocket server on port 3001 for PTY communication
17+
- Serves WebSocket PTY on the same port at `/ws` endpoint
1818
- Opens a real shell session (bash, zsh, etc.)
1919
- Provides full PTY support (colors, cursor positioning, resize, etc.)
20+
- Supports reverse proxies (ngrok, nginx, etc.) via X-Forwarded-\* headers
2021

2122
## Usage
2223

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

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

34+
## Reverse Proxy Support
35+
36+
The server now supports reverse proxies like ngrok, nginx, and others by:
37+
38+
- Serving WebSocket on the same HTTP port (no separate port needed)
39+
- Using relative WebSocket URLs on the client side
40+
- Automatic protocol detection (HTTP/HTTPS, WS/WSS)
41+
42+
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.
43+
44+
### Example with ngrok
45+
46+
```bash
47+
# Start the demo server
48+
npx @ghostty-web/demo@next
49+
50+
# In another terminal, expose it via ngrok
51+
ngrok http 8080
52+
```
53+
54+
The terminal will work seamlessly through the ngrok URL! Both HTTP and WebSocket traffic will be properly proxied.
55+
56+
### Example with nginx
57+
58+
```nginx
59+
server {
60+
listen 80;
61+
server_name example.com;
62+
63+
location / {
64+
proxy_pass http://localhost:8080;
65+
proxy_http_version 1.1;
66+
proxy_set_header Upgrade $http_upgrade;
67+
proxy_set_header Connection "upgrade";
68+
proxy_set_header Host $host;
69+
proxy_set_header X-Forwarded-Host $host;
70+
proxy_set_header X-Forwarded-Proto $scheme;
71+
proxy_set_header X-Real-IP $remote_addr;
72+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
73+
}
74+
}
75+
```
76+
3377
## Security Warning
3478

3579
⚠️ **This server provides full shell access.**

demo/bin/demo.js

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ const __dirname = path.dirname(__filename);
2323

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

2827
// ============================================================================
2928
// Locate ghostty-web assets
@@ -239,8 +238,9 @@ const HTML_TEMPLATE = `<!doctype html>
239238
statusText.textContent = text;
240239
}
241240
242-
// Connect to WebSocket PTY server
243-
const wsUrl = 'ws://' + window.location.hostname + ':${WS_PORT}/ws?cols=' + term.cols + '&rows=' + term.rows;
241+
// Connect to WebSocket PTY server (use same origin as HTTP server)
242+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
243+
const wsUrl = protocol + '//' + window.location.host + '/ws?cols=' + term.cols + '&rows=' + term.rows;
244244
let ws;
245245
246246
function connect() {
@@ -413,8 +413,23 @@ function createPtySession(cols, rows) {
413413
return ptyProcess;
414414
}
415415

416-
// WebSocket server using ws package
417-
const wss = new WebSocketServer({ port: WS_PORT, path: '/ws' });
416+
// WebSocket server attached to HTTP server (same port)
417+
const wss = new WebSocketServer({ noServer: true });
418+
419+
// Handle HTTP upgrade for WebSocket connections
420+
httpServer.on('upgrade', (req, socket, head) => {
421+
const url = new URL(req.url, `http://${req.headers.host}`);
422+
423+
if (url.pathname === '/ws') {
424+
// In production, consider validating req.headers.origin to prevent CSRF
425+
// For development/demo purposes, we allow all origins
426+
wss.handleUpgrade(req, socket, head, (ws) => {
427+
wss.emit('connection', ws, req);
428+
});
429+
} else {
430+
socket.destroy();
431+
}
432+
});
418433

419434
wss.on('connection', (ws, req) => {
420435
const url = new URL(req.url, `http://${req.headers.host}`);
@@ -498,7 +513,7 @@ function printBanner(url) {
498513
console.log(' 🚀 ghostty-web demo server' + (DEV_MODE ? ' (dev mode)' : ''));
499514
console.log('═'.repeat(60));
500515
console.log(`\n 📺 Open: ${url}`);
501-
console.log(` 📡 WebSocket PTY: ws://localhost:${WS_PORT}/ws`);
516+
console.log(` 📡 WebSocket PTY: same endpoint /ws`);
502517
console.log(` 🐚 Shell: ${getShell()}`);
503518
console.log(` 📁 Home: ${homedir()}`);
504519
if (DEV_MODE) {
@@ -534,7 +549,34 @@ if (DEV_MODE) {
534549
strictPort: true,
535550
},
536551
});
552+
537553
await vite.listen();
554+
555+
// Attach WebSocket handler AFTER Vite has fully initialized
556+
// Use prependListener (not prependOnceListener) so it runs for every request
557+
// This ensures our handler runs BEFORE Vite's handlers
558+
if (vite.httpServer) {
559+
vite.httpServer.prependListener('upgrade', (req, socket, head) => {
560+
const pathname = req.url?.split('?')[0] || req.url || '';
561+
562+
// ONLY handle /ws - everything else passes through unchanged to Vite
563+
if (pathname === '/ws') {
564+
if (!socket.destroyed && !socket.readableEnded) {
565+
wss.handleUpgrade(req, socket, head, (ws) => {
566+
wss.emit('connection', ws, req);
567+
});
568+
}
569+
// Stop here - we handled it, socket is consumed
570+
// Don't call other listeners
571+
return;
572+
}
573+
574+
// For non-/ws paths, explicitly do nothing and let the event propagate
575+
// The key is: don't return, don't touch the socket, just let it pass through
576+
// Vite's handlers (which were added before ours via prependListener) will process it
577+
});
578+
}
579+
538580
printBanner(`http://localhost:${HTTP_PORT}/demo/`);
539581
} else {
540582
// Production mode: static file server

demo/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@
193193

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

198198
ws = new WebSocket(wsUrl);
199199

0 commit comments

Comments
 (0)