77 * Run with: npx @ghostty-web/demo
88 */
99
10- import crypto from 'crypto' ;
1110import fs from 'fs' ;
1211import http from 'http' ;
1312import { homedir } from 'os' ;
@@ -16,6 +15,8 @@ import { fileURLToPath } from 'url';
1615
1716// Node-pty for cross-platform PTY support
1817import pty from '@lydell/node-pty' ;
18+ // WebSocket server
19+ import { WebSocketServer } from 'ws' ;
1920
2021const __filename = fileURLToPath ( import . meta. url ) ;
2122const __dirname = path . dirname ( __filename ) ;
@@ -349,7 +350,7 @@ function serveFile(filePath, res) {
349350}
350351
351352// ============================================================================
352- // WebSocket Server (using native WebSocket upgrade )
353+ // WebSocket Server (using ws package )
353354// ============================================================================
354355
355356const sessions = new Map ( ) ;
@@ -380,196 +381,85 @@ function createPtySession(cols, rows) {
380381 return ptyProcess ;
381382}
382383
383- // WebSocket server
384- const wsServer = http . createServer ( ) ;
384+ // WebSocket server using ws package
385+ const wss = new WebSocketServer ( { port : WS_PORT , path : '/ws' } ) ;
385386
386- wsServer . on ( 'upgrade ' , ( req , socket , head ) => {
387+ wss . on ( 'connection ' , ( ws , req ) => {
387388 const url = new URL ( req . url , `http://${ req . headers . host } ` ) ;
388-
389- if ( url . pathname !== '/ws' ) {
390- socket . destroy ( ) ;
391- return ;
392- }
393-
394389 const cols = Number . parseInt ( url . searchParams . get ( 'cols' ) || '80' ) ;
395390 const rows = Number . parseInt ( url . searchParams . get ( 'rows' ) || '24' ) ;
396391
397- // Parse WebSocket key and create accept key
398- const key = req . headers [ 'sec-websocket-key' ] ;
399- const acceptKey = crypto
400- . createHash ( 'sha1' )
401- . update ( key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' )
402- . digest ( 'base64' ) ;
403-
404- // Send WebSocket handshake response
405- socket . write (
406- 'HTTP/1.1 101 Switching Protocols\r\n' +
407- 'Upgrade: websocket\r\n' +
408- 'Connection: Upgrade\r\n' +
409- 'Sec-WebSocket-Accept: ' +
410- acceptKey +
411- '\r\n\r\n'
412- ) ;
413-
414- const sessionId = crypto . randomUUID ( ) . slice ( 0 , 8 ) ;
415-
416392 // Create PTY
417393 const ptyProcess = createPtySession ( cols , rows ) ;
418- sessions . set ( socket , { id : sessionId , pty : ptyProcess } ) ;
394+ sessions . set ( ws , { pty : ptyProcess } ) ;
419395
420396 // PTY -> WebSocket
421397 ptyProcess . onData ( ( data ) => {
422- if ( socket . writable ) {
423- sendWebSocketFrame ( socket , data ) ;
398+ if ( ws . readyState === ws . OPEN ) {
399+ ws . send ( data ) ;
424400 }
425401 } ) ;
426402
427403 ptyProcess . onExit ( ( { exitCode } ) => {
428- sendWebSocketFrame ( socket , `\r\n\x1b[33mShell exited (code: ${ exitCode } )\x1b[0m\r\n` ) ;
429- socket . end ( ) ;
404+ if ( ws . readyState === ws . OPEN ) {
405+ ws . send ( `\r\n\x1b[33mShell exited (code: ${ exitCode } )\x1b[0m\r\n` ) ;
406+ ws . close ( ) ;
407+ }
430408 } ) ;
431409
432410 // WebSocket -> PTY
433- let buffer = Buffer . alloc ( 0 ) ;
434-
435- socket . on ( 'data' , ( chunk ) => {
436- buffer = Buffer . concat ( [ buffer , chunk ] ) ;
437-
438- while ( buffer . length >= 2 ) {
439- const fin = ( buffer [ 0 ] & 0x80 ) !== 0 ;
440- const opcode = buffer [ 0 ] & 0x0f ;
441- const masked = ( buffer [ 1 ] & 0x80 ) !== 0 ;
442- let payloadLength = buffer [ 1 ] & 0x7f ;
443-
444- let offset = 2 ;
445-
446- if ( payloadLength === 126 ) {
447- if ( buffer . length < 4 ) break ;
448- payloadLength = buffer . readUInt16BE ( 2 ) ;
449- offset = 4 ;
450- } else if ( payloadLength === 127 ) {
451- if ( buffer . length < 10 ) break ;
452- payloadLength = Number ( buffer . readBigUInt64BE ( 2 ) ) ;
453- offset = 10 ;
454- }
455-
456- const maskKeyOffset = offset ;
457- if ( masked ) offset += 4 ;
458-
459- const totalLength = offset + payloadLength ;
460- if ( buffer . length < totalLength ) break ;
461-
462- // Handle different opcodes
463- if ( opcode === 0x8 ) {
464- // Close frame
465- socket . end ( ) ;
466- break ;
467- }
468-
469- if ( opcode === 0x1 || opcode === 0x2 ) {
470- // Text or binary frame
471- let payload = buffer . slice ( offset , totalLength ) ;
472-
473- if ( masked ) {
474- const maskKey = buffer . slice ( maskKeyOffset , maskKeyOffset + 4 ) ;
475- payload = Buffer . from ( payload ) ;
476- for ( let i = 0 ; i < payload . length ; i ++ ) {
477- payload [ i ] ^= maskKey [ i % 4 ] ;
478- }
411+ ws . on ( 'message' , ( data ) => {
412+ const message = data . toString ( 'utf8' ) ;
413+
414+ // Check for resize message
415+ if ( message . startsWith ( '{' ) ) {
416+ try {
417+ const msg = JSON . parse ( message ) ;
418+ if ( msg . type === 'resize' ) {
419+ ptyProcess . resize ( msg . cols , msg . rows ) ;
420+ return ;
479421 }
480-
481- const data = payload . toString ( 'utf8' ) ;
482-
483- // Check for resize message
484- if ( data . startsWith ( '{' ) ) {
485- try {
486- const msg = JSON . parse ( data ) ;
487- if ( msg . type === 'resize' ) {
488- ptyProcess . resize ( msg . cols , msg . rows ) ;
489- buffer = buffer . slice ( totalLength ) ;
490- continue ;
491- }
492- } catch ( e ) {
493- // Not JSON, treat as input
494- }
495- }
496-
497- // Send to PTY
498- ptyProcess . write ( data ) ;
422+ } catch ( e ) {
423+ // Not JSON, treat as input
499424 }
500-
501- buffer = buffer . slice ( totalLength ) ;
502425 }
426+
427+ // Send to PTY
428+ ptyProcess . write ( message ) ;
503429 } ) ;
504430
505- socket . on ( 'close' , ( ) => {
506- const session = sessions . get ( socket ) ;
431+ ws . on ( 'close' , ( ) => {
432+ const session = sessions . get ( ws ) ;
507433 if ( session ) {
508434 session . pty . kill ( ) ;
509- sessions . delete ( socket ) ;
435+ sessions . delete ( ws ) ;
510436 }
511437 } ) ;
512438
513- socket . on ( 'error' , ( ) => {
439+ ws . on ( 'error' , ( ) => {
514440 // Ignore socket errors (connection reset, etc.)
515441 } ) ;
516442
517443 // Send welcome message
518444 setTimeout ( ( ) => {
445+ if ( ws . readyState !== ws . OPEN ) return ;
519446 const C = '\x1b[1;36m' ; // Cyan
520447 const G = '\x1b[1;32m' ; // Green
521448 const Y = '\x1b[1;33m' ; // Yellow
522449 const R = '\x1b[0m' ; // Reset
523- sendWebSocketFrame (
524- socket ,
525- `${ C } ╔══════════════════════════════════════════════════════════════╗${ R } \r\n`
526- ) ;
527- sendWebSocketFrame (
528- socket ,
450+ ws . send ( `${ C } ╔══════════════════════════════════════════════════════════════╗${ R } \r\n` ) ;
451+ ws . send (
529452 `${ C } ║${ R } ${ G } Welcome to ghostty-web!${ R } ${ C } ║${ R } \r\n`
530453 ) ;
531- sendWebSocketFrame (
532- socket ,
533- `${ C } ║${ R } ${ C } ║${ R } \r\n`
534- ) ;
535- sendWebSocketFrame (
536- socket ,
537- `${ C } ║${ R } You have a real shell session with full PTY support. ${ C } ║${ R } \r\n`
538- ) ;
539- sendWebSocketFrame (
540- socket ,
454+ ws . send ( `${ C } ║${ R } ${ C } ║${ R } \r\n` ) ;
455+ ws . send ( `${ C } ║${ R } You have a real shell session with full PTY support. ${ C } ║${ R } \r\n` ) ;
456+ ws . send (
541457 `${ C } ║${ R } Try: ${ Y } ls${ R } , ${ Y } cd${ R } , ${ Y } top${ R } , ${ Y } vim${ R } , or any command! ${ C } ║${ R } \r\n`
542458 ) ;
543- sendWebSocketFrame (
544- socket ,
545- `${ C } ╚══════════════════════════════════════════════════════════════╝${ R } \r\n\r\n`
546- ) ;
459+ ws . send ( `${ C } ╚══════════════════════════════════════════════════════════════╝${ R } \r\n\r\n` ) ;
547460 } , 100 ) ;
548461} ) ;
549462
550- function sendWebSocketFrame ( socket , data ) {
551- const payload = Buffer . from ( data , 'utf8' ) ;
552- let header ;
553-
554- if ( payload . length < 126 ) {
555- header = Buffer . alloc ( 2 ) ;
556- header [ 0 ] = 0x81 ; // FIN + text frame
557- header [ 1 ] = payload . length ;
558- } else if ( payload . length < 65536 ) {
559- header = Buffer . alloc ( 4 ) ;
560- header [ 0 ] = 0x81 ;
561- header [ 1 ] = 126 ;
562- header . writeUInt16BE ( payload . length , 2 ) ;
563- } else {
564- header = Buffer . alloc ( 10 ) ;
565- header [ 0 ] = 0x81 ;
566- header [ 1 ] = 127 ;
567- header . writeBigUInt64BE ( BigInt ( payload . length ) , 2 ) ;
568- }
569-
570- socket . write ( Buffer . concat ( [ header , payload ] ) ) ;
571- }
572-
573463// ============================================================================
574464// Startup
575465// ============================================================================
@@ -596,16 +486,14 @@ function printBanner(url) {
596486// Graceful shutdown
597487process . on ( 'SIGINT' , ( ) => {
598488 console . log ( '\n\nShutting down...' ) ;
599- for ( const [ socket , session ] of sessions . entries ( ) ) {
489+ for ( const [ ws , session ] of sessions . entries ( ) ) {
600490 session . pty . kill ( ) ;
601- socket . destroy ( ) ;
491+ ws . close ( ) ;
602492 }
493+ wss . close ( ) ;
603494 process . exit ( 0 ) ;
604495} ) ;
605496
606- // Start WebSocket PTY server (runs in both modes)
607- wsServer . listen ( WS_PORT ) ;
608-
609497// Start HTTP/Vite server
610498if ( DEV_MODE ) {
611499 // Dev mode: use Vite for hot reload
0 commit comments