问题: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`);
注意 open、message、close 三个回调与 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 保持一致的关键。