编程 bun-pty + xterm.js 实现 Web 端 CMD 终端:伪终端与 WebSocket 直连

2026-09-02 10:01:24

问题:child_process.spawn 启动的 cmd 按不了 Ctrl+C

用 Bun 或 Node 启动一个 cmd.exe:

const { spawn } = require("child_process");
const cmd = spawn("cmd.exe");
cmd.stdout.on("data", (d) => process.stdout.write(d));
cmd.stdin.write("ping localhost -n 10\r\n");

输出正常,但按下 Ctrl+C 没有反应。

原因不在信号转发,而在管道类型。child_process.spawn 默认给子进程配的是匿名管道(anonymous pipe),cmd 在里面不认为自己连接到一个真实的控制台(console),自然不走中断这条路径。

bun-pty 的做法

bun-pty 在 Windows 上调用的是系统提供的 CreatePseudoConsole API。伪终端(pseudo console)是 Win10 之后系统级支持的机制,和用户直接在系统里打开 cmd 用的是同一套终端基础。所以 Ctrl+C、Tab 补全、ANSI 颜色这些行为都和原生 cmd 一致。

最小可执行例子:

import { spawn as ptySpawn } from "bun-pty";
import os from "node:os";

const pty = ptySpawn("cmd.exe", [], {
name: "xterm-256color",
cols: 80,
rows: 24,
cwd: os.homedir(),
env: { ...process.env, TERM: "xterm-256color" },
});

pty.onData((data) => process.stdout.write(data));
pty.write("echo hello world\r\n");

运行:

bun add bun-pty
bun run demo.ts

输出:

hello world
C:\Users\yourname>

参数取舍说明:

  • name: "xterm-256color":让 cmd 输出 ANSI 颜色转义序列。
  • cols/rows: 80/24:初始终端尺寸,随后可以通过 pty.resize(cols, rows) 修改。
  • cwd:指定 cmd 的工作目录。
  • env.TERM:把终端类型传给 shell。

不需要给 cmd 传 /K 参数。例如:

// 不推荐
ptySpawn("cmd.exe", ["/K", "cd /d D:\\project"]);

/K 会额外执行一条命令,输出多一行,而且 shell 启动状态不干净。直接把 cwd 指过去,效果一样,输出干净。

接到 WebSocket

PTY 跑在服务端,浏览器里的 xterm.js 需要通过网络拿数据。Bun 的 Bun.serve 原生支持 WebSocket。

一个容易踩的配置点:server.upgrade(req)websocket: wsHandler 必须同时存在。

// ❌ 只配 websocket,不在 fetch 里 upgrade
Bun.serve({
fetch() {},
websocket: wsHandler,
});

// ❌ 只调 upgrade,不提供 websocket handler
Bun.serve({
fetch(req, s) {
s.upgrade(req);
},
});

正确写法:

const wsHandler: WebSocketHandler = {
open(ws) {
console.log("connection open");
},
message(ws, message) {
// 这里把数据写入 PTY
},
close(ws) {
// 杀掉 PTY 进程
},
};

const server = Bun.serve({
port: 3000,
fetch(req, server) {
const url = new URL(req.url);

if (url.pathname === "/ws") {
if (server.upgrade(req)) return;
return new Response("Upgrade failed", { status: 400 });
}

// 静态页面等其他路由
return new Response("Hello");
},
websocket: wsHandler,
});

数据格式:普通输入直接透传,resize 单独走协议

快捷键、命令输入、粘贴内容这些,前端直接发原始字符串,后端直接 pty.write()。不做 JSON 包装。

唯一需要单独标识的是 resize 消息,用下面这个格式:

