# Step 55: 本地 REPL Bridge
一句话导读:把正在跑的会话「桥」出去,让另一个终端也能实时看到输出、发指令进来——而它能成立的全部前提,是 step32 早就把引擎产出从「回调」升级成了「事件流」,事件流天然可转发、可多消费者。
# 一、这一步做了什么(What)
实现一个本地 Bridge:把本地正在跑的 QueryEngine 会话接到第二个前端上。
你在终端 npm start(本地进程,跑着 QueryEngine)
│ Bridge 把【事件流】广播出去,接收远端【prompt】注入
▼
另一个终端 npm run attach ←→ (真实版还可以是网页 / 手机)
看到同一个会话的实时输出,也能发指令
2
3
4
5
具体三块新东西:
services/bridge/server.ts的BridgeServer:用 Node 自带net(TCP)+ 行分隔 JSON,广播引擎事件、接收远端 prompt;services/bridge/attach.ts:附着端 CLI,npm run attach起一个只观察 + 发指令的第二前端;utils/input.ts加injectInput():用readline.write把远端 prompt 模拟成本地键入。
真实 Claude Code 的 bridge/ 走 WebSocket/HTTP 经 Anthropic 服务器中转 + workSecret/设备鉴权,让你从 claude.ai/code 网页操控本地会话——我们剥掉云中转和鉴权,只保留架构内核。
# 二、面试官视角:为什么剥掉云中转和鉴权后,Bridge 还剩什么?(Why)
面试题:真实 Bridge 的一大堆工程(WebSocket、云 ingress 中转、workSecret 配对、设备 token)你全删了。删完之后,Bridge 到底还剩下什么「本质」值得学?
剩下的是前端与引擎解耦——这才是 Bridge 可迁移的内核,云中转和鉴权只是 Claude-Code 特定部署形态下的基建外壳。
核心洞察是:QueryEngine 只管产出一条 EngineEvent 事件流,根本不关心谁来消费。本地终端可以消费它、attach 客户端可以消费它、将来的网页也可以消费它。既然引擎和「谁在看」已经解耦,那「多接一个 socket 消费者」就是零侵入的——不用改引擎一行代码。Bridge 于是被压缩成一句话定义:
把事件流通过一个 socket 广播出去 + 把远端 prompt 注入回同一个引擎。
这正是 step32 埋下的伏笔的兑现。当初把引擎产出从「回调」(onEvent(cb),一次只能一个消费者、且耦合调用方)升级成「事件流」(可被任意多方 for-await 消费)看着只是重构,价值到这一步才结算:事件流天然可转发、可多播,回调做不到。这又是一次「模块化复利」——好的抽象让后来的新需求近乎免费。
# 三、原理:它是怎么工作的(How)
# 数据流(双向)
【输出方向】QueryEngine 产出 EngineEvent
→ index.ts 主循环 for-await 消费(本地渲染)
→ 同时 bridge.broadcastEvent(ev) → 所有 attach 客户端收到 {type:"event", ev}
【输入方向】attach 端输入「用两个字打招呼」
→ 客户端发 {type:"prompt", text} 走 TCP
→ BridgeServer.onData 拆行 JSON → promptHandler(text)
→ index.ts 的 onPrompt → injectInput(text)
→ readline.write 模拟本地键入 → 主循环正 await ask() 把它当一行收下
→ 引擎照常处理,产出的事件又广播回两端
2
3
4
5
6
7
8
9
10
# 巧办法:注入即模拟键入
最漂亮的一处是 prompt 注入。主循环平时在 await ask("> You:") 等本地用户输入。远端来了个 prompt,怎么塞进这个正在等待的 question?答案是假装用户敲了键:
// utils/input.ts
export function injectInput(text: string) {
getReadline().write(text + "\n"); // rl.write 会像用户敲键一样触发 line/question 回调
}
2
3
4
readline.write(text+"\n") 会让 readline 就像用户真敲了这一行并回车一样触发回调,正在 await 的那个 ask() 就把它收下了。主循环的输入逻辑一行都不用改——它分不清也不需要分清这行是本地敲的还是远端注入的。
# 服务端:TCP + 行分隔 JSON
BridgeServer 用 Node 自带 net(零依赖),分帧沿用 step47 手写 MCP 的行分隔 JSON思路(TCP 是字节流,得自己按 \n 拆):
private onData(sock: Socket, chunk: Buffer) {
let buf = (this.buffers.get(sock) ?? "") + chunk.toString("utf-8");
let nl: number;
while ((nl = buf.indexOf("\n")) !== -1) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
const msg = JSON.parse(line);
if (msg?.type === "prompt" && typeof msg.text === "string") {
this.promptHandler?.(msg.text); // 客户端目前只会发 prompt,注入回引擎
}
}
this.buffers.set(sock, buf);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
监听地址是关键的一行安全边界:
// 只监听回环地址 127.0.0.1:本机才能连,不暴露到局域网。
this.server.listen(this.port, "127.0.0.1", () => resolve());
2
# 消息协议
- 服务端 → 客户端:
{type:"hello"}(连上时的会话信息)/{type:"event", ev}(引擎事件)/{type:"prompt", text}(本地用户输入的回显,让 attach 端也看到本地在敲什么); - 客户端 → 服务端:
{type:"prompt", text}(远端指令)。
# 四、深入追问(面试常见 follow-up)
Q:为什么用「注入即模拟键入」这种取巧办法,而不是给主循环加一条「远端输入」的处理分支?
A:为了不碰主循环的输入逻辑。主循环只有一个「取一行输入 → 交给引擎」的路径。如果为远端 prompt 专门加分支,就得处理「本地 ask 正在 await 时突然来了远端输入」的并发交织——状态机立刻复杂。用 readline.write 把远端 prompt 降维成「本地又敲了一行」,让它汇入同一条已有路径,主循环对来源无感知,复杂度直接归零。这是「用已有抽象消化新需求」的典型。
Q:只监听 127.0.0.1 就算安全了吗?和真实版的 workSecret 差在哪?
A:127.0.0.1 只保证同机进程才能连(局域网/公网都够不着),这是最基本的攻击面收窄。但同机上的任何进程仍能连——没有身份校验。真实版必须跨机(网页操控本地),所以要 workSecret 配对 + trustedDevice token + 会话鉴权来证明「连过来的确实是你授权的设备」。我们是本地场景,用监听地址换掉了整套鉴权——够用且简单,但不能直接把 127.0.0.1 改成 0.0.0.0 暴露出去,那样就是无鉴权裸奔。
Q:多个 attach 客户端同时连,会乱吗?
A:不会——broadcast 遍历 clients 集合发给每一个,事件是只读广播,天然支持多消费者(这正是事件流相比回调的优势)。输入方向所有客户端的 prompt 都注入同一个引擎、串行成一条输入流。要注意的是没有并发会话隔离:所有人操控的是同一个会话,这是设计如此(「桥同一个会话给多个前端」),不是多租户。
Q:为什么分帧又用行分隔 JSON,而不是 LSP 那种长度前缀?
A:因为 Bridge 传的是控制消息和事件对象(EngineEvent),不含整段带换行的源代码,用 \n 分帧不会切错。这和 step47 MCP 选行分隔同理;只有 LSP 那种消息体里塞源码的场景才必须上长度前缀。分帧方式该按「消息内容会不会含分隔符」来选。
# 五、踩坑 / 设计权衡
- 剥离基建是刻意的:云中转、WebSocket、鉴权是 Claude-Code 独有的工程量,AI/架构价值低。剥掉它们不是偷懒,是为了让「前端与引擎解耦」这个可迁移内核不被基建噪音淹没。判断「什么该剥」的标准:它是本质,还是这个产品特定部署下的外壳?
- 注入的隐性依赖:
injectInput依赖主循环正好在await ask()。若主循环在忙别的(如正在跑工具),注入的行会被 readline 缓冲到下一次question——行为可接受,但要意识到它不是「立即打断」。 - 没做的:断线重连、v1/v2 transport、并发会话隔离、完整前端(历史/权限弹窗/文件 diff)——真实网页端都有,我们只做「观察 + 发指令」的最小附着端。
# 六、与真实源码的对照
| 我们的实现 | Claude Code 源码 |
|---|---|
| 本地 TCP + 行 JSON | WebSocket/HTTP,经 Anthropic 服务器 ingress 中转 |
| 只听 127.0.0.1、无鉴权 | workSecret 配对 + trustedDevice token + 会话鉴权 |
| 广播 EngineEvent + 注入 prompt | 同样是「事件流 + 指令」,但有 v1/v2 transport、断线重连、并发会话 |
| 附着端只观察 + 发指令 | 网页端是完整前端(历史、权限弹窗、文件 diff…) |
# 七、一句话总结
Bridge 的可迁移内核 = 前端与引擎解耦:引擎只吐 EngineEvent 事件流、不关心谁消费,所以多接一个 socket 广播是零侵入;远端 prompt 靠 readline.write 模拟本地键入注入,绕开改主循环——这一切都建立在 step32 把「回调」升级成「事件流」的那次伏笔之上。
# 下一节预告
下一步(跳过 step56 远程/SSH)进入 step57 权限引擎深化——把「按工具名放行」升级成带参数模式的 allow/deny 规则引擎 + 文件路径越界校验。