一句话定位

在浏览器里运行一个真实的 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
客户端 → 服务端1cols;rowsterm.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/xterm6.0.0xterm.js 新包名(旧包 xterm 已废弃,v5 起迁移到 @xterm scope)
@xterm/addon-fit0.11.0尺寸自适应 addon
node-pty^1.1.0原生模块,需与 Node ABI 匹配
ws^8.18.0Node 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 节流

相关页面