源码副本已存档至
raw/repos/web-terminal-pty/(server.js / public/index.html / package.json)。 架构与概念总览见 web-terminal-xterm-node-pty,本页逐文件讲源码。
项目结构
pty/
├── server.js # 静态文件服务 + WebSocketServer + pty 管理(全部服务端逻辑)
├── public/index.html # xterm.js 前端页面(全部前端逻辑)
└── package.json # 4 个依赖 + postinstall 源码编译 node-pty
整体约 170 行,零构建:前端直接 import node_modules 里的 ESM,服务端用原生 http 模块做静态服务。
server.js 逐段讲解
1. import 与根目录定位
import pty from "node-pty" // 创建伪终端(PTY)
import { WebSocketServer } from "ws" // WebSocket 服务端
const root = path.dirname(fileURLToPath(import.meta.url))import.meta.url 是当前文件的 file:///... URL,转路径后取目录 —— 无论从哪个 cwd 启动脚本都能正确定位项目根目录(ESM 下没有 __dirname,这是标准替代写法)。
2. 静态文件服务
const mime = { ".html": "text/html", ".js": "text/javascript",
".mjs": "text/javascript", ".css": "text/css" }
const server = http.createServer((req, res) => {
const url = req.url === "/" ? "/index.html" : req.url
// /node_modules/... 去项目根目录找(xterm.js 库文件);其余去 public/
const filePath = url.startsWith("/node_modules/")
? path.join(root, url)
: path.join(root, "public", url)
fs.readFile(filePath, (err, data) => { ... })
})两个技术点:
.mjs的 Content-Type 必须是text/javascript:浏览器按 MIME 类型而非扩展名决定是否执行 JS,写错会被拒绝加载(相关:application-octet-stream)- 路由白名单式分流:只有
/node_modules/前缀映射到根目录,其余都限制在public/内,避免任意文件读取(demo 级防护,生产还需防../穿越)
3. WebSocketServer 复用 http server
const wss = new WebSocketServer({ server })把 server 传给 WebSocketServer,WebSocket 握手复用同一个 HTTP 监听(HTTP Upgrade 机制),同端口同时提供静态文件和 ws,不需要额外开端口。
4. connection 回调:每连接一个 PTY
wss.on("connection", (ws) => {
const shell = process.platform === "win32" ? "powershell.exe"
: process.env.SHELL || "bash"
const term = pty.spawn(shell, [], {
name: "xterm-256color", // 写入子进程 TERM 环境变量,影响颜色/光标能力
cols: 80, rows: 24, // 初始尺寸,与前端默认一致
cwd: process.env.HOME,
env: process.env, // 完整继承环境变量(否则 zsh 配置可能失效)
})
term.onData((data) => ws.send(data)) // shell 输出 → 浏览器
term.onExit(({ exitCode }) => { // shell 退出(exit)→ 通知并断开
ws.send(`\r\n[process exited with code ${exitCode}]\r\n`)
ws.close()
})
...
})关键点:每个浏览器连接独立 spawn 一个 PTY,连接之间完全隔离。
5. 消息分发:前缀字节协议
ws.on("message", (msg) => {
const text = msg.toString()
if (text.startsWith("1")) { // resize 消息:"180;24"
const [cols, rows] = text.slice(1).split(";").map(Number)
if (cols > 0 && rows > 0) term.resize(cols, rows)
return
}
term.write(text.slice(1)) // 输入消息:"0" + 键盘内容
})
ws.on("close", () => term.kill()) // 断连清理,防进程残留单通道复用两种消息,首字符做类型前缀。回车触发命令执行不需要特判 —— xterm.js 的 onData 在按回车时自动产生 \r,shell 侧按常规输入处理。
index.html 逐段讲解
1. 零构建加载 xterm.js
<link rel="stylesheet" href="/node_modules/@xterm/xterm/css/xterm.css" />
<script type="module">
import { Terminal } from "/node_modules/@xterm/xterm/lib/xterm.mjs"
import { FitAddon } from "/node_modules/@xterm/addon-fit/lib/addon-fit.mjs"@xterm/xterm 提供 ESM 产物(.mjs),配合 type="module" 直接 import,不需要 Vite/webpack。依赖前面 server.js 对 /node_modules/ 的路由映射。
2. 终端初始化与 FitAddon
const term = new Terminal({ cursorBlink: true, fontSize: 14 })
const fit = new FitAddon()
term.loadAddon(fit)
term.open(document.getElementById("terminal")) // 渲染到 div
fit.fit() // 按容器像素反推 cols/rowsFitAddon 的作用:浏览器是像素世界,终端是字符行列世界,fit() 用字符宽高除容器尺寸算出 cols/rows。没有它终端不会随窗口缩放自适应。
3. WebSocket 接线(与前端事件一一对应)
const ws = new WebSocket(`ws://${location.host}`) // 同端口,自动 Upgrade
ws.onopen = () => ws.send(`1${term.cols};${term.rows}`) // 建连先同步初始尺寸
ws.onmessage = (e) => term.write(e.data) // shell 输出直接渲染
ws.onclose = () => term.write("\r\n[connection closed]\r\n")
term.onData((data) => ws.readyState === WebSocket.OPEN && ws.send(`0${data}`))
term.onResize(() => ws.readyState === WebSocket.OPEN && ws.send(`1${term.cols};${term.rows}`))
window.addEventListener("resize", () => fit.fit())resize 完整链路(全屏程序如 vim 布局正确的关键):
窗口缩放 → fit.fit() 重算 cols/rows → term.onResize 回调
→ 前缀 "1" 消息 → 服务端 term.resize() → shell/应用感知新尺寸
readyState === OPEN 判断防止断连瞬间的写入抛异常。
package.json 的关键一行
{
"type": "module",
"scripts": {
"start": "node server.js",
"postinstall": "npm rebuild node-pty --build-from-source"
}
}node-pty 是原生 C++ 模块,依赖与 Node ABI 匹配的 prebuild 二进制;Node 24 尚无 prebuild,postinstall 自动触发源码编译(macOS 需 Xcode CLT)。机制详见 npm-rebuild-build-from-source。
关键技术点小结
| 技术点 | 实现 | 为什么这么做 |
|---|---|---|
| 伪终端 | pty.spawn 而非 child_process | 交互式程序需要真 TTY 语义(颜色/行编辑/信号) |
| 端口复用 | new WebSocketServer({ server }) | 静态服务与 ws 共享 HTTP Upgrade |
| 单通道多消息 | 首字符前缀 0/1 | 免 JSON 开销,demo 级够用;生产建议二进制帧 |
| 尺寸同步 | FitAddon + onResize + term.resize | 字符世界 ↔ 像素世界换算 |
| 零构建 | ESM + import node_modules | 依赖少时省掉整条构建链 |
| 双向清理 | onExit 关 ws / close 杀 pty | 两个方向都兜底,不留僵尸 |
相关页面
- web-terminal-xterm-node-pty — 架构总览与生产化扩展点
- npm-rebuild-build-from-source — node-pty 源码重编译机制
- botmux — Web 终端 + tmux 常驻的实际应用