Skip to content

Commit 7b2dd99

Browse files
authored
fix: persist VT stream parser state across writes (#63)
1 parent 5d6bd7b commit 7b2dd99

File tree

4 files changed

+61
-162
lines changed

4 files changed

+61
-162
lines changed

demo/bin/demo.js

Lines changed: 42 additions & 154 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
* Run with: npx @ghostty-web/demo
88
*/
99

10-
import crypto from 'crypto';
1110
import fs from 'fs';
1211
import http from 'http';
1312
import { homedir } from 'os';
@@ -16,6 +15,8 @@ import { fileURLToPath } from 'url';
1615

1716
// Node-pty for cross-platform PTY support
1817
import pty from '@lydell/node-pty';
18+
// WebSocket server
19+
import { WebSocketServer } from 'ws';
1920

2021
const __filename = fileURLToPath(import.meta.url);
2122
const __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

355356
const 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
597487
process.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
610498
if (DEV_MODE) {
611499
// Dev mode: use Vite for hot reload

demo/bun.lock

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"dependencies": {
88
"@lydell/node-pty": "^1.0.1",
99
"ghostty-web": "latest",
10+
"ws": "^8.18.0",
1011
},
1112
},
1213
},
@@ -26,5 +27,7 @@
2627
"@lydell/node-pty-win32-x64": ["@lydell/node-pty-win32-x64@1.1.0", "", { "os": "win32", "cpu": "x64" }, "sha512-3N56BZ+WDFnUMYRtsrr7Ky2mhWGl9xXcyqR6cexfuCqcz9RNWL+KoXRv/nZylY5dYaXkft4JaR1uVu+roiZDAw=="],
2728

2829
"ghostty-web": ["ghostty-web@0.2.1", "", {}, "sha512-wrovbPlHcl+nIkp7S7fY7vOTsmBjwMFihZEe2PJe/M6G4/EwuyJnwaWTTzNfuY7RcM/lVlN+PvGWqJIhKSB5hw=="],
30+
31+
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
2932
}
3033
}

demo/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
},
1313
"dependencies": {
1414
"@lydell/node-pty": "^1.0.1",
15-
"ghostty-web": "latest"
15+
"ghostty-web": "latest",
16+
"ws": "^8.18.0"
1617
},
1718
"files": [
1819
"bin",

patches/ghostty-wasm-api.patch

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -606,10 +606,10 @@ index bc92597f5..d988967f7 100644
606606
_ = @import("../../lib/allocator.zig");
607607
diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig
608608
new file mode 100644
609-
index 000000000..c39b0791c
609+
index 000000000..e79702488
610610
--- /dev/null
611611
+++ b/src/terminal/c/terminal.zig
612-
@@ -0,0 +1,604 @@
612+
@@ -0,0 +1,611 @@
613613
+//! C API wrapper for Terminal
614614
+//!
615615
+//! This provides a C-compatible interface to Ghostty's Terminal for WASM export.
@@ -641,6 +641,10 @@ index 000000000..c39b0791c
641641
+ /// The terminal instance
642642
+ terminal: Terminal,
643643
+
644+
+ /// Persistent VT stream for parsing (preserves state across writes)
645+
+ /// This is critical for handling escape sequences split across WebSocket messages.
646+
+ stream: ReadonlyStream,
647+
+
644648
+ /// Dirty tracking - which rows have changed since last clear
645649
+ dirty_rows: []bool,
646650
+
@@ -765,17 +769,22 @@ index 000000000..c39b0791c
765769
+ wrapper.* = .{
766770
+ .alloc = alloc,
767771
+ .terminal = terminal,
772+
+ .stream = undefined, // Will be initialized below
768773
+ .dirty_rows = dirty_rows,
769774
+ .config = config,
770775
+ };
771776
+
777+
+ // Initialize the persistent VT stream (must be done after terminal is set)
778+
+ wrapper.stream = wrapper.terminal.vtStream();
779+
+
772780
+ return @ptrCast(wrapper);
773781
+}
774782
+
775783
+pub fn free(ptr: ?*anyopaque) callconv(.c) void {
776784
+ const wrapper: *TerminalWrapper = @ptrCast(@alignCast(ptr orelse return));
777785
+ const alloc = wrapper.alloc;
778786
+
787+
+ wrapper.stream.deinit();
779788
+ alloc.free(wrapper.dirty_rows);
780789
+ wrapper.terminal.deinit(alloc);
781790
+ alloc.destroy(wrapper);
@@ -811,12 +820,10 @@ index 000000000..c39b0791c
811820
+pub fn write(ptr: ?*anyopaque, data: [*]const u8, len: usize) callconv(.c) void {
812821
+ const wrapper: *TerminalWrapper = @ptrCast(@alignCast(ptr orelse return));
813822
+
814-
+ // Create stream for this write operation
815-
+ var stream = wrapper.terminal.vtStream();
816-
+
817-
+ // Write data to terminal stream (this parses VT sequences)
823+
+ // Use persistent stream to preserve parser state across writes
824+
+ // This is critical for handling escape sequences split across WebSocket messages
818825
+ const slice = data[0..len];
819-
+ stream.nextSlice(slice) catch |err| {
826+
+ wrapper.stream.nextSlice(slice) catch |err| {
820827
+ log.err("Write failed: {}", .{err});
821828
+ return;
822829
+ };

0 commit comments

Comments
 (0)