# Web Terminal 技术文档 > 版本: v0.2(草案) · 日期: 2026-06-12 > 定位: 快速原型 / 学习项目 · 主用途: **vibe coding(在任何设备上驾驶 Claude Code)** > 形态: 浏览器访问部署机的本地 Shell,同一局域网内其他设备也可访问 --- ## 1. 项目概述 ### 1.1 目标 在浏览器中提供一个功能完整的终端,连接到服务器(部署机)的本地 Shell。 任何局域网内的设备(其他电脑、平板、手机)打开 `http://<主机IP>:3000` 即可获得一个交互式终端。 ### 1.2 非目标(本期不做) - 用户注册 / 登录 / 多用户隔离 - SSH 跳板到其他远程主机 - 容器 / K8s 后端 - 公网部署(无认证,**严禁暴露公网**) --- ## 2. 总体架构 ``` ┌──────────────── 浏览器(局域网任意设备) ────────────────┐ │ ┌─────────────────────────────────────────────┐ │ │ │ xterm.js ── 终端渲染、键盘输入、光标、ANSI 转义 │ │ │ │ fit addon ── 自适应窗口大小 │ │ │ └───────────────┬─────────────────────────────┘ │ │ │ WebSocket(双向字节流 + 控制消息) │ └──────────────────┼─────────────────────────────────────┘ │ ┌──────────────────▼───────────── Node.js 服务端 ────────┐ │ ┌────────────┐ ┌──────────────┐ ┌─────────────┐ │ │ │ Express │ │ ws │ │ node-pty │ │ │ │ 静态资源服务 │ │ WebSocket 网关 │──▶│ PTY 进程管理 │ │ │ └────────────┘ └──────────────┘ └──────┬──────┘ │ │ │ fork │ │ ┌──────▼──────┐ │ │ │ zsh / bash │ │ │ │ (伪终端从端) │ │ │ └─────────────┘ │ └────────────────────────────────────────────────────────┘ ``` ### 2.1 核心原理:PTY(伪终端) 直接 `spawn('zsh')` 得到的是普通管道,shell 会认为自己**不在终端里**: 没有颜色、没有行编辑、vim/top 等全屏程序无法工作。 PTY(pseudo-terminal)是内核提供的一对设备(master/slave): - **slave 端**接给 shell,shell 以为自己连着真终端(isatty() = true) - **master 端**由 Node 进程持有,读写原始字节流(含 ANSI 转义序列) `node-pty` 封装了 `forkpty()` 系统调用。**xterm.js 负责解释这些转义序列并渲染**, 服务端不解析任何终端语义,只做字节搬运 —— 这是整个架构最重要的简化。 ### 2.2 数据流 ``` 按键 ──▶ xterm.js onData ──▶ WS 发送 ──▶ pty.write() ──▶ shell stdin shell stdout/stderr ──▶ pty onData ──▶ WS 发送 ──▶ xterm.write() ──▶ 屏幕 ``` 每个 WebSocket 连接对应一个独立的 PTY + shell 进程(1:1 会话模型)。 --- ## 3. 技术选型 | 组件 | 选择 | 理由 | |------|------|------| | 终端前端 | **@xterm/xterm 5.x** | 事实标准(VS Code、Hyper、JupyterLab 同款),ANSI 支持最完整 | | 自适应布局 | @xterm/addon-fit | 官方插件,窗口缩放时重算行列数 | | 伪终端 | **node-pty** | 微软维护,VS Code 同款,跨平台(macOS/Linux/Windows ConPTY) | | Web 框架 | Express 4 | 只用来托管静态页面,最熟悉、最简单 | | WebSocket | **ws** | 最轻量的标准实现,无额外协议封装,利于学习原始机制 | | 运行时 | Node.js ≥ 18 | node-pty 预编译二进制覆盖范围内 | **为什么不用 Socket.IO**:它自带重连、房间、降级轮询,但封装掉了 WebSocket 原始细节; 学习场景下用裸 `ws` 更能看清协议本质。后续需要自动重连时可以手写(也是学习点)。 **现成替代品(知晓即可)**:ttyd(C)、wetty(Node)、GoTTY(Go)。 本项目目的是理解原理,所以自己实现;遇到设计问题可参考 wetty 源码。 --- ## 4. WebSocket 消息协议 单 WebSocket 连接,JSON 文本帧。自定义极简协议: ```jsonc // 客户端 → 服务端 { "type": "attach", "sessionId": "f47ac10b-58cc-4372-a567-0e02b2c3d479" } // 首条消息:附着已有会话;为 null 则新建 { "type": "input", "data": "ls -la\r" } // 键盘输入(原始字节) { "type": "resize", "cols": 120, "rows": 40 } // 终端尺寸变化 // 服务端 → 客户端 { "type": "attached", "sessionId": "f47ac10b-58cc-4372-a567-0e02b2c3d479" } // 附着成功,随后先回放缓冲再接实时流 { "type": "output", "data": "..." } // PTY 输出(含 ANSI 序列) { "type": "exit", "code": 0, "reason": "..." } // shell 退出;reason 仅异常/spawn 失败时填,正常退出可省 ``` > sessionId 是 `crypto.randomUUID()`(UUID v4),服务端按该格式校验(见 ARCHITECTURE §3.2 `SESSION_ID_RE`)。 设计说明: - **attach 是首条消息**:客户端把 sessionId 存在 localStorage,刷新/换设备时带上, 服务端先回放该会话的环形缓冲,再接续实时流 —— 这就是"刷新页面 Claude 会话不丢"的机制。 - **resize 必须是独立消息**:PTY 尺寸要通过 `ioctl(TIOCSWINSZ)` 设置, shell 收到 SIGWINCH 后全屏程序(vim/top)才能正确重绘。 - 原型期用 JSON 足够;若追求吞吐,可升级为二进制帧(首字节做 opcode)。 --- ## 5. 服务端设计 ### 5.1 模块划分 ``` web-terminal/ ├── docs/ │ └── TECH_DOC.md # 本文档 ├── src/ │ ├── server.js # 入口:Express + HTTP + WS 升级,~60 行 │ ├── config.js # 端口、shell 路径、scrollback 等常量 │ ├── session.js # 会话:1 WS ↔ 1 PTY 的生命周期管理 │ └── protocol.js # 消息编解码 + 校验(parse/serialize) ├── public/ │ ├── index.html # 页面骨架 │ ├── main.js # xterm 初始化 + WS 客户端逻辑 │ └── style.css # 全屏终端布局 ├── test/ │ └── protocol.test.js # 协议编解码单元测试 └── package.json ``` 遵循小文件原则:每个文件单一职责,均 < 200 行。 ### 5.2 会话生命周期(session.js)—— attach/detach 模型 会话与连接解耦:**PTY 的生命周期 ≠ WebSocket 的生命周期**(vibe coding 核心需求)。 ``` attach(sessionId=null) └─▶ 新建:pty.spawn(SHELL, [], { cwd: HOME, cols, rows, env }) ├─ pty.onData ──▶ 写入环形缓冲(2MB) ──▶ 若有附着的 ws 则转发 ├─ ws 'message' ──▶ 校验 ──▶ pty.write() / pty.resize() ├─ ws 'close' ──▶ detach(会话标记为"无人观看",PTY 继续运行!) └─ pty.onExit ──▶ 通知附着的 ws ──▶ 从会话表移除 attach(sessionId="abc123") └─▶ 查会话表 ──▶ 回放环形缓冲 ──▶ 接管实时流(旧 ws 若还在则踢掉) ``` 关键约束: - **WS 断开不杀 PTY** —— 这是与普通 web terminal 最大的区别; 里面跑着的 Claude Code 任务必须继续执行。 - **孤儿会话回收**:detach 超过 `IDLE_TTL`(默认 24h)**且自 detach 起无新输出**时回收。 (原"PTY 内无前台子进程"判据落地不了——node-pty 不跨平台暴露前台进程组;改用"最近输出时间戳" 近似"是否仍在跑任务",可测,见 ARCHITECTURE §3.5。) 服务端进程退出时统一 `pty.kill()` 所有会话(v0.1 不做跨重启保活,v0.2 用 tmux 解决)。 - 一个会话同一时刻只允许一个附着的 WS(后到者踢前者),避免双端输入交错。 - 会话状态以不可变快照记录(创建时固定 id/启动时间),运行时句柄(pty/ws)单独持有。 ### 5.3 输入校验(系统边界) WS 消息是外部输入,逐条校验: - WS 帧大小设上限(`maxPayload`,默认 1MB,远超正常键盘输入),超限帧丢弃——防单条巨帧打爆内存 - 必须是合法 JSON,`type` 在白名单内,否则丢弃并记日志 - `resize` 的 cols/rows 必须是 1–1000 的整数(防御恶意/异常值打挂 ioctl);服务端再做幂等(值未变跳过), **不假设前端已防抖** - `input.data` 必须是 string(原样透传给 PTY,不做内容过滤——它就是键盘字节;服务端不解释字节,不引入注入) ### 5.4 错误处理 - node-pty spawn 失败(shell 路径不存在等)→ `createSession` 同步抛错,由 `server.ts` 捕获 → 向客户端发 `exit`(code:-1 + reason)+ 关闭**该连接**(注意:只关连接,**不**走 `uncaughtException` 杀进程) - 转发 `ws.send` 前必须查 `readyState === OPEN`;send 失败 → **只 detach 该 ws,绝不 kill PTY** (笼统说"清理会话"会误杀正在跑 Claude 的 PTY,违反保活) - 对**已退出**的 PTY 调 `write`/`resize` 一律忽略(否则 node-pty 抛错) - 输出侧背压:转发前看 `ws.bufferedAmount`,超阈值暂缓(终端输出可丢,环形缓冲仍留作回放) - 服务端监听 `uncaughtException` 仅记录后退出(fail fast),仅用于真正未预期错误;上述已知路径不得走到这里 --- ## 6. 前端设计 ### 6.1 初始化流程(main.js) 1. `new Terminal({ scrollback: 5000, fontFamily: 'Menlo, monospace', theme })` 2. 加载 FitAddon,`term.open(el)` → `fit()` 3. 建立 WS:`ws://${location.host}/term`(同源,无需配置 IP) 4. 绑定 `term.onData → ws.send(input)`、`ws.onmessage → term.write(output)` 5. `ResizeObserver` / window resize → `fit()` → 发送 resize 消息(防抖 100ms) ### 6.2 连接状态反馈 - 连接中 / 已断开时在终端打印彩色状态行(直接写 ANSI 序列,顺便练习转义码) - shell 退出后显示 "进程已退出,按回车重新连接" —— 回车触发重建 WS - 断线后自动重连(指数退避:1s/2s/4s…上限 30s),携带 localStorage 中的 sessionId ### 6.3 移动快捷键栏(Claude Code 必需) 手机软键盘打不出 Claude Code 的高频按键,在终端下方固定一排触摸按钮: | 按钮 | 发送字节 | Claude Code 中的作用 | |------|----------|---------------------| | Esc | `\x1b` | 打断 Claude 当前回答 | | Shift+Tab | `\x1b[Z` | 切换 auto-accept / plan 模式 | | ↑ ↓ ← → | `\x1b[A` 等 | 菜单/历史选择 | | Enter | `\r` | 确认 | | Ctrl+C | `\x03` | 终止进程 | | Tab | `\t` | 补全 | 实现:按钮 `touchstart` 直接 `ws.send(input)`,不经过 xterm,避免软键盘弹出。 桌面端(宽度 > 768px)默认隐藏该栏。 --- ## 7. 安全设计(必读) > ⚠️ 本项目 = **把你电脑的完整 shell 权限交给任何能访问该端口的人**。 | 风险 | 对策 | |------|------| | 公网暴露 = 远程任意代码执行 | 只绑定局域网;**永远不要**端口映射/内网穿透到公网 | | 局域网内他人访问(咖啡店/办公室 WiFi) | 默认绑定 `0.0.0.0` 但文档显著警告;只在可信家庭网络使用 | | **明文嗅探 / 中间人(MITM)** | `ws://` 不加密,键盘输入(可能含密码、API key、claude token)与全部输出在局域网明文传输,同网段可被动监听、ARP 欺骗可劫持。Origin 校验对此**无任何防护**。缓解:**Tailscale(WireGuard 加密)** 或本地自签 TLS 走 `wss://`——在不可信网络下这不是可选优化,而是必需加固 | | 跨站 WebSocket 劫持(CSWSH):恶意网页在你浏览器里偷连 ws://内网IP | **校验 Origin 头**,只允许来自本服务页面的连接(零成本,必须做) | | 后续想加最简认证 | 预留:URL token(`?token=xxx` 比对环境变量),v0.2 可选 | Origin 校验是无认证方案里**唯一不能省**的防线,因为攻击者无需在你的局域网内, 只要你访问了恶意网页,网页里的 JS 就能尝试连接 `ws://192.168.x.x:3000`。 **Origin 白名单的正确来源(关键)**:`0.0.0.0` 是监听通配地址,浏览器永远不会以它为 Origin。 白名单须由**本机各网卡 IPv4 + localhost + 可选主机名**推导出 `http(s)://:`,而非从 `bindHost` 推导, 否则 F4(局域网设备访问)会被 F9(Origin 校验)误判 401——二者打架。实现见 ARCHITECTURE §3.1/§3.3。 前端 WS 的 scheme 随页面协议(`https→wss`),避免 Tailscale/TLS(HTTPS)部署下 `ws://` 被 mixed-content 拦截。 --- ## 8. 功能清单 > 设计原则:本终端的主用途是 **vibe coding** —— 给 Claude Code 发任务后人离开, > 之后从任意设备(手机/平板/另一台电脑)回来查看和确认。 > 因此"会话不死"和"移动端可操作"是核心需求,不是锦上添花(见第 11 节调研)。 ### v0.1 MVP(本期) | # | 功能 | 验收标准 | |---|------|----------| | F1 | 浏览器交互式终端 | `ls`、tab 补全、Ctrl+C、方向键历史均正常 | | F2 | 全彩 + 全屏程序支持 | `vim`、`top`、`claude` TUI 颜色与重绘正确 | | F3 | 终端自适应 | 拖动浏览器窗口,行列数实时调整,TUI 不错位 | | F4 | 局域网访问 | 同网段手机/电脑通过 `http://:3000` 可用 | | F5 | **会话保活** | 刷新页面/换设备重连后,Claude Code 会话仍在跑,内容不丢 | | F6 | **重连回放** | 重连时回放离线期间的输出(环形缓冲,默认 2MB/会话) | | F7 | **移动快捷键栏** | 手机上可按 Esc、Shift+Tab、↑↓←→、Enter、Ctrl+C | | F8 | 进程退出处理 | shell 退出后提示重连,回车开新会话 | | F9 | Origin 校验 | 来自其他源的 WS 握手被拒绝(401) | 会话保活实现方式(F5):PTY 不再随 WS 断开而销毁。会话由服务端以 sessionId 管理, WS 断开只是"分离"(detach),PTY 与其中运行的 Claude Code 继续存活; 新 WS 携带 sessionId 即可重新附着(attach)。服务端重启保活(tmux 方案)留给 v0.2。 ### v0.2 候选(按对 vibe coding 的价值排序) - **Claude Code 状态感知**:通过 Claude hooks(Stop / PermissionRequest / PreToolUse) 把"工作中 / 等待批准 / 空闲"推送到页面标题与会话列表(参考 claude-coterminal、control-center) - **多会话仪表盘**:并行开多个 Claude agent,列表显示各会话状态,点击切换 - **历史会话恢复**:扫描 `~/.claude/projects/*/` 的 JSONL,一键 `claude --resume ` (参考 cc-remote-term 的 history-index 设计) - **tmux 后端**:PTY 活在 tmux 里,服务重启也不丢会话(同类项目的事实标准方案) - 浏览器通知:Claude 等待输入时推送提醒 - 简单 token 认证、复制粘贴增强(@xterm/addon-web-links) --- ## 9. 任务拆解 | 阶段 | 内容 | 预估 | |------|------|------| | T1 | 项目脚手架 + 依赖安装(node-pty 需本地编译,验证环境) | 0.5h | | T2 | protocol.js + 单元测试(TDD:先写编解码与校验测试) | 0.5h | | T3 | session.js + server.js(PTY ↔ WS 桥接,Origin 校验) | 1h | | T4 | 前端 index.html + main.js(xterm + fit + 状态提示) | 1h | | T5 | 联调验收 F1–F7(vim/top/resize/局域网手机实测) | 0.5h | ### 已知坑位 - **node-pty 编译**:macOS 需要 Xcode Command Line Tools;Node 大版本升级后需 `npm rebuild` - **`\r` vs `\n`**:终端回车发送的是 `\r`(0x0D),不是 `\n`,自己拼输入时容易踩 - **fit 时机**:必须在容器有实际尺寸后调用,`display:none` 时 fit 会得到 NaN - **中文/IME 输入**:xterm.js 已处理 composition event,但要避免自己重复监听 keydown --- ## 10. 运行方式(规划) ```bash npm install # node-pty 触发原生编译 npm start # 监听 0.0.0.0:3000 npm test # 协议层单元测试 # 局域网访问: # 1. 查本机 IP:ipconfig getifaddr en0 # 2. 其他设备打开 http://:3000 ``` 配置通过环境变量覆盖(无硬编码):`PORT`、`SHELL_PATH`、`BIND_HOST`、`IDLE_TTL`。 --- ## 11. 同类项目调研(2026-06) 调研目的:本项目主用途是 vibe coding,需要对 Claude Code 友好。以下是同赛道 开源项目的共性设计,v0.1/v0.2 功能取舍即来源于此。 ### 11.1 重点参考项目 | 项目 | 技术栈 | 值得借鉴的设计 | |------|--------|----------------| | [cc-remote-term](https://github.com/AliceLJY/cc-remote-term) | Next.js + ws + node-pty + tmux | tmux 保活;5MB 输出环形缓冲重连回放;扫描 `~/.claude/projects/` 的历史会话浏览器 + 一键 `--resume`;iOS 触摸键栏 | | [open-claude-remote](https://github.com/StephenTowne/open-claude-remote) | Node + xterm.js | 移动端细节:QR 码连接、长按复制、点击聚焦、50K 行回滚、多网卡 IP 探测 | | [claude-coterminal](https://github.com/adamorad/claude-coterminal) | FastAPI + tmux | 用 Claude hooks(Stop/PreToolUse)检测会话状态(工作中/等输入/完成),手机一眼看到哪个会话在等人 | | [control-center](https://github.com/charan1319/control-center) | Node + SQLite + tmux | hooks POST 到服务端 → WS 推送状态;多会话仪表盘;Telegram 通知 | | [claude-command-center](https://github.com/afstkla/claude-command-center) | Node + Express + tmux | 不进终端即可批准/拒绝工具调用的快捷按钮;ntfy 手机推送;WS + SSE 双传输 | | [claudecodeui / CloudCLI](https://github.com/siteboon/claudecodeui) | 全家桶 | 直接读写 `~/.claude` 配置;聊天界面 + 文件浏览器 + git 面板(超出本项目范围,仅作参照) | ### 11.2 提炼的共性模式("Claude Code 友好"的定义) 1. **会话与连接解耦**(所有项目):PTY 活在服务端(多数用 tmux),浏览器只是附着的视图。 vibe coding 的工作流是"发任务 → 离开 → 回来看",连接断了任务不能死。 2. **重连回放缓冲**:环形缓冲保留输出,重连先回放再续流。 3. **移动触摸键栏**:Esc / Shift+Tab / 方向键是 Claude Code 高频键,软键盘打不出。 4. **状态感知靠 hooks 而非解析终端输出**:解析 TUI 输出脆弱(Claude 改版即坏), 正确做法是利用 Claude Code 官方 hooks 机制写状态文件 / POST 事件。 5. **历史会话 = 读 `~/.claude/projects/*/` JSONL**,恢复 = `claude --resume `, 不需要自己存任何对话数据。 6. **安全上偏好 Tailscale 而非裸局域网**:多数项目文档推荐 Tailscale, 免认证又加密;本项目 v0.1 仍按局域网 + Origin 校验,Tailscale 作为部署建议。 ### 11.3 与现成方案的关系 若只为使用,cc-remote-term / CloudCLI 已可直接安装。本项目坚持自研的理由是学习 PTY/WS/终端协议本身;设计决策时优先参考 cc-remote-term(目标形态最接近, 代码结构清晰),移动端体验参考 open-claude-remote。