# Step 55: 本地 REPL Bridge

一句话导读:把正在跑的会话「桥」出去,让另一个终端也能实时看到输出、发指令进来——而它能成立的全部前提,是 step32 早就把引擎产出从「回调」升级成了「事件流」,事件流天然可转发、可多消费者。


# 一、这一步做了什么(What)

实现一个本地 Bridge:把本地正在跑的 QueryEngine 会话接到第二个前端上。

你在终端 npm start(本地进程,跑着 QueryEngine)
        │  Bridge 把【事件流】广播出去,接收远端【prompt】注入
        ▼
另一个终端 npm run attach  ←→  (真实版还可以是网页 / 手机)
   看到同一个会话的实时输出,也能发指令
1
2
3
4
5

具体三块新东西:

  1. services/bridge/server.ts 的 BridgeServer:用 Node 自带 net(TCP)+ 行分隔 JSON,广播引擎事件、接收远端 prompt;
  2. services/bridge/attach.ts:附着端 CLI,npm run attach 起一个只观察 + 发指令的第二前端;
  3. 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() 把它当一行收下
   → 引擎照常处理,产出的事件又广播回两端
1
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 回调
}
1
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);
}
1
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());
1
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 规则引擎 + 文件路径越界校验。