@ghostty-web/demo 0.4.0-next.13.g65ed96f → 0.4.0-next.18.gbec9e16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +11 -27
  2. package/bin/auth.js +335 -0
  3. package/bin/demo.js +213 -30
  4. package/package.json +2 -2
package/README.md CHANGED
@@ -13,11 +13,12 @@ Works on **Linux** and **macOS** (no Windows support yet).
13
13
 
14
14
  ## What it does
15
15
 
16
- - Starts an HTTP server on port 8080 (configurable via `PORT` env var)
16
+ - Starts an HTTP server on `127.0.0.1:8080` by default (`PORT` and `HOST` are configurable)
17
17
  - Serves WebSocket PTY on the same port at `/ws` endpoint
18
+ - Protects `/ws` with a per-run same-origin token from `/api/token`
19
+ - Rejects cross-origin WebSocket handshakes
18
20
  - Opens a real shell session (bash, zsh, etc.)
19
21
  - Provides full PTY support (colors, cursor positioning, resize, etc.)
20
- - Supports reverse proxies (ngrok, nginx, etc.) via X-Forwarded-\* headers
21
22
 
22
23
  ## Usage
23
24
 
@@ -27,31 +28,18 @@ npx @ghostty-web/demo@next
27
28
 
28
29
  # Custom port
29
30
  PORT=3000 npx @ghostty-web/demo@next
30
- ```
31
-
32
- Then open http://localhost:8080 in your browser.
33
-
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
31
 
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.
32
+ # Explicit bind host for intentional non-default access
33
+ HOST=192.0.2.10 GHOSTTY_ALLOWED_HOSTS=demo.example npx @ghostty-web/demo@next
34
+ ```
43
35
 
44
- ### Example with ngrok
36
+ Then open http://127.0.0.1:8080 in your browser.
45
37
 
46
- ```bash
47
- # Start the demo server
48
- npx @ghostty-web/demo@next
38
+ ## Bind host and proxy configuration
49
39
 
50
- # In another terminal, expose it via ngrok
51
- ngrok http 8080
52
- ```
40
+ The demo binds to `127.0.0.1` by default and only allows loopback hostnames (`localhost`, `127.0.0.1`, and `::1`) unless configured otherwise. Set `HOST=<host>` to change the bind address. If you serve the demo through another hostname, or bind to a wildcard such as `HOST=0.0.0.0`, add the browser-visible hostnames with `GHOSTTY_ALLOWED_HOSTS=host1,host2`.
53
41
 
54
- The terminal will work seamlessly through the ngrok URL! Both HTTP and WebSocket traffic will be properly proxied.
42
+ The browser client fetches `/api/token` from the same origin before opening `/ws`, and the server rejects `/ws` when the token is missing, the `Host` is not allowed, or the WebSocket `Origin` does not match the request host. Do not set permissive CORS in front of `/api/token`.
55
43
 
56
44
  ### Example with nginx
57
45
 
@@ -66,10 +54,6 @@ server {
66
54
  proxy_set_header Upgrade $http_upgrade;
67
55
  proxy_set_header Connection "upgrade";
68
56
  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
57
  }
74
58
  }