\x1b[DIMENSION:100x30

\x1b[ 是 ANSI 转义前缀,普通用户输入里几乎不会出现这个序列,后端据此可以区分 resize 和普通输入。

后端解析逻辑:

message(ws, message) {
const data = typeof message === "string" ? message : new TextDecoder().decode(message);

if (data.startsWith("\x1b[DIMENSION:")) {
const parts = data.slice(12).split("x");
if (parts.length === 2) {
const cols = parseInt(parts[0], 10);
const rows = parseInt(parts[1], 10);
if (!isNaN(cols) && !isNaN(rows) && cols > 0 && rows > 0) {
pty.resize(cols, rows);
return;
}
}
}

// 普通输入
pty.write(data);
}

注意 data.slice(12)\x1b[DIMENSION: 去掉前缀后的内容才是 100x30,按 x 拆开。

后端完整代码

// server.ts
import { spawn as ptySpawn, type IPty } from "bun-pty";
import { join } from "node:path";
import { readFileSync } from "node:fs";
import os from "node:os";

const PORT = 3000;
const sessions = new Map();

function defaultShell(): { cmd: string; args: string[] } {
if (process.platform === "win32") return { cmd: "cmd.exe", args: [] };
if (process.platform === "darwin") return { cmd: process.env.SHELL ?? "/bin/zsh", args: ["-i"] };
return { cmd: process.env.SHELL ?? "/bin/bash", args: ["-i"] };
}

function generateId(): string {
return `term-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
}

function createTerminal(ws: WebSocket): string | null {
const sessionId = generateId();
const cwd = process.env.HOME || os.homedir();
const { cmd, args } = defaultShell();

try {
const pty = ptySpawn(cmd, args, {
name: "xterm-256color",
cols: 80,
rows: 24,
cwd,
env: { ...process.env, TERM: "xterm-256color" },
});

sessions.set(sessionId, pty);
console.log(`[terminal] created: ${sessionId} pid=${pty.pid}`);

pty.onData((data) => ws.send(data));

pty.onExit(() => {
sessions.delete(sessionId);
ws.send("\r\n[进程已退出,连接关闭]\r\n");
ws.close();
});

return sessionId;
} catch (err) {
console.error(`[terminal] spawn failed:`, err);
return null;
}
}

const wsHandler: WebSocketHandler = {
open(ws) {
const sessionId = createTerminal(ws);
if (!sessionId) {
ws.send("\r\n启动终端失败\r\n");
ws.close();
return;
}
(ws as any)._sessionId = sessionId;
},

message(ws, message) {
const sessionId = (ws as any)._sessionId;
const pty = sessions.get(sessionId);
if (!pty) return;

const data = typeof message === "string" ? message : new TextDecoder().decode(message);

if (data.startsWith("\x1b[DIMENSION:")) {
const parts = data.slice(12).split("x");
if (parts.length === 2) {
const cols = parseInt(parts[0], 10);
const rows = parseInt(parts[1], 10);
if (!isNaN(cols) && !isNaN(rows) && cols > 0 && rows > 0) {
pty.resize(cols, rows);
return;
}
}
}

pty.write(data);
},

close(ws) {
const sessionId = (ws as any)._sessionId;
const pty = sessions.get(sessionId);
if (pty) {
pty.kill();
sessions.delete(sessionId);
}
},
};

const server = Bun.serve({
port: PORT,
fetch(req, server) {
const url = new URL(req.url);

if (url.pathname === "/ws") {
if (server.upgrade(req)) return;
return new Response("WebSocket upgrade failed", { status: 400 });
}

if (url.pathname === "/" || url.pathname === "/index.html") {
try {
const filePath = join(import.meta.dir, "static", "index.html");
const html = readFileSync(filePath, "utf-8");
return new Response(html, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
} catch {
return new Response("404 Not Found", { status: 404 });
}
}

return new Response("404 Not Found", { status: 404 });
},
websocket: wsHandler,
});

console.log(`\n终端服务已启动: http://localhost:${PORT}\n`);

注意 openmessageclose 三个回调与 PTY 生命周期的对应关系:

  • open:创建一个 PTY,并绑定到当前 WebSocket 连接
  • message:普通输入直接写入 PTY,resize 消息另做处理
  • close:杀掉对应 PTY 进程

一个 WebSocket 连接对应一个终端实例。

前端 xterm.js

前端直接用 CDN 引入,不需要本地构建:


初始化终端:

const term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: '"Cascadia Code", "Consolas", monospace',
scrollback: 5000,
theme: {
background: '#0d0d0d',
foreground: '#f5f5f5',
cursor: '#ffffff',
},
});

const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById('terminal'));
fitAddon.fit();

连接 WebSocket:

const socket = new WebSocket(`ws://${location.host}/ws`);

socket.addEventListener('open', () => {
term.write('终端已连接\r\n');
socket.send(`\x1b[DIMENSION:${term.cols}x${term.rows}`);
});

socket.addEventListener('message', (event) => {
term.write(event.data);
});

双向绑定:

// 用户输入 -> WebSocket -> PTY
term.onData((data) => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(data);
}
});

// 终端尺寸变化 -> resize PTY
term.onResize(({ cols, rows }) => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(`\x1b[DIMENSION:${cols}x${rows}`);
}
});

// 浏览器窗口变化 -> 重新 fit
window.addEventListener('resize', () => fitAddon.fit());

打开页面后的效果:

✓ 终端已连接
欢迎使用 Bun.js CMD Demo
这是一个基于 bun-pty 和 WebSocket 的 Web 终端演示
Microsoft Windows [版本 10.0.19045.3570]
(c) Microsoft Corporation。保留所有权利。

C:\Users\yourname>

输入 dir 回车,目录列表正常输出。

项目结构

bun-cmd-demo/
├── package.json
├── server.ts
└── static/
└── index.html

package.json

{
"dependencies": {
"bun-pty": "^0.4.10"
},
"scripts": {
"dev": "bun run server.ts"
}
}

安装并启动:

bun install
bun run dev

浏览器打开 http://localhost:3000

数据流

打开页面时:

浏览器 -> HTTP GET / -> 返回 index.html
-> new WebSocket('ws://host/ws') -> open -> spawn cmd.exe
-> pty.onData() -> ws.send() -> term.write()

输入命令时:

用户输入 "dir" -> term.onData("dir\r\n") -> ws.send("dir\r\n")
-> message -> pty.write("dir\r\n") -> cmd 执行
-> pty.onData() -> ws.send() -> term.write()

调整窗口大小时:

onResize({100,30}) -> ws.send("\x1b[DIMENSION:100x30")
-> message -> 检测到前缀 -> pty.resize(100, 30)

数据全程透传,没有中间包装。这是行为与系统 cmd 保持一致的关键。

复制全文 生成海报 Bun bun-pty xterm.js WebSocket 伪终端 Web终端

推荐文章

程序员茄子在线接单