14 KiB
Rust Browser Terminal Development Guide
本指南用于在 base-path-demo 中实现一套 Rust 终端能力:
- 后端:Axum +
portable-pty创建真实伪终端,负责启动 shell、读写 PTY、处理 resize。 - 前端:Rust/WASM + Leptos 实现浏览器终端组件,负责输入、渲染、滚屏、选区和终端状态。
- 沉淀:先在业务项目中跑通,再拆成可复用 Rust 终端组件库。
1. 核心判断
浏览器终端必须拆成两层:
Browser Rust/WASM Terminal Component
<-> WebSocket protocol
Axum terminal session service
<-> portable-pty master
Shell process: bash/zsh/fish/vim/top
浏览器里的 WASM 不能直接创建系统 PTY,因为浏览器沙箱不能启动本机 shell。PTY 必须在 Rust native 后端或桌面端创建。浏览器终端组件只做终端仿真、渲染和用户输入采集。
portable-pty 是 Rust crate,提供跨平台 PTY API。它适合放在后端适配层,不应该放进浏览器 WASM 组件。
2. 目标架构
建议分四层沉淀。
base-path-demo/
app/
src/
pages/terminal.rs
terminal/
mod.rs
component.rs
core.rs
dom_renderer.rs
keyboard.rs
protocol.rs
server/
src/
terminal/
mod.rs
pty_session.rs
ws.rs
main.rs
后续抽库时再拆成 workspace crates:
crates/
terminal-core/ # no web-sys, no axum; parser/buffer/cursor/style
terminal-leptos/ # Leptos component and browser renderer
terminal-protocol/ # shared client/server message types
terminal-pty-server/ # portable-pty + Axum integration
第一阶段可以先在 app/src/terminal 和 server/src/terminal 内实现,等 API 稳定后再抽 crate。这样比一开始拆库更快,也能减少早期重构成本。
3. 依赖规划
当前 workspace 已有 axum = "0.8",但 WebSocket 模块需要启用 ws feature。
建议先调整 base-path-demo/Cargo.toml:
[workspace.dependencies]
axum = { version = "0.8", features = ["ws"] }
futures-util = "0.3"
portable-pty = "0.9"
tokio = { version = "1", features = ["rt-multi-thread", "sync"] }
vt100 = "0.16"
server/Cargo.toml 建议增加:
[dependencies]
futures-util.workspace = true
portable-pty.workspace = true
app/Cargo.toml 建议增加:
[dependencies]
vt100.workspace = true
前端需要扩展 web-sys features:
web-sys = { version = "0.3", default-features = false, features = [
"BinaryType",
"Blob",
"CloseEvent",
"Document",
"Element",
"ErrorEvent",
"Event",
"EventTarget",
"HtmlElement",
"KeyboardEvent",
"MessageEvent",
"Url",
"WebSocket",
"Window",
] }
说明:
portable-pty只用于 native server。vt100可先用于 WASM 端解析字节流并维护屏幕状态,后续再替换为自研terminal-core。futures-util用于 Axum WebSocket split。
4. WebSocket 协议
先用简单、可调试的协议,不要过早优化。
浏览器到服务端:
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientTerminalMessage {
Input { data: String },
Resize {
cols: u16,
rows: u16,
pixel_width: u16,
pixel_height: u16,
},
Ping,
}
服务端到浏览器:
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerTerminalMessage {
Output { data: String },
Exit { code: Option<i32> },
Error { message: String },
Pong,
}
MVP 可以先用 JSON 文本传输。稳定后再优化为:
- 控制消息走 JSON text frame。
- PTY output/input 走 binary frame。
- 大输出使用批处理,避免每个字节触发一次 render。
5. 后端实现计划
5.1 路由
在 server/src/main.rs 中新增 WebSocket route。
因为当前项目有 SiteConfig::BASE_PATH = "/rustui" 的场景,建议同时支持:
/terminal/ws
/rustui/terminal/ws
挂载位置建议放在 leptos_router 外层,避免被 SSR fallback 吃掉。
目标形态:
let terminal_routes = Router::new()
.route("/terminal/ws", axum::routing::get(terminal_ws_handler));
let app = if SiteConfig::BASE_PATH.is_empty() {
terminal_routes.merge(leptos_router)
} else {
Router::new()
.route("/rustui/api/{*fn_name}", axum::routing::post(handle_server_fns))
.route("/api/{*fn_name}", axum::routing::post(handle_server_fns))
.merge(terminal_routes.clone())
.nest(SiteConfig::BASE_PATH, terminal_routes.merge(leptos_router))
};
5.2 PTY session
新增 server/src/terminal/pty_session.rs。
职责:
- 创建 PTY。
- 启动 shell。
- 拿到 master reader/writer。
- 支持 resize。
- 在断开连接时终止 child。
核心流程:
use portable_pty::{CommandBuilder, PtySize, PtySystem, native_pty_system};
let pty_system = native_pty_system();
let pair = pty_system.openpty(PtySize {
rows,
cols,
pixel_width,
pixel_height,
})?;
let shell = std::env::var("SHELL").unwrap_or_else(|_| "bash".to_owned());
let child = pair.slave.spawn_command(CommandBuilder::new(shell))?;
let reader = pair.master.try_clone_reader()?;
let writer = pair.master.take_writer()?;
注意:
portable-pty的 reader/writer 是阻塞 IO,放进tokio::task::spawn_blocking或专用线程,不要直接阻塞 async runtime。- 每个 WebSocket 连接对应一个 PTY session。
- socket close、read error、child exit 时要清理 session。
- 初版只允许启动固定 shell,不允许前端传任意命令。
5.3 WebSocket 桥接
新增 server/src/terminal/ws.rs。
需要两个方向的任务:
PTY reader thread -> mpsc -> WebSocket sender
WebSocket receiver -> PTY writer
建议结构:
pub async fn terminal_ws_handler(ws: WebSocketUpgrade) -> Response {
ws.on_upgrade(handle_terminal_socket)
}
async fn handle_terminal_socket(socket: WebSocket) {
let session = PtySession::spawn(default_size)?;
let (mut ws_sender, mut ws_receiver) = socket.split();
// Task A: PTY output -> WS output.
// Task B: WS input/resize -> PTY.
// On either side ending, abort/cleanup the other side.
}
MVP 验收标准:
- 打开页面能看到 shell prompt。
- 输入
pwd能看到输出。 - 输入
top或vim不会把 UI 卡死。 - 浏览器断开后后端 child 被终止。
6. 前端实现计划
6.1 页面接入
新增:
app/src/pages/terminal.rs
app/src/terminal/mod.rs
修改:
app/src/pages/mod.rs暴露 terminal page。app/src/app.rs增加/terminalroute。app/src/menu_items.rs增加导航项。
建议页面名:
#[component]
pub fn TerminalPage() -> impl IntoView {
view! {
<AdminShell>
<TerminalPanel />
</AdminShell>
}
}
6.2 TerminalPanel
新增 app/src/terminal/component.rs。
职责:
- 建立 WebSocket。
- 捕获键盘输入。
- 调用 terminal core 处理服务端 output。
- 渲染 terminal screen。
- 根据容器尺寸计算 cols/rows,并发送 resize。
MVP 可以先使用 DOM 渲染:
<div class="terminal-root" tabindex="0">
<For each=screen_lines>
<div class="terminal-row">
<span style=cell_style>...</span>
</div>
</For>
</div>
后续再切 Canvas/WebGL。
6.3 输入处理
新增 app/src/terminal/keyboard.rs。
先支持基础输入:
Enter -> "\r"
Backspace -> "\x7f"
Tab -> "\t"
ArrowUp -> "\x1b[A"
ArrowDown -> "\x1b[B"
ArrowRight -> "\x1b[C"
ArrowLeft -> "\x1b[D"
Ctrl+C -> "\x03"
Ctrl+D -> "\x04"
普通字符 -> event.key
后续增强:
- Alt/meta 组合键。
- bracketed paste。
- IME composition。
- 鼠标协议。
- Kitty keyboard protocol。
6.4 终端 core
MVP 不建议立即手写完整 ANSI/VT parser。先用 vt100::Parser 做 core:
pub struct TerminalCore {
parser: vt100::Parser,
}
impl TerminalCore {
pub fn new(rows: u16, cols: u16) -> Self;
pub fn process(&mut self, bytes: &[u8]);
pub fn resize(&mut self, rows: u16, cols: u16);
pub fn snapshot(&self) -> TerminalSnapshot;
}
TerminalSnapshot 是组件渲染所需的稳定数据结构:
pub struct TerminalSnapshot {
pub rows: Vec<TerminalRow>,
pub cursor: TerminalCursor,
pub title: Option<String>,
}
pub struct TerminalRow {
pub cells: Vec<TerminalCell>,
}
pub struct TerminalCell {
pub text: String,
pub fg: TerminalColor,
pub bg: TerminalColor,
pub bold: bool,
pub italic: bool,
pub underline: bool,
pub inverse: bool,
}
后续自研 core 时,可以逐步替换:
terminal-core::parserterminal-core::bufferterminal-core::screenterminal-core::styleterminal-core::unicode_width
7. 渲染策略
MVP: DOM renderer
优点:
- 容易实现。
- 容易调试。
- 选区、复制、无障碍更自然。
缺点:
- 大量输出时性能一般。
- 每个 cell 都渲染 DOM 会很重。
建议 MVP 不要每个 cell 一个 span,而是按 style run 合并:
row = [
run(style A, "hello "),
run(style B, "world"),
]
第二阶段: Canvas renderer
优点:
- 性能好。
- 更接近 xterm.js 的渲染模型。
缺点:
- 选区、光标、字体测量需要自己做。
- IME 和可访问性需要额外 DOM 辅助层。
推荐路线:先 DOM,等 parser/protocol/session 稳定后再做 Canvas。
8. 安全边界
浏览器终端是高风险能力,必须先定边界。
MVP 安全策略:
- 默认只在 development 环境启用。
- WebSocket route 必须鉴权,至少先做本地开发开关。
- 不允许前端指定任意启动命令。
- shell 工作目录固定在项目目录或安全 sandbox 目录。
- 限制并发 session 数。
- 限制空闲时间,超时自动 kill child。
- 记录 session start/stop/error 日志,但不要记录全部输入输出,避免泄露 secret。
后续生产策略:
- 接入用户身份和 RBAC。
- 每个 session 用独立低权限系统用户或容器隔离。
- 支持 allowlist 命令模式。
- 限制环境变量透传。
- 添加审计和资源配额。
9. 里程碑
M0: 文档与目录准备
- 新增本指南。
- 明确 crate/模块命名。
- 确认 Axum route 如何兼容
/rustuibase path。
验收:团队能按本文拆任务。
M1: 后端 PTY + WebSocket
- 启用
axum/ws。 - 实现
PtySession。 - 实现
/terminal/ws。 - 支持 input/output/resize。
- 断连自动清理 child。
验收:用浏览器或 WebSocket client 连接后,可以和 shell 交互。
M2: Leptos DOM 终端 MVP
- 新增
/terminal页面。 - 建立 WebSocket。
- 捕获基础键盘输入。
- 用
vt100解析 output。 - DOM 渲染 rows/runs。
- 支持 resize。
验收:浏览器页面能执行 pwd、ls、clear、top,基础 ANSI 颜色可见。
M3: 组件化与体验
- 抽出
TerminalCore、TerminalPanel、TerminalTransport。 - 增加滚屏 scrollback。
- 增加复制、粘贴、选区。
- 增加连接状态、重连、错误提示。
- 增加主题 token。
验收:组件可在其他 Leptos 页面复用。
M4: 抽 Rust 组件库
- 从
app/src/terminal抽出terminal-core。 - 从页面组件抽出
terminal-leptos。 - 从消息类型抽出
terminal-protocol。 - 从 server module 抽出
terminal-pty-server。
验收:base-path-demo 只依赖这些 crates,不再持有核心实现。
M5: 自研 parser/buffer
- 保留
vt100作为参考测试 oracle。 - 实现自研 escape parser。
- 实现 buffer、cursor、SGR style、scroll region。
- 对比
vt100snapshot 做回归测试。
验收:常见 shell、git、vim、top、cargo 输出行为稳定。
10. 测试策略
后端测试:
PtySession创建和清理。- resize 后 PTY size 更新。
- WebSocket 收到 input 后能产生 output。
- socket 断开后 child 退出。
前端 core 测试:
- 普通文本换行。
- ANSI 颜色。
- cursor movement。
- clear screen。
- resize reflow。
集成测试:
- 启动
cargo leptos watch或测试 server。 - 打开
/rustui/terminal。 - 输入
echo hello。 - 断言页面出现
hello。
11. 开发注意事项
- 不要在 async task 中直接阻塞读取 PTY。
- 不要把
portable-pty编译到 WASM 目标。 - 不要一开始支持“前端传 command”,这会扩大安全面。
- 不要先做 Canvas,先把协议和 core 跑稳。
- 不要把每个 cell 都渲染成独立 DOM 节点,优先按 style run 合并。
- 不要把 xterm.js 当成逐行移植对象,只参考它的边界:parser、buffer、renderer、input、addon。
12. 推荐第一批改动清单
建议按这个顺序提交:
Cargo.toml增加axum/ws、portable-pty、futures-util、vt100。server/src/terminal/pty_session.rs实现 PTY session。server/src/terminal/ws.rs实现 WebSocket handler。server/src/main.rs挂/terminal/ws和/rustui/terminal/ws。app/src/terminal/protocol.rs定义共享消息。app/src/terminal/core.rs包装vt100::Parser。app/src/terminal/component.rs实现 DOM 终端。app/src/pages/terminal.rs接入页面。app/src/app.rs和app/src/menu_items.rs接入路由和导航。- 增加基础测试和手动验收记录。
13. 参考资料
portable-pty: Rust 跨平台 PTY crate,用于后端创建和控制伪终端。axum::extract::ws: Axum WebSocket 支持,需要启用wsfeature。vt100: Rust terminal byte stream parser,可作为 MVP parser 和后续自研 core 的对照实现。xterm.js: 参考模块边界,不逐行移植。