75
59
  ```
@@ -78,4 +62,4 @@ server {
78
62
 
79
63
  ⚠️ **This server provides full shell access.**
80
64
 
81
- Only use for local development and demos. Do not expose to untrusted networks.
65
+ Only use for local development and demos. Keep the default loopback bind unless you intentionally need remote access and have configured `HOST` and `GHOSTTY_ALLOWED_HOSTS` for the exact hostnames you trust.
package/bin/auth.js ADDED
@@ -0,0 +1,335 @@
1
+ import assert from 'assert';
2
+ import { randomBytes, timingSafeEqual } from 'crypto';
3
+ import { isIP } from 'net';
4
+
5
+ const LOOPBACK_HOSTS = Object.freeze(['localhost', '127.0.0.1', '::1']);
6
+ const WILDCARD_BIND_HOSTS = Object.freeze(['0.0.0.0', '::', '*']);
7
+
8
+ function decision(status, reason) {
9
+ return { ok: false, status, reason };
10
+ }
11
+
12
+ function badRequest() {
13
+ return decision(400, 'Bad Request');
14
+ }
15
+
16
+ function forbidden() {
17
+ return decision(403, 'Forbidden');
18
+ }
19
+
20
+ function unauthorized() {
21
+ return decision(401, 'Unauthorized');
22
+ }
23
+
24
+ function parseAllowedHosts(value) {
25
+ if (!value) {
26
+ return [];
27
+ }
28
+ return value
29
+ .split(',')
30
+ .map((host) => host.trim())
31
+ .filter((host) => host.length > 0);
32
+ }
33
+
34
+ function normalizeHostname(hostname) {
35
+ if (typeof hostname !== 'string') {
36
+ return null;
37
+ }
38
+
39
+ let value = hostname.trim().toLowerCase();
40
+ if (value.length === 0 || /\s/.test(value)) {
41
+ return null;
42
+ }
43
+
44
+ if (value.startsWith('[') || value.endsWith(']')) {
45
+ if (!value.startsWith('[') || !value.endsWith(']')) {
46
+ return null;
47
+ }
48
+ value = value.slice(1, -1);
49
+ }
50
+
51
+ if (value.length === 0 || value.includes('/') || value.includes('\\') || value.includes('@')) {
52
+ return null;
53
+ }
54
+
55
+ if (isIP(value) !== 0) {
56
+ return value;
57
+ }
58
+
59
+ if (value.includes(':') || !/^[a-z0-9.-]+$/.test(value)) {
60
+ return null;
61
+ }
62
+
63
+ const labels = value.split('.');
64
+ if (labels.some((label) => label.length === 0 || label.length > 63)) {
65
+ return null;
66
+ }
67
+
68
+ for (const label of labels) {
69
+ if (label.startsWith('-') || label.endsWith('-')) {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ return value;
75
+ }
76
+
77
+ function addAllowedHost(allowedHosts, host) {
78
+ const normalized = normalizeHostname(host);
79
+ assert(normalized, `Allowed host must be a hostname or IP address: ${host}`);
80
+ allowedHosts.add(normalized);
81
+ }
82
+
83
+ function parsePort(port) {
84
+ if (port === undefined || port === '') {
85
+ return '';
86
+ }
87
+ if (!/^[0-9]+$/.test(port)) {
88
+ return null;
89
+ }
90
+ const value = Number.parseInt(port, 10);
91
+ if (!Number.isInteger(value) || value < 1 || value > 65535) {
92
+ return null;
93
+ }
94
+ return String(value);
95
+ }
96
+
97
+ export function parseHostHeader(hostHeader) {
98
+ if (
99
+ typeof hostHeader !== 'string' ||
100
+ hostHeader.length === 0 ||
101
+ hostHeader.trim() !== hostHeader
102
+ ) {
103
+ return null;
104
+ }
105
+
106
+ let hostname = '';
107
+ let port = '';
108
+
109
+ if (hostHeader.startsWith('[')) {
110
+ const match = /^\[([^\]]+)\](?::([0-9]+))?$/.exec(hostHeader);
111
+ if (!match) {
112
+ return null;
113
+ }
114
+ hostname = match[1];
115
+ const parsedPort = parsePort(match[2]);
116
+ if (parsedPort === null) {
117
+ return null;
118
+ }
119
+ port = parsedPort;
120
+ } else {
121
+ const colonCount = (hostHeader.match(/:/g) || []).length;
122
+ if (colonCount === 0) {
123
+ hostname = hostHeader;
124
+ } else if (colonCount === 1) {
125
+ const parts = hostHeader.split(':');
126
+ hostname = parts[0];
127
+ const parsedPort = parsePort(parts[1]);
128
+ if (parsedPort === null) {
129
+ return null;
130
+ }
131
+ port = parsedPort;
132
+ } else {
133
+ hostname = hostHeader;
134
+ }
135
+ }
136
+
137
+ const normalizedHostname = normalizeHostname(hostname);
138
+ if (!normalizedHostname) {
139
+ return null;
140
+ }
141
+
142
+ return { hostname: normalizedHostname, port };
143
+ }
144
+
145
+ function parseOriginHeader(originHeader) {
146
+ if (
147
+ typeof originHeader !== 'string' ||
148
+ originHeader.length === 0 ||
149
+ originHeader.trim() !== originHeader
150
+ ) {
151
+ return null;
152
+ }
153
+
154
+ let origin;
155
+ try {
156
+ origin = new URL(originHeader);
157
+ } catch (_error) {
158
+ return null;
159
+ }
160
+
161
+ if (origin.protocol !== 'http:' && origin.protocol !== 'https:') {
162
+ return null;
163
+ }
164
+
165
+ if (
166
+ origin.username ||
167
+ origin.password ||
168
+ origin.pathname !== '/' ||
169
+ origin.search ||
170
+ origin.hash
171
+ ) {
172
+ return null;
173
+ }
174
+
175
+ const host = parseHostHeader(origin.host);
176
+ if (!host) {
177
+ return null;
178
+ }
179
+
180
+ return { protocol: origin.protocol, ...host };
181
+ }
182
+
183
+ function defaultPort(protocol) {
184
+ assert(protocol === 'http:' || protocol === 'https:', `Unexpected origin protocol: ${protocol}`);
185
+ return protocol === 'https:' ? '443' : '80';
186
+ }
187
+
188
+ function originMatchesHost(origin, host) {
189
+ const fallbackPort = defaultPort(origin.protocol);
190
+ return (
191
+ origin.hostname === host.hostname &&
192
+ (origin.port || fallbackPort) === (host.port || fallbackPort)
193
+ );
194
+ }
195
+
196
+ function validateAllowedHost(config, hostHeader) {
197
+ assertAuthConfig(config);
198
+ const host = parseHostHeader(hostHeader);
199
+ if (!host) {
200
+ return { ...badRequest(), host: null };
201
+ }
202
+
203
+ if (!config.allowedHosts.includes(host.hostname)) {
204
+ return { ...forbidden(), host };
205
+ }
206
+
207
+ return { ok: true, host };
208
+ }
209
+
210
+ function validateMatchingOrigin(originHeader, host, required) {
211
+ if (originHeader === undefined && !required) {
212
+ return { ok: true };
213
+ }
214
+
215
+ if (originHeader === undefined) {
216
+ return forbidden();
217
+ }
218
+
219
+ const origin = parseOriginHeader(originHeader);
220
+ if (!origin) {
221
+ return badRequest();
222
+ }
223
+
224
+ if (!originMatchesHost(origin, host)) {
225
+ return forbidden();
226
+ }
227
+
228
+ return { ok: true };
229
+ }
230
+
231
+ function safeTokenEquals(expected, actual) {
232
+ assert(
233
+ typeof expected === 'string' && expected.length > 0,
234
+ 'Expected auth token must be non-empty'
235
+ );
236
+ if (typeof actual !== 'string' || actual.length === 0) {
237
+ return false;
238
+ }
239
+
240
+ const expectedBuffer = Buffer.from(expected, 'utf8');
241
+ const actualBuffer = Buffer.from(actual, 'utf8');
242
+ if (expectedBuffer.length !== actualBuffer.length) {
243
+ return false;
244
+ }
245
+
246
+ return timingSafeEqual(expectedBuffer, actualBuffer);
247
+ }
248
+
249
+ function assertAuthConfig(config) {
250
+ assert(config && typeof config === 'object', 'Auth config must be an object');
251
+ assert(
252
+ typeof config.token === 'string' && config.token.length > 0,
253
+ 'Auth token must be non-empty'
254
+ );
255
+ assert(
256
+ Array.isArray(config.allowedHosts) && config.allowedHosts.length > 0,
257
+ 'Allowed host list must be non-empty'
258
+ );
259
+ }
260
+
261
+ export function generateSessionToken() {
262
+ const token = randomBytes(32).toString('base64url');
263
+ assert(token.length >= 32, 'Generated session token must contain enough entropy');
264
+ return token;
265
+ }
266
+
267
+ export function isWildcardBindHost(host) {
268
+ const normalized = normalizeHostname(host);
269
+ return (
270
+ WILDCARD_BIND_HOSTS.includes(host) ||
271
+ (normalized !== null && WILDCARD_BIND_HOSTS.includes(normalized))
272
+ );
273
+ }
274
+
275
+ export function isLoopbackHost(host) {
276
+ const normalized = normalizeHostname(host);
277
+ return normalized !== null && LOOPBACK_HOSTS.includes(normalized);
278
+ }
279
+
280
+ export function createAuthConfig(options = {}) {
281
+ const env = options.env ?? process.env;
282
+ const bindHost = options.bindHost ?? env.HOST ?? '127.0.0.1';
283
+ const token = options.token ?? generateSessionToken();
284
+
285
+ assert(typeof bindHost === 'string' && bindHost.length > 0, 'Bind host must be non-empty');
286
+ assert(typeof token === 'string' && token.length > 0, 'Auth token must be non-empty');
287
+ assert(LOOPBACK_HOSTS.length > 0, 'Loopback host allowlist must not be empty');
288
+
289
+ const allowedHosts = new Set(LOOPBACK_HOSTS);
290
+ for (const host of options.allowedHosts ?? parseAllowedHosts(env.GHOSTTY_ALLOWED_HOSTS)) {
291
+ addAllowedHost(allowedHosts, host);
292
+ }
293
+
294
+ if (!isWildcardBindHost(bindHost)) {
295
+ addAllowedHost(allowedHosts, bindHost);
296
+ }
297
+
298
+ return Object.freeze({
299
+ token,
300
+ bindHost,
301
+ allowedHosts: Object.freeze([...allowedHosts]),
302
+ });
303
+ }
304
+
305
+ export function validateTokenRequest(config, request) {
306
+ const hostDecision = validateAllowedHost(config, request.host);
307
+ if (!hostDecision.ok) {
308
+ return hostDecision;
309
+ }
310
+
311
+ const originDecision = validateMatchingOrigin(request.origin, hostDecision.host, false);
312
+ if (!originDecision.ok) {
313
+ return originDecision;
314
+ }
315
+
316
+ return { ok: true };
317
+ }
318
+
319
+ export function validateWebSocketRequest(config, request) {
320
+ const hostDecision = validateAllowedHost(config, request.host);
321
+ if (!hostDecision.ok) {
322
+ return hostDecision;
323
+ }
324
+
325
+ const originDecision = validateMatchingOrigin(request.origin, hostDecision.host, true);
326
+ if (!originDecision.ok) {
327
+ return originDecision;
328
+ }
329
+
330
+ if (!safeTokenEquals(config.token, request.token)) {
331
+ return unauthorized();
332
+ }
333
+
334
+ return { ok: true };
335
+ }
package/bin/demo.js CHANGED
@@ -18,11 +18,29 @@ import pty from '@lydell/node-pty';
18
18
  // WebSocket server
19
19
  import { WebSocketServer } from 'ws';
20
20
 
21
+ import {
22
+ createAuthConfig,
23
+ isLoopbackHost,
24
+ isWildcardBindHost,
25
+ validateTokenRequest,
26
+ validateWebSocketRequest,
27
+ } from './auth.js';
28
+
21
29
  const __filename = fileURLToPath(import.meta.url);
22
30
  const __dirname = path.dirname(__filename);
23
31
 
24
32
  const DEV_MODE = process.argv.includes('--dev');
25
- const HTTP_PORT = process.env.PORT || (DEV_MODE ? 8000 : 8080);
33
+ const HTTP_PORT = parsePort(process.env.PORT || (DEV_MODE ? '8000' : '8080'));
34
+ const AUTH_CONFIG = createAuthConfig();
35
+ const HOST = AUTH_CONFIG.bindHost;
36
+
37
+ function parsePort(value) {
38
+ const port = Number.parseInt(value, 10);
39
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
40
+ throw new Error(`PORT must be an integer from 1 to 65535: ${value}`);
41
+ }
42
+ return port;
43
+ }
26
44
 
27
45
  // ============================================================================
28
46
  // Locate ghostty-web assets
@@ -240,12 +258,46 @@ const HTML_TEMPLATE = `<!doctype html>
240
258
 
