- TECH_DOC + ARCHITECTURE (cross-validated, 12 fixes M1-M7/L1-L5) - PLAN: waves W0-W5, tasks T1-T21 for multi-agent parallel dev - PROGRESS_LOG: orchestrator-owned cross-session memory - CLAUDE.md: required-reading + orchestrator-worker workflow - .claude/agents: module-builder, module-reviewer
19 KiB
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 文本帧。自定义极简协议:
// 客户端 → 服务端
{ "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": "[1;32m..." } // PTY 输出(含 ANSI 序列)
{ "type": "exit", "code": 0, "reason": "..." } // shell 退出;reason 仅异常/spawn 失败时填,正常退出可省
sessionId 是
crypto.randomUUID()(UUID v4),服务端按该格式校验(见 ARCHITECTURE §3.2SESSION_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)
new Terminal({ scrollback: 5000, fontFamily: 'Menlo, monospace', theme })- 加载 FitAddon,
term.open(el)→fit() - 建立 WS:
ws://${location.host}/term(同源,无需配置 IP) - 绑定
term.onData → ws.send(input)、ws.onmessage → term.write(output) 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)://<host>:<port>,而非从 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://<IP>: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 <id>(参考 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 \rvs\n:终端回车发送的是\r(0x0D),不是\n,自己拼输入时容易踩- fit 时机:必须在容器有实际尺寸后调用,
display:none时 fit 会得到 NaN - 中文/IME 输入:xterm.js 已处理 composition event,但要避免自己重复监听 keydown
10. 运行方式(规划)
npm install # node-pty 触发原生编译
npm start # 监听 0.0.0.0:3000
npm test # 协议层单元测试
# 局域网访问:
# 1. 查本机 IP:ipconfig getifaddr en0
# 2. 其他设备打开 http://<IP>:3000
配置通过环境变量覆盖(无硬编码):PORT、SHELL_PATH、BIND_HOST、IDLE_TTL。
11. 同类项目调研(2026-06)
调研目的:本项目主用途是 vibe coding,需要对 Claude Code 友好。以下是同赛道 开源项目的共性设计,v0.1/v0.2 功能取舍即来源于此。
11.1 重点参考项目
| 项目 | 技术栈 | 值得借鉴的设计 |
|---|---|---|
| cc-remote-term | Next.js + ws + node-pty + tmux | tmux 保活;5MB 输出环形缓冲重连回放;扫描 ~/.claude/projects/ 的历史会话浏览器 + 一键 --resume;iOS 触摸键栏 |
| open-claude-remote | Node + xterm.js | 移动端细节:QR 码连接、长按复制、点击聚焦、50K 行回滚、多网卡 IP 探测 |
| claude-coterminal | FastAPI + tmux | 用 Claude hooks(Stop/PreToolUse)检测会话状态(工作中/等输入/完成),手机一眼看到哪个会话在等人 |
| control-center | Node + SQLite + tmux | hooks POST 到服务端 → WS 推送状态;多会话仪表盘;Telegram 通知 |
| claude-command-center | Node + Express + tmux | 不进终端即可批准/拒绝工具调用的快捷按钮;ntfy 手机推送;WS + SSE 双传输 |
| claudecodeui / CloudCLI | 全家桶 | 直接读写 ~/.claude 配置;聊天界面 + 文件浏览器 + git 面板(超出本项目范围,仅作参照) |
11.2 提炼的共性模式("Claude Code 友好"的定义)
- 会话与连接解耦(所有项目):PTY 活在服务端(多数用 tmux),浏览器只是附着的视图。 vibe coding 的工作流是"发任务 → 离开 → 回来看",连接断了任务不能死。
- 重连回放缓冲:环形缓冲保留输出,重连先回放再续流。
- 移动触摸键栏:Esc / Shift+Tab / 方向键是 Claude Code 高频键,软键盘打不出。
- 状态感知靠 hooks 而非解析终端输出:解析 TUI 输出脆弱(Claude 改版即坏), 正确做法是利用 Claude Code 官方 hooks 机制写状态文件 / POST 事件。
- 历史会话 = 读
~/.claude/projects/*/JSONL,恢复 =claude --resume <id>, 不需要自己存任何对话数据。 - 安全上偏好 Tailscale 而非裸局域网:多数项目文档推荐 Tailscale, 免认证又加密;本项目 v0.1 仍按局域网 + Origin 校验,Tailscale 作为部署建议。
11.3 与现成方案的关系
若只为使用,cc-remote-term / CloudCLI 已可直接安装。本项目坚持自研的理由是学习 PTY/WS/终端协议本身;设计决策时优先参考 cc-remote-term(目标形态最接近, 代码结构清晰),移动端体验参考 open-claude-remote。