源码副本已存档至 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/rows

FitAddon 的作用:浏览器是像素世界,终端是字符行列世界,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两个方向都兜底,不留僵尸

相关页面