241
259
  // Connect to WebSocket PTY server (use same origin as HTTP server)
242
260
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
243
- const wsUrl = protocol + '//' + window.location.host + '/ws?cols=' + term.cols + '&rows=' + term.rows;
244
261
  let ws;
245
262
 
246
- function connect() {
263
+ async function fetchAuthToken() {
264
+ const response = await fetch('/api/token', { cache: 'no-store' });
265
+ if (!response.ok) {
266
+ throw new Error('Token request failed with HTTP ' + response.status);
267
+ }
268
+
269
+ const body = await response.json();
270
+ if (!body || typeof body.token !== 'string' || body.token.length === 0) {
271
+ throw new Error('Token response did not include a token');
272
+ }
273
+
274
+ return body.token;
275
+ }
276
+
277
+ function buildWebSocketUrl(token) {
278
+ const params = new URLSearchParams();
279
+ params.set('cols', String(term.cols));
280
+ params.set('rows', String(term.rows));
281
+ params.set('token', token);
282
+ return protocol + '//' + window.location.host + '/ws?' + params.toString();
283
+ }
284
+
285
+ async function connect() {
286
+ setStatus('connecting', 'Authenticating...');
287
+
288
+ let token;
289
+ try {
290
+ token = await fetchAuthToken();
291
+ } catch (error) {
292
+ console.error('Authentication failed:', error);
293
+ setStatus('disconnected', 'Auth error');
294
+ term.write('\\r\\n\\x1b[31mAuthentication failed. Retrying in 2s...\\x1b[0m\\r\\n');
295
+ setTimeout(connect, 2000);
296
+ return;
297
+ }
298
+
247
299
  setStatus('connecting', 'Connecting...');
248
- ws = new WebSocket(wsUrl);
300
+ ws = new WebSocket(buildWebSocketUrl(token));
249
301
 
250
302
  ws.onopen = () => {
251
303
  setStatus('connected', 'Connected');
@@ -338,7 +390,16 @@ const MIME_TYPES = {
338
390
  // ============================================================================
339
391
 
340
392
  const httpServer = http.createServer((req, res) => {
341
- const url = new URL(req.url, `http://${req.headers.host}`);
393
+ const url = parseRequestUrl(req);
394
+ if (!url) {
395
+ writeHttpDecision(res, { status: 400, reason: 'Bad Request' });
396
+ return;
397
+ }
398
+
399
+ if (handleTokenRequest(req, res, url)) {
400
+ return;
401
+ }
402
+
342
403
  const pathname = url.pathname;
343
404
 
344
405
  // Serve index page
@@ -381,6 +442,60 @@ function serveFile(filePath, res) {
381
442
  });
382
443
  }
383
444
 
445
+ function parseRequestUrl(req) {
446
+ try {
447
+ return new URL(req.url || '/', 'http://127.0.0.1');
448
+ } catch (_error) {
449
+ return null;
450
+ }
451
+ }
452
+
453
+ function writeHttpDecision(res, decision) {
454
+ res.writeHead(decision.status, {
455
+ 'Content-Type': 'text/plain; charset=utf-8',
456
+ 'X-Content-Type-Options': 'nosniff',
457
+ });
458
+ res.end(decision.reason);
459
+ }
460
+
461
+ function writeTokenResponse(res) {
462
+ res.writeHead(200, {
463
+ 'Content-Type': 'application/json',
464
+ 'Cache-Control': 'no-store',
465
+ 'X-Content-Type-Options': 'nosniff',
466
+ });
467
+ res.end(JSON.stringify({ token: AUTH_CONFIG.token }));
468
+ }
469
+
470
+ function handleTokenRequest(req, res, url) {
471
+ if (url.pathname !== '/api/token') {
472
+ return false;
473
+ }
474
+
475
+ if (req.method !== 'GET') {
476
+ res.writeHead(405, {
477
+ Allow: 'GET',
478
+ 'Content-Type': 'text/plain; charset=utf-8',
479
+ 'X-Content-Type-Options': 'nosniff',
480
+ });
481
+ res.end('Method Not Allowed');
482
+ return true;
483
+ }
484
+
485
+ const decision = validateTokenRequest(AUTH_CONFIG, {
486
+ host: req.headers.host,
487
+ origin: req.headers.origin,
488
+ });
489
+
490
+ if (!decision.ok) {
491
+ writeHttpDecision(res, decision);
492
+ return true;
493
+ }
494
+
495
+ writeTokenResponse(res);
496
+ return true;
497
+ }
498
+
384
499
  // ============================================================================
385
500
  // WebSocket Server (using ws package)
386
501
  // ============================================================================
@@ -416,23 +531,67 @@ function createPtySession(cols, rows) {
416
531
  // WebSocket server attached to HTTP server (same port)
417
532
  const wss = new WebSocketServer({ noServer: true });
418
533
 
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}`);
534
+ function rejectUpgrade(socket, decision) {
535
+ if (socket.destroyed) {
536
+ return;
537
+ }
538
+
539
+ const body = decision.reason + '\n';
540
+ socket.write(
541
+ `HTTP/1.1 ${decision.status} ${decision.reason}\r\n` +
542
+ 'Connection: close\r\n' +
543
+ 'Content-Type: text/plain; charset=utf-8\r\n' +
544
+ 'X-Content-Type-Options: nosniff\r\n' +
545
+ `Content-Length: ${Buffer.byteLength(body)}\r\n` +
546
+ '\r\n' +
547
+ body
548
+ );
549
+ socket.destroy();
550
+ }
551
+
552
+ function handleWebSocketUpgrade(req, socket, head) {
553
+ const url = parseRequestUrl(req);
554
+ if (!url) {
555
+ rejectUpgrade(socket, { status: 400, reason: 'Bad Request' });
556
+ return true;
557
+ }
558
+
559
+ if (url.pathname !== '/ws') {
560
+ return false;
561
+ }
562
+
563
+ const decision = validateWebSocketRequest(AUTH_CONFIG, {
564
+ host: req.headers.host,
565
+ origin: req.headers.origin,
566
+ token: url.searchParams.get('token'),
567
+ });
422
568
 
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
569
+ if (!decision.ok) {
570
+ rejectUpgrade(socket, decision);
571
+ return true;
572
+ }
573
+
574
+ if (!socket.destroyed && !socket.readableEnded) {
426
575
  wss.handleUpgrade(req, socket, head, (ws) => {
427
576
  wss.emit('connection', ws, req);
428
577
  });
429
- } else {
578
+ }
579
+ return true;
580
+ }
581
+
582
+ // Handle HTTP upgrade for WebSocket connections
583
+ httpServer.on('upgrade', (req, socket, head) => {
584
+ if (!handleWebSocketUpgrade(req, socket, head)) {
430
585
  socket.destroy();
431
586
  }
432
587
  });
433
588
 
434
589
  wss.on('connection', (ws, req) => {
435
- const url = new URL(req.url, `http://${req.headers.host}`);
590
+ const url = parseRequestUrl(req);
591
+ if (!url) {
592
+ ws.close();
593
+ return;
594
+ }
436
595
  const cols = Number.parseInt(url.searchParams.get('cols') || '80');
437
596
  const rows = Number.parseInt(url.searchParams.get('rows') || '24');
438
597
 
@@ -508,12 +667,17 @@ wss.on('connection', (ws, req) => {
508
667
  // Startup
509
668
  // ============================================================================
510
669
 
670
+ function formatUrlHost(host) {
671
+ return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
672
+ }
673
+
511
674
  function printBanner(url) {
512
675
  console.log('\n' + '═'.repeat(60));
513
676
  console.log(' 🚀 ghostty-web demo server' + (DEV_MODE ? ' (dev mode)' : ''));
514
677
  console.log('═'.repeat(60));
515
678
  console.log(`\n 📺 Open: ${url}`);
516
679
  console.log(` 📡 WebSocket PTY: same endpoint /ws`);
680
+ console.log(' 🔐 WebSocket auth: per-run same-origin token');
517
681
  console.log(` 🐚 Shell: ${getShell()}`);
518
682
  console.log(` 📁 Home: ${homedir()}`);
519
683
  if (DEV_MODE) {
@@ -522,6 +686,12 @@ function printBanner(url) {
522
686
  console.log(` 📦 Using local build: ${distPath}`);
523
687
  }
524
688
  console.log('\n ⚠️ This server provides shell access.');
689
+ console.log(' It binds to ' + HOST + ' and rejects cross-origin WebSockets.');
690
+ if (isWildcardBindHost(HOST) || !isLoopbackHost(HOST)) {
691
+ console.log(
692
+ ' Remote access requires GHOSTTY_ALLOWED_HOSTS and can expose your shell if misconfigured.'
693
+ );
694
+ }
525
695
  console.log(' Only use for local development.\n');
526
696
  console.log('═'.repeat(60));
527
697
  console.log(' Press Ctrl+C to stop.\n');
@@ -544,9 +714,32 @@ if (DEV_MODE) {
544
714
  const { createServer } = await import('vite');
545
715
  const vite = await createServer({
546
716
  root: repoRoot,
717
+ plugins: [
718
+ {
719
+ name: 'ghostty-demo-auth',
720
+ configureServer(server) {
721
+ server.middlewares.use((req, res, next) => {
722
+ const url = parseRequestUrl(req);
723
+ if (!url) {
724
+ writeHttpDecision(res, { status: 400, reason: 'Bad Request' });
725
+ return;
726
+ }
727
+
728
+ if (handleTokenRequest(req, res, url)) {
729
+ return;
730
+ }
731
+
732
+ next();
733
+ });
734
+ },
735
+ },
736
+ ],
547
737
  server: {
738
+ host: HOST,
548
739
  port: HTTP_PORT,
549
740
  strictPort: true,
741
+ cors: false,
742
+ allowedHosts: AUTH_CONFIG.allowedHosts,
550
743
  },
551
744
  });
552
745
 
@@ -557,30 +750,20 @@ if (DEV_MODE) {
557
750
  // This ensures our handler runs BEFORE Vite's handlers
558
751
  if (vite.httpServer) {
559
752
  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
753
+ // ONLY handle /ws - everything else passes through unchanged to Vite.
754
+ if (handleWebSocketUpgrade(req, socket, head)) {
571
755
  return;
572
756
  }
573
757
 
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
758
+ // For non-/ws paths, explicitly do nothing and let the event propagate.
759
+ // The key is: don't return, don't touch the socket, just let Vite process it.
577
760
  });
578
761
  }
579
762
 
580
- printBanner(`http://localhost:${HTTP_PORT}/demo/`);
763
+ printBanner(`http://${formatUrlHost(HOST)}:${HTTP_PORT}/demo/`);
581
764
  } else {
582
765
  // Production mode: static file server
583
- httpServer.listen(HTTP_PORT, () => {
584
- printBanner(`http://localhost:${HTTP_PORT}`);
766
+ httpServer.listen(HTTP_PORT, HOST, () => {
767
+ printBanner(`http://${formatUrlHost(HOST)}:${HTTP_PORT}`);
585
768
  });
586
769
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghostty-web/demo",
3
- "version": "0.4.0-next.13.g65ed96f",
3
+ "version": "0.4.0-next.18.gbec9e16",
4
4
  "description": "Cross-platform demo server for ghostty-web terminal emulator",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,7 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "@lydell/node-pty": "^1.0.1",
15
- "ghostty-web": "0.4.0-next.13.g65ed96f",
15
+ "ghostty-web": "0.4.0-next.18.gbec9e16",
16
16
  "ws": "^8.18.0"
17
17
  },
18
18
  "files": [