Without error handling, if node-pty require or pty.spawn throws (bad permissions, missing cwd, stale server), the client gets no response and the terminal hangs with a blinking cursor. Now sends an exit event back.
185 lines
4.6 KiB
TypeScript
185 lines
4.6 KiB
TypeScript
import { WebSocketServer, type WebSocket } from "ws";
|
|
import type { IncomingMessage } from "node:http";
|
|
import { dirname, join } from "node:path";
|
|
import { createRequire } from "node:module";
|
|
import { chmodSync, existsSync } from "node:fs";
|
|
|
|
interface TerminalSession {
|
|
pty: import("node-pty").IPty;
|
|
ws: WebSocket;
|
|
}
|
|
|
|
const sessions = new Map<WebSocket, TerminalSession>();
|
|
|
|
let wss: WebSocketServer | null = null;
|
|
let didFixSpawnHelper = false;
|
|
|
|
function ensureSpawnHelperExecutable() {
|
|
if (didFixSpawnHelper || process.platform === "win32") return;
|
|
didFixSpawnHelper = true;
|
|
try {
|
|
const req = createRequire(import.meta.url);
|
|
const pkgPath = req.resolve("node-pty/package.json");
|
|
const pkgDir = dirname(pkgPath);
|
|
const candidates = [
|
|
join(pkgDir, "build", "Release", "spawn-helper"),
|
|
join(pkgDir, "build", "Debug", "spawn-helper"),
|
|
join(pkgDir, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"),
|
|
];
|
|
for (const candidate of candidates) {
|
|
if (existsSync(candidate)) {
|
|
chmodSync(candidate, 0o755);
|
|
}
|
|
}
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
}
|
|
|
|
function defaultShell(): string {
|
|
if (process.platform === "win32") {
|
|
return process.env.ComSpec ?? "cmd.exe";
|
|
}
|
|
return process.env.SHELL ?? "/bin/zsh";
|
|
}
|
|
|
|
function shellArgs(shell: string): string[] {
|
|
const name = shell.split("/").pop()?.toLowerCase() ?? "";
|
|
if (process.platform !== "win32" && name === "zsh") {
|
|
return ["-o", "nopromptsp"];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function spawnTerminal(ws: WebSocket, cols: number, rows: number, cwd?: string) {
|
|
ensureSpawnHelperExecutable();
|
|
|
|
let nodePty: typeof import("node-pty");
|
|
try {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
nodePty = require("node-pty") as typeof import("node-pty");
|
|
} catch {
|
|
ws.send(JSON.stringify({ type: "exit", exitCode: 1, signal: null }));
|
|
return;
|
|
}
|
|
|
|
const shell = defaultShell();
|
|
const spawnCwd = cwd || process.env.HOME || process.cwd();
|
|
|
|
let pty: import("node-pty").IPty;
|
|
try {
|
|
pty = nodePty.spawn(shell, shellArgs(shell), {
|
|
name: "xterm-256color",
|
|
cols,
|
|
rows,
|
|
cwd: spawnCwd,
|
|
env: Object.fromEntries(
|
|
Object.entries(process.env).filter(
|
|
([, v]) => v !== undefined,
|
|
),
|
|
) as Record<string, string>,
|
|
});
|
|
} catch {
|
|
ws.send(JSON.stringify({ type: "exit", exitCode: 1, signal: null }));
|
|
return;
|
|
}
|
|
|
|
const session: TerminalSession = { pty, ws };
|
|
sessions.set(ws, session);
|
|
|
|
pty.onData((data) => {
|
|
if (ws.readyState === ws.OPEN) {
|
|
ws.send(JSON.stringify({ type: "output", data }));
|
|
}
|
|
});
|
|
|
|
pty.onExit(({ exitCode, signal }) => {
|
|
if (ws.readyState === ws.OPEN) {
|
|
ws.send(JSON.stringify({ type: "exit", exitCode, signal }));
|
|
}
|
|
sessions.delete(ws);
|
|
});
|
|
|
|
ws.send(JSON.stringify({ type: "ready", pid: pty.pid }));
|
|
}
|
|
|
|
function handleMessage(ws: WebSocket, raw: string) {
|
|
let msg: { type: string; data?: string; cols?: number; rows?: number; cwd?: string };
|
|
try {
|
|
msg = JSON.parse(raw);
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
const session = sessions.get(ws);
|
|
|
|
switch (msg.type) {
|
|
case "spawn": {
|
|
if (session) {
|
|
session.pty.kill();
|
|
sessions.delete(ws);
|
|
}
|
|
spawnTerminal(ws, msg.cols ?? 80, msg.rows ?? 24, msg.cwd);
|
|
break;
|
|
}
|
|
case "input": {
|
|
if (session && msg.data) {
|
|
session.pty.write(msg.data);
|
|
}
|
|
break;
|
|
}
|
|
case "resize": {
|
|
if (session && msg.cols && msg.rows) {
|
|
session.pty.resize(msg.cols, msg.rows);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleConnection(ws: WebSocket, _req: IncomingMessage) {
|
|
ws.on("message", (data) => {
|
|
handleMessage(ws, data.toString());
|
|
});
|
|
|
|
ws.on("close", () => {
|
|
const session = sessions.get(ws);
|
|
if (session) {
|
|
session.pty.kill();
|
|
sessions.delete(ws);
|
|
}
|
|
});
|
|
|
|
ws.on("error", () => {
|
|
const session = sessions.get(ws);
|
|
if (session) {
|
|
session.pty.kill();
|
|
sessions.delete(ws);
|
|
}
|
|
});
|
|
}
|
|
|
|
export function startTerminalServer(port: number) {
|
|
if (wss) return;
|
|
|
|
wss = new WebSocketServer({ port, host: "127.0.0.1" });
|
|
wss.on("connection", handleConnection);
|
|
wss.on("error", (err) => {
|
|
if ((err as NodeJS.ErrnoException).code === "EADDRINUSE") {
|
|
console.warn(`[terminal] Port ${port} in use, retrying on ${port + 1}`);
|
|
wss = null;
|
|
startTerminalServer(port + 1);
|
|
}
|
|
});
|
|
}
|
|
|
|
export function stopTerminalServer() {
|
|
if (!wss) return;
|
|
for (const session of sessions.values()) {
|
|
session.pty.kill();
|
|
}
|
|
sessions.clear();
|
|
wss.close();
|
|
wss = null;
|
|
}
|