Alex Severyanov  ·  2026-03-25

ws-handler.ts

  1import type { Server as HttpServer } from 'http';
  2import { WebSocketServer, type WebSocket } from 'ws';
  3import {
  4	startSession,
  5	sendInput,
  6	sendReviewPrompt,
  7	resize,
  8	killSession,
  9	isAlive,
 10	getBufferedOutput,
 11	onData,
 12	onExit
 13} from './pty-session.ts';
 14
 15export function attachWebSocket(server: HttpServer, appPort?: number): void {
 16	const wss = new WebSocketServer({ noServer: true });
 17
 18	server.on('upgrade', (req, socket, head) => {
 19		if (req.url === '/ws/terminal') {
 20			wss.handleUpgrade(req, socket, head, (ws) => {
 21				wss.emit('connection', ws, req);
 22			});
 23		}
 24		// Don't destroy other upgrade requests — Vite HMR uses WebSocket too
 25	});
 26
 27	wss.on('connection', async (ws: WebSocket) => {
 28		// Don't start session yet — wait for first resize message with correct dimensions.
 29		// If session is already running, send buffered output immediately.
 30		const buffered = isAlive() ? getBufferedOutput() : null;
 31		if (buffered) {
 32			ws.send(JSON.stringify({ type: 'output', data: buffered }));
 33		}
 34
 35		// Forward PTY output to WebSocket
 36		const unsubData = onData((data) => {
 37			if (ws.readyState === ws.OPEN) {
 38				ws.send(JSON.stringify({ type: 'output', data }));
 39			}
 40		});
 41
 42		const unsubExit = onExit((code) => {
 43			if (ws.readyState === ws.OPEN) {
 44				ws.send(JSON.stringify({ type: 'exit', code }));
 45			}
 46		});
 47
 48		// Handle messages from client
 49		ws.on('message', async (raw) => {
 50			try {
 51				const msg = JSON.parse(raw.toString());
 52
 53				switch (msg.type) {
 54					case 'input':
 55						sendInput(msg.data);
 56						break;
 57
 58					case 'review': {
 59						// Fetch the review prompt via HTTP to ensure we use the same
 60						// module instance as the SvelteKit API routes
 61						const port = appPort || (() => { const a = server.address(); return typeof a === 'object' && a ? a.port : 5173; })();
 62						try {
 63							const res = await fetch(`http://localhost:${port}/api/export`, {
 64								method: 'POST',
 65								headers: { 'Content-Type': 'application/json' },
 66								body: JSON.stringify({ format: 'clipboard' })
 67							});
 68							const result = await res.json();
 69							if (!result.success || !result.text || result.text === 'No review comments.') {
 70								ws.send(JSON.stringify({
 71									type: 'output',
 72									data: '\r\n\x1b[33mâš  No review comments to send. Add comments first.\x1b[0m\r\n'
 73								}));
 74								break;
 75							}
 76							if (!isAlive()) {
 77								// Session should already be started by resize on connect.
 78								// If somehow not, start with defaults and wait.
 79								await startSession(80, 24);
 80								await new Promise((r) => setTimeout(r, 2000));
 81							}
 82							sendReviewPrompt(result.text);
 83						} catch (err) {
 84							ws.send(JSON.stringify({
 85								type: 'output',
 86								data: `\r\n\x1b[31mFailed to fetch review: ${err}\x1b[0m\r\n`
 87							}));
 88						}
 89						break;
 90					}
 91
 92					case 'resize':
 93						if (msg.cols && msg.rows) {
 94							if (!isAlive()) {
 95								// First resize — start session with correct dimensions
 96								try {
 97									await startSession(msg.cols, msg.rows);
 98									const buf = getBufferedOutput();
 99									if (buf) ws.send(JSON.stringify({ type: 'output', data: buf }));
100								} catch (err) {
101									ws.send(JSON.stringify({
102										type: 'output',
103										data: `\r\n\x1b[31mFailed to start terminal: ${err}\x1b[0m\r\n`
104									}));
105								}
106							} else {
107								resize(msg.cols, msg.rows);
108							}
109						}
110						break;
111
112					case 'stop':
113						killSession();
114						break;
115
116					case 'restart':
117						killSession();
118						// Ask client to send dimensions so we restart with correct size
119						ws.send(JSON.stringify({ type: 'request-resize' }));
120						break;
121				}
122			} catch (err) {
123				console.error('WS message error:', err);
124			}
125		});
126
127		ws.on('close', () => {
128			unsubData();
129			unsubExit();
130		});
131	});
132}