一句话定位
在浏览器里运行一个真实的 shell:xterm.js 负责终端渲染与键盘事件,WebSocket 做双向管道,node-pty 在服务端开一个伪终端(PTY)承载 shell 进程。三件套是几乎所有网页终端(ttyd、Wetty、Cloud Shell、各类 WebIDE)的最小内核。
为什么需要 PTY
直接用 child_process.spawn 起 shell 并用 pipe 转发是不行的:
- 交互式程序(vim、top、ssh、带颜色的 ls)检测到 stdout 不是 TTY 会降级或拒绝工作
- 行编辑、回显、Ctrl+C 信号(SIGINT 由终端驱动产生)都依赖终端语义
- PTY(伪终端)由 master/slave 一对设备组成:程序看到 slave 就像看到真终端,宿主进程通过 master 读写,这正是 xterm.js ↔ shell 之间缺的那层
node-pty 就是 Node 里创建/管理 PTY 的绑定(源自微软,VS Code 内置终端同款)。
架构与数据流
┌──────────────┐ WebSocket ┌──────────────┐ pty ┌───────┐
│ xterm.js │ ◄────────────► │ server.js │ ◄────────► │ shell │
│ (browser) │ │ (ws server) │ │(zsh等)│
└──────────────┘ └──────────────┘ └───────┘
- 服务端单进程同时承载:静态文件服务(页面 + node_modules 里的 xterm ESM)和 WebSocketServer(复用同一个 http server,浏览器自动完成 HTTP Upgrade)
- 每个浏览器连接 spawn 一个独立 PTY;连接断开
term.kill(),shell 退出(exit)则通知浏览器并关连接,双向清理避免残留进程
消息协议(前缀字节复用单通道)
WebSocket 是单一文本通道,用首字符做类型分发:
| 方向 | 前缀 | 内容 | 动作 |
|---|---|---|---|
| 客户端 → 服务端 | 0 | 键盘输入文本 | term.write() 写入 pty |
| 客户端 → 服务端 | 1 | cols;rows | term.resize(cols, rows) |
| 服务端 → 客户端 | 无 | PTY 原始输出 | 直接 term.write() 渲染 |
回车触发命令执行靠的是 xterm.js onData 自动产生 \r,不需要特殊处理。
关键实现点
服务端(server.js,~110 行):
const term = pty.spawn(shell, [], {
name: "xterm-256color", // TERM 变量,影响颜色/能力
cols: 80, rows: 24, // 初始尺寸,后续靠 resize 消息同步
cwd: process.env.HOME,
env: process.env, // 继承环境变量
})
term.onData((data) => ws.send(data))
term.onExit(({ exitCode }) => { ws.send(...); ws.close() })- shell 选择:Windows 用
powershell.exe,其余用$SHELL || "bash" - 静态服务把
/node_modules/...映射到项目根目录,前端可直接 import ESM,零构建
前端(public/index.html):
const term = new Terminal({ cursorBlink: true, fontSize: 14 })
term.loadAddon(fit) // FitAddon:按容器算 cols/rows
ws.onopen = () => ws.send(`1${term.cols};${term.rows}`) // 建连先同步尺寸
term.onData((d) => ws.send(`0${d}`))
term.onResize(() => ws.send(`1${term.cols};${term.rows}`))
window.addEventListener("resize", () => fit.fit())- resize 链路:窗口变化 →
fit.fit()重算 →onResize回调 → 前缀1消息 → 服务端term.resize(),保证 vim/top 等全屏程序布局正确
依赖版本(demo 实测组合)
| 包 | 版本 | 说明 |
|---|---|---|
@xterm/xterm | 6.0.0 | xterm.js 新包名(旧包 xterm 已废弃,v5 起迁移到 @xterm scope) |
@xterm/addon-fit | 0.11.0 | 尺寸自适应 addon |
node-pty | ^1.1.0 | 原生模块,需与 Node ABI 匹配 |
ws | ^8.18.0 | Node WebSocket 服务端事实标准 |
坑:Node 24 下 node-pty 预编译二进制不可用,需 npm rebuild node-pty --build-from-source 源码编译(见 npm-rebuild-build-from-source),macOS 需 Xcode CLT。
走向生产的扩展点
demo 只覆盖最小闭环,生产级网页终端(ttyd/Wetty/Cloud Shell)还需要:
- 鉴权与隔离:当前实现任何人连上即获得本机 shell;需 token/OIDC + 容器/chroot 隔离
- 会话保持:连接断开即杀 shell,可用 tmux 作中间层(botmux 的 Web 终端即此思路,见 botmux),断线重连后 attach 回原会话
- 二进制安全:文本协议 + 前缀字节对二进制输入不鲁棒,生产实现多用 binary frame 或 JSON 消息
- 输出背压:大量输出(如
cat大文件)时 ws.send 无限堆积,需 bufferedAmount 节流
相关页面
- web-terminal-pty — 本 demo 的源码逐段讲解
- npm-rebuild-build-from-source — node-pty 原生模块重编译
- botmux — Web 终端 + tmux 常驻的实际应用
- application-octet-stream — MIME 类型(静态服务 Content-Type 处理相关)