- esbuild dev dep; build:web/dev:web bundle public/main.ts → public/build/main.js - gitignore public/build/; tsconfig.web.json now typecheck-only - frontend build convention documented in PLAN §1, DISPATCH, ARCHITECTURE §5 - resolves the T1 deferred decision
425 lines
21 KiB
Markdown
425 lines
21 KiB
Markdown
# Web Terminal 架构文档(开发指引)
|
||
|
||
> 版本: v0.1 · 日期: 2026-06-12
|
||
> 定位: **开发指引** —— 把 [TECH_DOC.md](./TECH_DOC.md) 的设计落到模块边界、接口契约、函数签名和实现顺序。
|
||
> 写代码时对照本文档逐模块填充;设计意图与取舍请回看 TECH_DOC。
|
||
|
||
---
|
||
|
||
## 0. 与 TECH_DOC 的差异(先读)
|
||
|
||
| 项 | TECH_DOC | 本文档(采纳) | 原因 |
|
||
|----|----------|--------------|------|
|
||
| 语言 | `.js` | **TypeScript(`.ts`)** | 强类型表达模块契约,IDE 可校验协议消息形状 |
|
||
| 构建 | 直接 `node` | `tsc` 编译到 `dist/`,或 `tsx` 直跑 | TS 需要编译步骤 |
|
||
|
||
其余设计(byte-shuttle 原则、attach/detach 会话模型、协议、安全)完全遵循 TECH_DOC。
|
||
|
||
---
|
||
|
||
## 1. 分层与依赖方向
|
||
|
||
依赖只能从上往下,**不允许反向或跨层**:
|
||
|
||
```
|
||
┌─ 入口层 ──────────────────────────────────────────┐
|
||
│ server.ts Express 静态服务 + HTTP + WS 升级 │
|
||
└───────┬───────────────────────────┬───────────────┘
|
||
│ 创建/路由 │ Origin 校验
|
||
┌───────▼──────────┐ ┌────────▼──────────┐
|
||
│ session/ │ │ http/origin.ts │ 无状态纯函数
|
||
│ manager.ts │ └───────────────────┘
|
||
│ session.ts │
|
||
└───────┬──────────┘
|
||
│ 编解码消息
|
||
┌───────▼──────────┐ ┌───────────────────┐
|
||
│ protocol.ts │ │ config.ts │ 常量,被所有层读取
|
||
│ (纯函数, 无副作用) │ └───────────────────┘
|
||
└──────────────────┘
|
||
```
|
||
|
||
核心原则(继承自 TECH_DOC §2.1):**服务端是字节搬运工,不解析终端语义**。
|
||
`protocol.ts` 只认 JSON 信封;PTY 输出的 ANSI 字节对服务端是不透明的 blob。
|
||
|
||
---
|
||
|
||
## 2. 目录结构
|
||
|
||
```
|
||
web-terminal/
|
||
├── docs/
|
||
│ ├── TECH_DOC.md # 设计与取舍(why)
|
||
│ └── ARCHITECTURE.md # 本文档(how)
|
||
├── src/
|
||
│ ├── server.ts # 入口:HTTP + WS 升级 + Origin 校验接线 (<80 行)
|
||
│ ├── config.ts # 环境变量读取 + 常量 (<60 行)
|
||
│ ├── protocol.ts # 消息类型 + parse/serialize + 校验 (纯函数)
|
||
│ ├── http/
|
||
│ │ └── origin.ts # Origin 白名单校验 (纯函数)
|
||
│ └── session/
|
||
│ ├── manager.ts # 会话表:create/attach/detach/reap
|
||
│ └── session.ts # 单会话:PTY 句柄 + ring buffer + ws 绑定
|
||
├── public/
|
||
│ ├── index.html
|
||
│ ├── main.ts # xterm 初始化 + WS 客户端 + 重连
|
||
│ ├── keybar.ts # 移动触摸键栏
|
||
│ └── style.css
|
||
├── test/
|
||
│ ├── protocol.test.ts # 协议编解码 + 校验(TDD 起点)
|
||
│ └── manager.test.ts # 会话生命周期(PTY 用 mock)
|
||
├── tsconfig.json
|
||
└── package.json
|
||
```
|
||
|
||
文件遵循小文件原则,单一职责,均 < 200 行。
|
||
|
||
---
|
||
|
||
## 3. 模块契约(函数签名级)
|
||
|
||
### 3.1 `config.ts`
|
||
|
||
环境变量在启动时一次性读取并冻结,杜绝运行时硬编码(继承 TECH_DOC §10)。
|
||
|
||
```ts
|
||
export interface Config {
|
||
readonly port: number; // PORT,默认 3000
|
||
readonly bindHost: string; // BIND_HOST,默认 '0.0.0.0'
|
||
readonly shellPath: string; // SHELL_PATH,默认 process.env.SHELL ?? '/bin/zsh'
|
||
readonly homeDir: string; // PTY cwd,默认 os.homedir()
|
||
readonly idleTtlMs: number; // IDLE_TTL,默认 24h
|
||
readonly scrollbackBytes: number; // ring buffer 容量,默认 2MB
|
||
readonly maxPayloadBytes: number; // 单条 WS 帧上限,默认 1MB(远超正常键盘输入,防巨帧打爆内存)
|
||
readonly allowedOrigins: readonly string[]; // 见下方推导规则
|
||
}
|
||
|
||
/**
|
||
* 读取并校验环境变量;缺失用默认值,非法值(端口越界等)直接抛错 fail-fast。
|
||
*
|
||
* allowedOrigins 推导(关键,M1):**不能**从 bindHost 推导 ——
|
||
* 默认 bindHost='0.0.0.0' 是监听通配地址,浏览器永远不会以 http://0.0.0.0:3000 为 Origin。
|
||
* 正确来源 = 枚举本机各网卡 IPv4(os.networkInterfaces(),非内部地址)
|
||
* + 'localhost' / '127.0.0.1'
|
||
* + 可选主机名(<host>.local)
|
||
* → 对每个生成 http://<host>:<port>(和 https:// 同形,见 M6)
|
||
* + ALLOWED_ORIGINS 环境变量追加项。
|
||
* isOriginAllowed 应同时比对 host 与 port。
|
||
* 否则 F4(局域网任意设备访问)会被 F9(Origin 校验)误判 401 —— 二者直接打架。
|
||
*/
|
||
export function loadConfig(env: NodeJS.ProcessEnv): Config;
|
||
```
|
||
|
||
### 3.2 `protocol.ts`(纯函数,先用 TDD 实现)
|
||
|
||
消息形状即 TECH_DOC §4。用可辨识联合(discriminated union)表达:
|
||
|
||
```ts
|
||
// 客户端 → 服务端
|
||
export type ClientMessage =
|
||
| { type: 'attach'; sessionId: string | null }
|
||
| { type: 'input'; data: string }
|
||
| { type: 'resize'; cols: number; rows: number };
|
||
|
||
// 服务端 → 客户端
|
||
export type ServerMessage =
|
||
| { type: 'attached'; sessionId: string }
|
||
| { type: 'output'; data: string }
|
||
| { type: 'exit'; code: number; reason?: string };
|
||
// exit.code: shell 正常退出码;spawn 从未成功用 -1(见 §3.6 / §4.4)。
|
||
// exit.reason: 正常退出可省;**spawn 失败 / 异常退出时必填**,向用户说明原因。
|
||
|
||
/** sessionId 格式常量,放 protocol.ts(分层上 protocol 不依赖 session 模块)。 */
|
||
export const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; // UUID v4
|
||
|
||
export type ParseResult =
|
||
| { ok: true; message: ClientMessage }
|
||
| { ok: false; error: string }; // 非法消息丢弃 + 记日志,不抛
|
||
|
||
/** 解析 + 校验外部输入(系统边界,TECH_DOC §5.3)。永不抛异常,错误走 ParseResult。 */
|
||
export function parseClientMessage(raw: string): ParseResult;
|
||
|
||
/** 序列化服务端消息为 WS 文本帧。内部可信数据,直接 JSON.stringify。 */
|
||
export function serialize(msg: ServerMessage): string;
|
||
```
|
||
|
||
**校验规则**(必须在 `parseClientMessage` 内实现,见 TECH_DOC §5.3):
|
||
- 入参 `raw` 在送入 `parseClientMessage` 前,调用方先按 `cfg.maxPayloadBytes` 拒收超大帧
|
||
(并把 `ws` 的 `maxPayload` 设为同值,L5:防单条数百 MB 巨帧打爆内存)。
|
||
- 合法 JSON 且 `type` 在白名单内,否则 `{ ok: false }`。
|
||
- `resize`: `cols`/`rows` 为 1–1000 的整数(`Number.isInteger` + 范围)。
|
||
- `input.data`: 必须是 `string`,**原样透传不过滤**(它是键盘字节;服务端不解释字节,
|
||
透传本身不引入注入风险)。
|
||
- `attach.sessionId`: `string | null`;若为 string,**必须匹配 `SESSION_ID_RE`(UUID v4)**,
|
||
否则 `{ ok: false }`。注意 TECH_DOC §4 示例里的 `"abc123"` 仅为示意,真实值是 `crypto.randomUUID()`。
|
||
|
||
### 3.3 `http/origin.ts`(纯函数,安全关键)
|
||
|
||
```ts
|
||
/** CSWSH 防线(TECH_DOC §7)。Origin 不在白名单 → false → 握手返回 401。 */
|
||
export function isOriginAllowed(origin: string | undefined, allowed: readonly string[]): boolean;
|
||
```
|
||
|
||
约定:`undefined` Origin(非浏览器客户端)的处理策略在此集中决定。默认拒绝;
|
||
如需放行本机 curl 调试,通过 config 显式开关,**不要散落 if 判断**。
|
||
|
||
### 3.4 `session/session.ts` —— 单会话
|
||
|
||
会话状态按 TECH_DOC §5.2 拆成**不可变快照** + **可变运行时句柄**:
|
||
|
||
```ts
|
||
/** 创建时固定,永不修改(immutable)。 */
|
||
export interface SessionMeta {
|
||
readonly id: string; // crypto.randomUUID()
|
||
readonly createdAt: number; // 由调用方传入时间戳(便于测试注入)
|
||
readonly shellPath: string;
|
||
}
|
||
|
||
export interface Session {
|
||
readonly meta: SessionMeta;
|
||
/** 输出环形缓冲(2MB),重连回放用。淘汰语义见下方 RingBuffer。 */
|
||
readonly buffer: RingBuffer;
|
||
/** 当前附着的 ws,null = 已 detach 但 PTY 仍活(vibe coding 核心)。 */
|
||
attachedWs: WebSocket | null;
|
||
detachedAt: number | null; // detach 时刻
|
||
/** 最近一次 pty.onData 的时间戳。孤儿回收用它近似"是否仍在跑前台作业"(M3)。 */
|
||
lastOutputAt: number;
|
||
/** PTY 退出时刻;null = 仍存活。非 null 后:writeInput/resize 静默忽略(L4),
|
||
* attach 命中时回放 buffer 后立即补发 exit(L1)。 */
|
||
exitedAt: number | null;
|
||
exitCode: number | null;
|
||
readonly pty: IPty; // node-pty 句柄
|
||
}
|
||
|
||
/**
|
||
* spawn PTY,接好 onData→buffer→(转发 ws) 与 onExit。不持有 ws。
|
||
*
|
||
* onData:append buffer、刷新 lastOutputAt、若 attachedWs 存在则转发。
|
||
* onExit(M4/L1/L2):置 exitedAt/exitCode;若 attachedWs 存在则发 exit;
|
||
* 再调用注入的 onExit 回调,把"从会话表移除"的职责交回 manager
|
||
* (session 不持有 manager,靠回调跨层,见 §3.5)。
|
||
*
|
||
* 失败契约(M4):node-pty 在 shell 路径不存在等情况下会**同步抛错**。
|
||
* createSession **不**吞掉它 —— 让它抛出,由 manager.handleAttach 透传给 server.ts 捕获,
|
||
* server.ts serialize 一条 { type:'exit', code:-1, reason } 后 close 该连接(只关连接,不 fail-fast)。
|
||
*/
|
||
export function createSession(
|
||
cfg: Config,
|
||
dims: { cols: number; rows: number },
|
||
now: number,
|
||
onExit: (session: Session) => void, // manager 注入:用于从会话表移除(L2)
|
||
): Session;
|
||
|
||
/** 把 ws 绑定到会话:先回放 buffer,再接续实时流。返回踢掉的旧 ws(若有)。 */
|
||
export function attachWs(session: Session, ws: WebSocket): WebSocket | null;
|
||
|
||
/** 解绑 ws,标记 detachedAt;**不杀 PTY**。 */
|
||
export function detachWs(session: Session, now: number): void;
|
||
|
||
// writeInput/resize:exitedAt!=null 时**直接 return**(L4:对已退出 PTY 调 write/resize 会抛错)。
|
||
export function writeInput(session: Session, data: string): void; // pty.write
|
||
// resize 另做幂等:cols/rows 与当前相同则跳过,天然抑制高频 resize(SIGWINCH 抖动)。
|
||
export function resize(session: Session, cols: number, rows: number): void; // pty.resize → SIGWINCH
|
||
export function kill(session: Session): void; // pty.kill,进程退出/回收时调用
|
||
```
|
||
|
||
`RingBuffer` 是独立小工具(可放 `session/ring-buffer.ts`):
|
||
|
||
```ts
|
||
export interface RingBuffer {
|
||
append(chunk: string): void;
|
||
snapshot(): string; // 当前缓冲全量,attach 回放用
|
||
}
|
||
export function createRingBuffer(maxBytes: number): RingBuffer;
|
||
```
|
||
|
||
**裁剪语义(M2,易踩)**:容量单位是**真实字节**(`scrollbackBytes`),但 PTY 输出是 ANSI 字节流,
|
||
**不能在任意字节处切断** —— 否则会把一条 ANSI 转义序列(如 `ESC[1;32m`)或一个多字节 UTF-8 码点
|
||
切成两半,重连回放第一屏花屏/乱码,而这正是 vibe coding 重连看 Claude TUI 的主场景。约定:
|
||
- 内部以 `Buffer`(`'utf8'`)按真实字节计量,使容量与 `scrollbackBytes` 语义一致
|
||
(`string.length` 是 UTF-16 码元数 ≠ 字节数,不可直接当字节用);
|
||
- **按 chunk(append 整块)边界整体淘汰最旧块**,保证不切碎序列(代价是容量略浮动);
|
||
- 兜底:`snapshot()` 开头补一个软复位 `ESC[0m`,即使最旧块边界仍残留半截属性也能止血。
|
||
- 这与不变量 #1"字节是不透明 blob"不冲突:不解析语义 ≠ 可在任意字节处切割。
|
||
`ring-buffer.test.ts` 须覆盖"淘汰不切碎多字节码点 / 不切碎转义序列"。
|
||
|
||
### 3.5 `session/manager.ts` —— 会话表
|
||
|
||
```ts
|
||
export interface SessionManager {
|
||
/**
|
||
* attach 入口:
|
||
* - sessionId=null → 新建。
|
||
* - 命中存活会话 → attachWs(回放 + 续流)。
|
||
* - 命中**已退出**会话(exitedAt!=null,L1)→ 回放最后 buffer 后立即补发 exit,再移除;不静默新建。
|
||
* - 查不到(含格式非法已被 §3.2 拦掉)→ 新建并返回新 id。
|
||
* createSession 抛错(spawn 失败,M4)不在此吞掉,向上抛给 server.ts。
|
||
*/
|
||
handleAttach(ws: WebSocket, sessionId: string | null, dims: Dims, now: number): Session;
|
||
get(id: string): Session | undefined;
|
||
/**
|
||
* 孤儿回收(M3)。判据 = detach 超过 idleTtl **且** 自 detach 起无新输出
|
||
* (now - max(detachedAt, lastOutputAt) > idleTtl)。
|
||
* 不依赖"PTY 前台子进程"——node-pty 不跨平台暴露前台进程组,无法可靠判定;
|
||
* 用 lastOutputAt 近似"仍在跑任务",且该字段可被 mock IPty 驱动、可测。
|
||
* 定时器周期调用。返回回收数量。
|
||
*/
|
||
reapIdle(now: number): number;
|
||
/** 服务端退出时统一清理(TECH_DOC §5.2)。 */
|
||
shutdown(): void;
|
||
}
|
||
|
||
export function createSessionManager(cfg: Config): SessionManager;
|
||
```
|
||
|
||
**约束(逐条对应 TECH_DOC §5.2):**
|
||
- WS close → `detachWs`,**不** kill PTY。
|
||
- 同一会话同时只允许一个 ws,后到踢前者(`attachWs` 返回旧 ws,调用方先把 `attachedWs` 指向新 ws、
|
||
再 close 旧 ws,确保转发逻辑永不向被踢/关闭中的 ws 写)。
|
||
- PTY 退出:`createSession` 内部 onExit → 置 exitedAt → 有 ws 则发 exit → 调 manager 注入的回调从表移除
|
||
(L2:session 不持有 manager,退出事件靠注入回调跨层冒泡)。
|
||
- detach 后才退出(无 ws 可通知,L1):**不立即丢弃会话**,保留 buffer + exitedAt,
|
||
等用户带旧 id 回来 attach 时回放 + 补发 exit;若一直无人 attach,由 reapIdle 兜底回收。
|
||
|
||
### 3.6 `server.ts` —— 接线层(薄)
|
||
|
||
只做接线,无业务逻辑:
|
||
|
||
```ts
|
||
export function startServer(cfg: Config): { close(): Promise<void> };
|
||
```
|
||
|
||
职责:
|
||
1. Express 托管 `public/`(及编译后的前端)。
|
||
2. **WS 用 `new WebSocketServer({ noServer: true })` 创建**(L3:不传 `server`,否则它会自挂
|
||
upgrade 监听,与下面手写的 upgrade 校验双重处理同一 socket)。
|
||
3. HTTP server `upgrade` 事件(单一升级入口):
|
||
- `pathname !== '/term'`(路径常量入 config,不散落硬编码)→ `socket.destroy()`;
|
||
- `isOriginAllowed` 不通过 → `socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')` + `destroy()`;
|
||
- 全部通过 → `wss.handleUpgrade(req, socket, head, ws => wss.emit('connection', ws, req))`。
|
||
4. WS `connection`:等首条 `attach` → `try { manager.handleAttach() }` 回 `attached`;
|
||
**`catch`(spawn 失败,M4)→ `send({type:'exit', code:-1, reason})` 后 close,只关该连接,不 fail-fast**。
|
||
5. WS `message`:`parseClientMessage`,`ok:false` 丢弃+记日志,否则路由到 `writeInput`/`resize`。
|
||
6. WS `close`:`manager` detach(**不** kill PTY)。
|
||
7. `process.on('SIGINT'/'SIGTERM')` → `manager.shutdown()`;`uncaughtException` 记录后退出
|
||
(fail-fast 仅用于真正未预期错误;spawn 失败、send 失败等已知路径不得走到这里,TECH_DOC §5.4)。
|
||
|
||
**转发统一守卫(M5)**:任何 `ws.send` 前必须 `if (ws.readyState === WebSocket.OPEN)`;转发只对
|
||
`session.attachedWs` 发送。send 失败的处理是**只 detach 该 ws、绝不 kill PTY**(否则违反不变量 #2)——
|
||
注意区别于 TECH_DOC §5.4 笼统的"清理会话"措辞。
|
||
|
||
---
|
||
|
||
## 4. 关键流程时序
|
||
|
||
### 4.1 新建会话
|
||
|
||
```
|
||
ws 连接 → 等待首帧
|
||
client → {attach, sessionId:null}
|
||
manager.handleAttach(null) → createSession(spawn pty) → attachWs
|
||
server → {attached, sessionId:"<uuid>"}
|
||
pty.onData → buffer.append → ws.send {output}
|
||
```
|
||
|
||
### 4.2 重连回放(F5/F6,vibe coding 命脉)
|
||
|
||
```
|
||
新 ws 连接(旧 ws 可能还活着)
|
||
client → {attach, sessionId:"<uuid>"}
|
||
manager.get(id) 命中
|
||
attachWs: 踢掉旧 ws → buffer.snapshot() 一次性 send → 之后接实时流
|
||
server → {attached} → {output: <回放>} → {output: <实时>}...
|
||
```
|
||
关键:**断开期间 pty.onData 仍写入 buffer**(因为 detach 不停 PTY),所以离线输出不丢。
|
||
|
||
### 4.3 resize
|
||
|
||
```
|
||
client → {resize, cols, rows} // 独立消息,不混进 input
|
||
exitedAt? → 忽略;cols/rows 未变? → 忽略(幂等)
|
||
pty.resize(cols, rows) → ioctl(TIOCSWINSZ) → shell 收 SIGWINCH → 全屏程序重绘
|
||
```
|
||
|
||
### 4.4 PTY 退出 / spawn 失败(L1 / M4)
|
||
|
||
```
|
||
[在 detach 状态下退出]
|
||
pty.onExit → 置 exitedAt/exitCode → attachedWs 为 null,exit 暂不投递
|
||
会话保留(buffer + exitedAt)
|
||
client(旧 id 回来) → {attach, sessionId}
|
||
命中已退出会话 → server → {output:<回放>} → {exit, code} // 用户仍看到末屏与退出码
|
||
之后由 reapIdle 兜底回收
|
||
|
||
[spawn 失败]
|
||
client → {attach, sessionId:null}
|
||
handleAttach → createSession 同步抛错 → server.ts catch
|
||
server → {exit, code:-1, reason:"spawn failed: ..."} → close(仅此连接)
|
||
```
|
||
|
||
---
|
||
|
||
## 5. 前端架构(`public/`)
|
||
|
||
**构建**:打包器 = esbuild(T1 定)。入口 `public/main.ts` → `npm run build:web` → `public/build/main.js`(+ `main.css`);
|
||
`index.html` 以 `<script type="module" src="./build/main.js">` 加载;server 静态托管 `public/`。`tsconfig.web.json` 仅类型检查。
|
||
|
||
| 文件 | 职责 | 关键点 |
|
||
|------|------|--------|
|
||
| `main.ts` | xterm + FitAddon 初始化、WS 客户端、重连 | WS URL 同源,**scheme 随页面协议**:`(location.protocol === 'https:' ? 'wss' : 'ws') + '://' + location.host + '/term'`(M6:HTTPS 页面下硬写 `ws://` 会被浏览器 mixed-content 拦截,而 Tailscale/TLS 部署正是 HTTPS);`term.onData → ws.send {input}`;`onmessage → term.write(output)` |
|
||
| `keybar.ts` | 移动触摸键栏 | `touchstart` 直接 `ws.send {input,data}`,**绕过 xterm** 避免弹软键盘;>768px 隐藏 |
|
||
|
||
**重连状态机**(TECH_DOC §6.2):指数退避 1s/2s/4s…上限 30s,携带 localStorage 的 sessionId;
|
||
`fit()` 必须在容器有真实尺寸后调用(`display:none` 会得到 NaN,TECH_DOC §9 坑位)。
|
||
|
||
按键字节表(keybar):Esc `\x1b` / Shift+Tab `\x1b[Z` / 方向 `\x1b[A..D` / Enter `\r` / Ctrl+C `\x03` / Tab `\t`。
|
||
注意回车是 `\r`(0x0D)不是 `\n`。
|
||
|
||
---
|
||
|
||
## 6. 实现顺序(对应 TECH_DOC §9,TDD)
|
||
|
||
| 步骤 | 模块 | 验证 | 依赖 |
|
||
|------|------|------|------|
|
||
| S1 | 脚手架 + `package.json` + `tsconfig` + node-pty 编译验证 | `npm install` 通过 | — |
|
||
| S2 | `config.ts` | 单测:默认值 + 非法值抛错 | — |
|
||
| S3 | `protocol.ts` + `ring-buffer.ts` | **先写测试**(RED→GREEN),覆盖校验分支 | — |
|
||
| S4 | `http/origin.ts` | 单测:白名单/非法/undefined | config |
|
||
| S5 | `session.ts` + `manager.ts`(PTY 用 mock) | `manager.test.ts`:attach/detach 不杀 PTY、踢旧 ws、reap | S2–S3 |
|
||
| S6 | `server.ts` 接线 | 手动:浏览器连通 | S2–S5 |
|
||
| S7 | `public/`(main + keybar) | 手动:vim/top/resize/手机 | S6 |
|
||
| S8 | 联调验收 F1–F9 | TECH_DOC §8 验收表 | 全部 |
|
||
|
||
**先做 S3**:协议与缓冲是纯函数,最适合 TDD 起步且无外部依赖,能在没接 PTY/WS 时就锁定边界行为。
|
||
|
||
---
|
||
|
||
## 7. 测试策略
|
||
|
||
- **单元**(`test/`):
|
||
- `protocol`:编解码 + 全部校验分支(含 `SESSION_ID_RE` 接受 UUID v4 / 拒绝 `abc123`、超 `maxPayloadBytes` 拒收)。
|
||
- `config`:`allowedOrigins` 由网卡 IP 推导(注入假 `networkInterfaces`),**不含** `0.0.0.0`(M1)。
|
||
- `origin`:白名单/非法/undefined;host+port 双匹配。
|
||
- `ring-buffer`:淘汰不切碎多字节码点 / 不切碎 ANSI 序列、按字节计量(M2)。
|
||
- **会话生命周期**:`manager.test.ts` 用 **mock IPty**(`{ write, resize, kill, onData, onExit }`,可手动触发
|
||
onData/onExit)验证:"WS 断开不杀 PTY""后到踢前者""reapIdle 用 lastOutputAt 判据(M3):有新输出不回收""
|
||
detach 后 onExit 保留会话、attach 回放+补发 exit(L1)""对已退出 PTY 的 write/resize 被忽略(L4)"。
|
||
- **集成/E2E**:server 起真 WS,断言 attach→attached→output 时序;重连回放覆盖 F5/F6;
|
||
覆盖 spawn 失败 → exit(-1)+close 仅关连接(M4)、非 `/term` 路径与坏 Origin 被拒(L3/F9)。
|
||
- 覆盖率目标见全局 testing 规范;PTY/xterm 真实交互部分以手动验收(§6 S7/S8)补足。
|
||
|
||
---
|
||
|
||
## 8. 不变量清单(代码评审对照)
|
||
|
||
1. 服务端不解析 ANSI/终端语义,PTY 字节是不透明 blob(但 RingBuffer 淘汰须按 chunk/码点边界,不可任意字节切割,M2)。
|
||
2. WS 断开 **永不** kill PTY —— 只 detach;`ws.send` 失败也只 detach 该 ws,不动 PTY(M5)。
|
||
3. `parseClientMessage` 永不抛异常,非法输入走 `ParseResult.ok=false`。
|
||
4. 会话 meta 不可变;运行时句柄(ws/detachedAt/lastOutputAt/exitedAt)单独可变。
|
||
5. 一个会话同一时刻最多一个附着 ws;转发前先重置 `attachedWs` 指针再 close 旧 ws。
|
||
6. 任何 `ws.send` 前必须 `readyState === OPEN`(M5)。
|
||
7. Origin 校验在 upgrade 阶段完成(`noServer` + `handleUpgrade`),失败 401,不进入 WS 逻辑;路径限 `/term`(L3)。
|
||
8. 无硬编码:所有可变参数走 `config.ts`(端口、shell、TTL、scrollback、maxPayload、WS 路径、allowedOrigins)。
|
||
9. `input.data` 原样透传,不做内容过滤。
|
||
10. PTY 退出后 `writeInput`/`resize` 静默忽略;exitedAt 会话保留至被 attach 回放或 reapIdle 回收(L1/L4)。
|
||
11. 服务端不假设前端已防抖:`resize` 在服务端做幂等(值未变则跳过);WS 帧受 `maxPayload` 上限约束(L5)。
|
||
``` |