浏览器里的真实终端?DSH-better-sidebar用xterm.js+node-pty实现真Shell的完整原理(含断线重连回放)
【免费下载链接】DSH-better-sidebar开放的侧边栏底座,支持三方拓展注册新侧边栏页面。内置文件渲染编辑/终端/侧边对话/Git/子代理页面 | Open sidebar foundation, supports third-party extensions to register new sidebar pages. Built-in file rendering/editing, terminal, side chat, Git, and sub-agent pages.项目地址: https://gitcode.com/gh_mirrors/ds/DSH-better-sidebar
DSH-better-sidebar 是一款 DSH 网页插件,它在浏览器侧边栏里内置了文件编辑、终端、侧边对话、Git 与子代理页面。其中的终端不是"模拟出来的假终端",而是基于xterm.js + node-pty + WebSocket实现的真实 Shell:页面刷新、切换会话、网络抖动之后还能重连回同一个进程,并把断线期间的历史输出完整回放。下面用最少代码量,完整讲清这条链路的原理。
为什么浏览器终端分"前后两半"
浏览器出于安全限制不能直接启动系统进程,所以"浏览器终端"必然拆成两半:
| 组件 | 位置 | 职责 |
|---|---|---|
| node-pty | 服务端(Node 进程内) | 真正fork出 shell 进程,分配伪终端(pty),持有进程生命周期 |
| WebSocket | 双向通道 | 把键盘输入送到 pty,把 pty 输出流推回浏览器 |
| xterm.js | 浏览器前端 | 解析 ANSI 转义序列、渲染字符网格、处理光标与滚动 |
这个项目里,前后两半分别落在 src/client/TerminalView.tsx 与 src/pty-manager.ts、src/index.ts。理解终端的一切行为(包括断线重连),本质上就是理解这两半如何"续命"。
node-pty 端:如何在一个 Web 服务里开出真 Shell
Shell 解析链:配置 → 环境变量 → 登录 Shell
服务端并不是随便启动一个bash。src/pty-manager.ts 中的defaultShell()按以下优先级解析:
- 用户显式配置的
shell(见 src/config.ts 的shell/shellArgs字段); - POSIX 下的
$SHELL环境变量; - 系统账户的登录 Shell(比如 zsh);
- 兜底
/bin/bash。
Windows 侧则是:DSH_SIDEBAR_SHELL→ PATH 或已知安装目录里的pwsh.exe(优先 PowerShell 7)→ 兜底powershell.exe。这样即使部署环境没设置SHELL,打开的仍然是用户本人的登录 Shell,而不是悄悄降级成 bash。
启动时还有一个容易被忽视的细节:POSIX 下 shell 以login shell(-l)方式拉起(shellSpawnArgs()),保证~/.profile、~/.zprofile等配置文件正常加载——这是"真 Shell"和"一次性命令行"的分水岭。
spawn-helper 权限修复
node-pty 的预编译二进制里带有一个spawn-helper辅助程序,pnpm 安装时会剥掉它的可执行位,导致所有终端都报posix_spawnp failed。src/pty-manager.ts 的ensureSpawnHelper()在插件激活时幂等地chmod 755修复它——这就是为什么安装脚本(scripts/install.sh)要专门处理构建权限。
PtyManager:终端会话的核心状态机
src/pty-manager.ts 中的PtyManager是整个终端的"调度中心",每个终端用sessionId:tabId作为注册表键,这决定了"重连"时如何找回同一个进程:
- 每个会话最多 N 个并发进程:由
terminalsPerSession配置(默认 3),超限直接返回pty-error; - 进程比连接活得久:WebSocket 断开不杀进程,只有"关标签页"或"插件卸载"才会真正
kill; - 工作目录一致性:重连时如果解析到的权威
cwd和进程当初启动的cwd不一致(页面加载竞态的产物),会直接重开一个进程,绝不让 Shell 停留在错误目录里。
三种断开方式,三种命运
这是理解断线重连的关键。终端视图卸载时,前端会区分三种情况发送不同的控制帧:
| 断开场景 | 控制帧 | 服务端行为 |
|---|---|---|
| 用户关闭标签页 | {type:'close'} | 立即释放进程(0ms 延迟) |
| 用户切到其他会话 | {type:'park'} | 进程无限期挂起,不启动重连倒计时,切回来即续上 |
| 页面刷新 / 崩溃 / 插件重建 | 无帧(裸断开) | 启动reconnectGraceMs重连宽限期,窗口内重连就复用原进程 |
为什么要有park帧?如果不区分"会话切换"和"页面崩溃",切换会话后那 30 秒宽限期一过就会把用户还在用的 Shell 杀掉。源码注释里把这叫做"错误地杀掉了用户正在用的 Shell"——正是 src/pty-manager.ts 花大段注释解释的动机。
WebSocket 线路协议:输入、缩放与回放
客户端连接的是/sidebar/ws/terminal(见 src/client/TerminalView.tsx),服务端在 src/index.ts 的attachTerminal()中接线,规则非常简洁:
- 上行:普通文本帧 = 原始键盘输入;JSON 帧
{type:'resize', cols, rows}= 调整 pty 尺寸;{type:'close'}/{type:'park'}= 生命周期控制; - 下行:纯字符串帧 = pty 原始输出流,xterm.js 直接
write; - 背压保护:发送前检查
ws.bufferedAmount < 4MB,慢连接不会把服务端内存撑爆; - 回放优先:
attachTerminal()接通后第一件事是ws.send(handle.transcript)——先把历史缓冲区整段发过去,再订阅实时输出。这就是"断线回放"在协议层的落点。
断线重连回放:1MB 转录 + 30 秒宽限
回放能力来自 src/pty-manager.ts 中的有界转录环(transcript ring):
- 每个终端自启动起持续累积 pty 输出;
- 上限
TRANSCRIPT_LIMIT = 1 << 20(1MB),超出时丢最旧的头部; - 即使 Shell 已退出,转录仍然保留可回放(你会看到
[process exited with code N])。
配合默认30 秒重连宽限期(reconnectGraceMs,可在 src/config.ts 中调整),完整链路是:
- 刷新页面 → 前端 xterm.js 组件重建 → 自动重连(2 秒重试间隔);
- 服务端发现
sessionId:tabId键对应的进程还活着 →不新开进程; - 先整段回放 ≤1MB 的历史输出,再续接实时流;
- 若超过宽限期才回来,进程已被回收,则透明地启动一个新 Shell。
前端这一侧同样克制:连续无原因失败达到 3 次才弹错误横幅,服务端明确拒绝(close code 1011)则立即停止重试并显示原因,横幅永不空转(src/client/TerminalView.tsx)。
前端渲染:xterm.js 的几个关键配置
src/client/TerminalView.tsx 中的终端并非裸 xterm.js,有几个值得一提的细节:
- 主题联动:16 色 ANSI 调色板与应用的代码高亮同源(one-dark / one-light,见 src/client/one-dark-palette.ts),明暗模式切换时原地重着色;
- FitAddon + rAF 合帧:面板展开动画期间 ResizeObserver 每帧触发,
fit()被合帧到每动画帧一次,避免测量抖动; - 4000 行 scrollback+ 服务端 1MB 转录,双保险覆盖常用回看范围;
- URL 可点击:pty 输出里的 http(s) 链接支持 Ctrl+Click 打开(
file://等方案被安全策略拒绝); - 零尺寸保护:容器高度为 0 时延迟
open(),靠 xterm.js 的 WriteBuffer 缓冲期间写入,避免 WKWebView 下白屏崩溃。
降级模式:node-pty 不可用时的"软着陆"
node-pty 是原生模块,安装失败(pnpm 11 的 strict-dep-builds、预编译下载失败等)很常见。src/pty-deps.ts 的设计原则是:绝不静态 import node-pty——否则一次加载失败会拖垮整个 Web 服务。
改为惰性加载 + 缓存结果:加载失败时插件保持挂载,仅终端功能降级。前端收到 close code 1011 +pty-deps-missing标记后,通过/sidebar/api/terminal.deps拉取完整的可粘贴修复命令(scripts/install.sh 或 scripts/install.ps1 的--repair模式),横幅上一键复制、粘贴到本机执行即可修复。为什么不用 WS 直接带命令?因为 WS close reason 上限只有 123 字节——这条约束在注释里写得明明白白。
关键参数速查
| 参数 | 默认值 | 作用 |
|---|---|---|
terminalsPerSession | 3 | 单会话最大并发终端数 |
reconnectGraceMs | 30000 | 裸断开后进程存活宽限期(ms) |
shell/shellArgs | 自动解析 | 覆盖 Shell 及其启动参数(shellArgs非空时完全替换平台默认-l) |
| 转录上限 | 1MB | 每终端可回放的历史输出字节数 |
以上均可在 src/config.ts 的SidebarConfig中配置,更多细节见 README.md 与 docs/plans/2026-08-14-terminal-font-design.md、docs/plans/2026-08-26-pinned-terminal-design.md。
小结
DSH-better-sidebar 的浏览器终端之所以是"真 Shell",靠的是四件事各司其职:node-pty在服务端持有真实的进程与 pty;WebSocket以极简协议(原始输入 / resize / close / park)双向泵数据;xterm.js在前端完成 ANSI 渲染与自动重连;PtyManager用sessionId:tabId键 + 1MB 有界转录 + 30 秒宽限期,让"刷新页面"和"关掉标签页"有了截然不同的命运。想继续深挖,推荐直接读 src/pty-manager.ts 的头部注释——它把整个生命周期设计讲得比本文更细。
【免费下载链接】DSH-better-sidebar开放的侧边栏底座,支持三方拓展注册新侧边栏页面。内置文件渲染编辑/终端/侧边对话/Git/子代理页面 | Open sidebar foundation, supports third-party extensions to register new sidebar pages. Built-in file rendering/editing, terminal, side chat, Git, and sub-agent pages.项目地址: https://gitcode.com/gh_mirrors/ds/DSH-better-sidebar
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考