数据截至 (上游 commit c76af90d88f4)
三方拓扑与协议底座
30 秒导读: HAPI 里有三个角色——跑在你开发机上的 CLI、常驻的 Hub、手机/浏览器里的 Web。 本章只讲一件事:谁跟谁说话、说的是什么话。后面所有章节(接管、RPC、缓存、Runner)都站在这层协议地基上。
1. 先认人:三个角色各自是什么
HAPI 要解决的场景很具体:你在终端里跑着 Claude Code,人要离开工位,但 agent 还在干活、还会随时弹出"要不要允许执行这条命令"。
要让手机能接手,至少得有三个东西:
| 角色 | 是什么 | 跑在哪 |
|---|---|---|
| CLI | 包住真实 agent(Claude Code / Codex / Cursor Agent 等)的壳,是唯一知道真相的人 | 你的开发机 |
| Hub | 常驻服务:存消息、做缓存、转发请求、扇出通知 | 通常也是你的开发机(local-first) |
| Web | 浏览器 / PWA / Telegram Mini App 前端 | 手机或另一台电脑 |
关键的不对称: CLI 是事实源(它真的在跑 agent 进程),Hub 是账本 + 交换机,Web 是观察者 + 遥控器。 这个不对称直接决定了两条链路用了完全不同的传输方式。
2. 顶层全景:三条链路
怎么读这张图:左边是事实源,右边是遥控器,Hub 夹在中间做交换机。序号 ①③ 是长连接,② 是普通 HTTP(请求 + 单向流)。
┌──────────────┐ ┌──────────────┐
│ CLI │ │ Web │
│ (包住 agent) │ │ (浏览器/PWA) │
└──────┬───────┘ └───┬──────┬───┘
│ │ │
① Socket.IO │ /cli namespace REST ② │ │ ③ Socket.IO
双向长连接 │ ▲ 上报: message/session-alive │ │ /terminal
│ ▼ 下发: update/rpc-request 写操作 │ │ 双向长连接
│ │ │ (远程终端)
┌──────▼──────────────────────────────────────▼──────▼───┐
│ Hub │
│ socket server ── syncEngine ── SSEManager ── web(hono) │
└────────────────────────────┬────────────────────────────┘
│
② SSE /api/events (单向下推)
▼
Web 前端
三条链路的取舍一句话总结:
| 链路 | 传输 | 方向 | 为什么这么选 |
|---|---|---|---|
| CLI ↔ Hub | Socket.IO /cli namespace | 双向 | Hub 必须能主动向 CLI 下发 rpc-request(权限审批、读文件),纯请求-响应做不到 |
| Web → Hub | REST(hono 路由) | 单向请求 | 所有写操作(发消息、批准权限、改元数据)都是普通 HTTP,便宜、可缓存、易调试 |
| Hub → Web | SSE /api/events | 单向下推 | Web 只需要"被通知",不需要往回推流;SSE 是纯 HTTP,浏览器原生带自动重连 |
| Web ↔ Hub(终端) | Socket.IO /terminal namespace | 双向 | 远程终端要逐键上行 + 逐字节下行,这是唯一真需要双向长连接的前端能力 |
3. 链路一:CLI ↔ Hub —— 为什么必须是双向 socket
3.1 CLI 侧怎么连
CLI 每个会话建一条 socket,直接连到 /cli 这个 namespace(cli/src/api/apiSession.ts:303,ApiSessionClient(:221)构造里的 io() 调用):
// cli/src/api/apiSession.ts:274 — 压缩片段(略去了 reconnectionDelay / reconnectionDelayMax
// 与 buildSocketIoExtraHeaderOptions() 的展开)
this.socket = io(`${configuration.apiUrl}/cli`, {
auth: { token: this.token, clientType: 'session-scoped' as const, sessionId: this.sessionId },
path: '/socket.io/',
reconnection: true,
reconnectionAttempts: Infinity,
transports: ['websocket'],
autoConnect: false
})
三个细节值得记:握手里就带 sessionId(Hub 拿它决定进哪个房间)、无限重连(开发机断网 是常态)、只走 websocket(CLI 不是浏览器,不需要 polling 兜底)。
3.2 Hub 侧为什么需要"主动下发"
如果 CLI 只是"上报",用 HTTP POST 就够了。真正逼出长连接的是反方向:Hub 要能在任意时刻叫 CLI 干活。
ServerToClientEvents 里的这一条就是理由(shared/src/socket.ts:238):
'rpc-request': (data: { method: string; params: string }, callback: (response: string) => void) => void
Hub 主动发起、CLI 用 callback 回值——这是反向 RPC,手机上按"允许"之后走的就是它。细节见 反向 RPC 与权限审批。
除了 RPC,Hub 还会通过 update 事件把消息、元数据变更推回 CLI(shared/src/socket.ts:237)。例如你在手机上发了一句话,MessageService 会把它投递到会话房间(hub/src/sync/messageService.ts:931):
this.io.of('/cli').to(`session:${sessionId}`).emit('update', update)
3.3 房间(room)= 路由单位
Hub 不广播,它按房间投递。CLI 一连上,握手里的 sessionId / machineId 通过鉴权后就 join 对应房间(hub/src/socket/handlers/cli/index.ts:91-98,registerCliHandlers):
socket.join(`session:${sessionId}`) // 会话级
socket.join(`machine:${machineId}`) // 机器级(Runner 用)
于是"发给这个会话的 CLI"就是 .to('session:xxx'),"发给这台机器的 Runner"就是 .to('machine:xxx')。
4. 链路二:Web ↔ Hub —— 为什么是 REST + SSE,而不是 socket
4.1 事实先摆出来
Web 的读写是分开的两套东西:
- 写:全部走 hono 的 REST 路由,挂在
/api下(hub/src/web/server.ts:284-310)——sessions/messages/permissions/machines/git/voice等各一个路由模块。 - 读(实时):一条 SSE 长连接,
GET /api/events,用 hono 的streamSSE实现(路由工厂createEventsRoutes在hub/src/web/routes/events.ts:36,streamSSE调用在:81)。
4.2 为什么不用 socket
对比一下两边的真实需求形状:
| 需求 | CLI 侧 | Web 侧 |
|---|---|---|
| 服务端要主动调用客户端并等返回值 | 要(rpc-request) | 不要 |
| 客户端上行频率 | 高(每条 agent 输出) | 低(人手打字/点按钮) |
| 上行是否需要保序低延迟 | 是 | 否,REST 足够 |
| 断线重连 | 自己实现 | 浏览器 EventSource 原生带 |
Web 侧唯一的实时诉求是"服务端有新东西就告诉我",这正是 SSE 的定义域。用 socket 反而要多付三笔成本:多一层协议帧、要自己做重连退避、以及不能走标准 HTTP 中间件链(CORS、gzip、鉴权中间件都得另写一套)。(inferred:代码本身没写取舍理由,这里是从两侧实现形状反推的。)
HAPI 把这三笔便宜都吃到了:
- 鉴权直接复用 hono 中间件——
createAuthMiddleware一把管住整个/api/*(hub/src/web/middleware/auth.ts:17)。 - gzip 直接套在 Response 上——
compressSseResponse(hub/src/web/sseCompression.ts:58)。 - 重连由浏览器兜底——前端只在
EventSource真的 CLOSED 时才自己退避重连(useSSE的onerror,web/src/hooks/useSSE.ts:849-854)。
4.3 SSE 的那个代价:token 只能塞 query
EventSource 不能设请求头,所以 JWT 没法放 Authorization。HAPI 的处理是在鉴权中间件里只给 /api/events 这一条路径开口子(hub/src/web/middleware/auth.ts:27):
const tokenFromQuery = path === '/api/events' ? c.req.query().token : undefined
const token = tokenFromHeader ?? tokenFromQuery
前端相应地把 token 拼进 URL(web/src/hooks/useSSE.ts:213,buildEventsUrl)。这是 SSE 方案唯一明显的丑处——白名单收得很窄,但 token 确实会出现在 URL 里。
4.4 远程终端为什么又退回 socket
终端是唯一"真双向"的前端能力:你按一个键要立刻上行,PTY 吐一个字节要立刻下行。所以它单开一个 namespace,前端用 socket.io-client 的 Manager 连 /terminal(useTerminalSocket 在 web/src/hooks/useTerminalSocket.ts:37,建连在 :128):
const socket = manager.socket('/terminal', { auth: { token } })
注意这里 transports: ['polling', 'websocket'] ——和 CLI 侧只走 websocket 不同,浏览器要留 polling 兜底(web/src/hooks/useTerminalSocket.ts:128)。
Hub 侧不让 Web 直接碰 PTY:/terminal 的消息会被翻译成 terminal:* 事件,转发给 /cli 房间里的那条 CLI socket(registerTerminalHandlers 在 hub/src/socket/handlers/terminal.ts:33,挑 CLI socket 的 pickCliSocketId 在 :71)。终端的注册表、并发上限与 idle 回收见 多 agent 抽象、远程终端与外围能力。
5. 事件契约:两套,不是重复
这是最容易看岔的地方。shared 包(在 workspace 里叫 @hapi/protocol,见 shared/package.json)里躺着两套事件定义,名字还长得很像。
5.1 它们分别是什么
CLI ──── ClientToServerEvents ────► Hub ──── SyncEvent ────► Web
◄─── ServerToClientEvents ───── (SSE 单向)
(shared/src/socket.ts) (shared/src/schemas.ts)
「权威上报 / 指令下发」 「面向前端的投影」
| 维度 | socket.ts 的两个接口 | schemas.ts 的 SyncEventSchema |
|---|---|---|
| 位置 | shared/src/socket.ts:236、:202 | shared/src/schemas.ts:543 |
| 谁跟谁 | CLI ↔ Hub | Hub → Web(SSE) |
| 语义 | 权威事实 + 指令(带 ack、带版本号) | 只读通知("有东西变了") |
| 是否有 ack | 有(update-metadata / update-state 都带回调) | 无,SSE 是单向流 |
| 表达形式 | TypeScript interface + 若干 zod schema | 单个 zod discriminatedUnion |
5.2 CLI 侧契约长什么样
ClientToServerEvents(shared/src/socket.ts:254)是 CLI 的上报面,挑几条代表性的:
| 事件 | 说什么 |
|---|---|
message | agent 又产出了一条消息 |
session-alive | 我还活着;顺带汇报 thinking / mode(local 还是 remote)/ model / permissionMode |
session-ready | agent 加载完了,可以接 prompt 了 |
session-end | 会话结束,带 reason |
update-metadata / update-state | 改会话元数据/agent 状态,带 expectedVersion 和 ack |
machine-alive / machine-update-* | Runner 守护进程的同款三件套 |
rpc-register / rpc-unregister | 声明"我能处理哪些 RPC 方法" |
terminal:ready / output / exit / error | PTY 上行 |
反方向 ServerToClientEvents(shared/src/socket.ts:236-252)只有七个,归成四类:update、rpc-request、四个 terminal:*(open / write / resize / close)、error。Hub 对 CLI 说话的词汇量远小于 CLI 对 Hub 说话的词汇量——因为 CLI 才是事实源。
5.3 Update 是一个信封
update 事件的载荷是 UpdateSchema(shared/src/socket.ts:175),结构是"信封 + 四选一的内容":
Update { id, seq, createdAt, body }
│
├── UpdateNewMessageBodySchema (t: 'new-message') :72
├── UpdateSessionBodySchema (t: 'update-session') :86
├── UpdateMachineBodySchema (t: 'update-machine') :101
└── UpdateCancelQueuedMessageBody… (t: 'cancel-queued-…') :116
注意 UpdateSessionBodySchema 里 metadata 和 agentState 各自带 version——这是乐观并发的版本号,细节在 Hub 的状态与同步。
5.4 Web 侧契约:投影,不是转发
SyncEventSchema(shared/src/schemas.ts:543)是一个 13 分支的判别联合,类型标签有 session-added / session-updated / session-removed / message-received / messages-invalidated / scheduled-matured / session-ended / machine-updated / toast / messages-consumed / message-cancelled / heartbeat / connection-changed。
它不是 Update 的重命名版,证据有三:
- 来源不止 CLI。
heartbeat、connection-changed是 SSE 传输层自己造的(hub/src/web/routes/events.ts:100-126);toast是 Hub 的通知系统造的;scheduled-matured是 Hub 定时器造的。 - 粒度不同。 CLI 一条
message事件进来,Hub 可能同时产出message-received和session-updated(因为消息里夹带了 TodoWrite 或 team 状态)——见hub/src/socket/handlers/cli/sessionHandlers.ts:154-171(TodoWrite)、:128-137(team 状态)与:162(message-received本体)。 - 信息量被裁剪。
session-updated可以只带一个sessionId而不带 data,让前端自己去 REST 拉最新快照。
一次 CLI 上报的分叉,画出来是这样:
CLI: emit('message', {...})
│
▼ sessionHandlers.ts:85 socket.on('message')
store.messages.addMessage() ← 先落账本 :115
│
├──► socket.to('session:sid').emit('update', …) :160 ← 给同会话的其它 CLI
│
└──► onWebappEvent({ type:'message-received', …}) :162 ← 给 Web 的投影
│
▼ syncEngine.handleRealtimeEvent (syncEngine.ts:363)
eventPublisher.emit() (eventPublisher.ts:20)
│ 补上 namespace
▼
sseManager.broadcast() ← 再按订阅过滤扇出
EventPublisher.emit 那一步做的事很小但很关键:给事件补 namespace 字段(hub/src/sync/eventPublisher.ts:20)。namespace 是从会话/机器缓存里反查出来的(hub/src/sync/syncEngine.ts:241,resolveNamespace)。为什么必须补,下一节讲。
6. 认证与多租户:两类客户端,两种信任
Hub 上跑着两个 socket namespace,鉴权方式完全不同——因为这两类客户端的信任级别不同。
createSocketServer (hub/src/socket/server.ts:50)
│
┌──────────────────┴──────────────────┐
▼ ▼
io.of('/cli') :89 io.of('/terminal') :90
cliNs.use() :107 terminalNs.use() :133
│ │
长期 CLI_API_TOKEN 4 小时期限的 JWT
constantTimeEquals 常时比较 jose.jwtVerify(HS256)
│ │
socket.data.namespace = 从 token 后缀切 socket.data.namespace = payload.ns
6.1 CLI 侧:长期 token + 后缀切租户
CLI 拿的是 CLI_API_TOKEN,一个长期有效的共享密钥。HAPI 在这个 token 上借位做了多租户:token 写成 <baseToken>:<namespace>。
parseAccessToken(hub/src/utils/accessToken.ts:8)负责切:
const separatorIndex = trimmed.lastIndexOf(':')
if (separatorIndex === -1) {
return { baseToken: trimmed, namespace: DEFAULT_NAMESPACE } // 没写就是 'default'
}
三个设计点:
- 用
lastIndexOf而不是indexOf——base token 本身含:也不会被切错。 - 没写后缀就落到
DEFAULT_NAMESPACE = 'default'(hub/src/utils/accessToken.ts:1),老用户零改动。 - 两侧带空白一律判非法(
:29-31),避免"tok "和"tok"被当成同一个租户。
切出来的 baseToken 用常时比较对照配置里的真值(hub/src/socket/server.ts:117):
if (!parsedToken || !constantTimeEquals(parsedToken.baseToken, configuration.cliApiToken)) {
return next(new Error('Invalid token'))
}
socket.data.namespace = parsedToken.namespace
constantTimeEquals(hub/src/utils/crypto.ts:3)先把两串补齐到等长再 timingSafeEqual,最后额外比一次原始长度(:18)——补零后长度信息就没了,不补这一刀,"abc" 和 "abc\0" 会判等。
Web 端首次登录也走同一把 token:POST /api/auth 用 parseAccessToken + constantTimeEquals 验完(hub/src/web/routes/auth.ts:31-32),把 ns 写进 JWT payload 签发出去(:63,createAuthRoutes 在 :12)。namespace 就这样从 CLI token 传导到了 Web JWT。
6.2 Terminal 侧:短期 JWT
/terminal namespace 不接受 CLI_API_TOKEN,只认 Web 端那本 4 小时期限的 JWT(hub/src/socket/server.ts:139-159):
const verified = await jwtVerify(token, deps.jwtSecret, { algorithms: ['HS256'] })
const parsed = jwtPayloadSchema.safeParse(verified.payload)
socket.data.userId = parsed.data.uid
socket.data.namespace = parsed.data.ns
差别的意义很直白:
/cli | /terminal | |
|---|---|---|
| 凭证 | 长期共享密钥 | 短期签名令牌 |
| 有效期 | 直到你换掉它 | 4 小时(createAuthRoutes 里 setExpirationTime('4h'),hub/src/web/routes/auth.ts:66) |
| 泄露后果 | 等同拿到整个 Hub | 4 小时后自动作废 |
| 谁持有 | 你自己机器上的进程 | 浏览器 localStorage,跑在不受控环境 |
一句话:浏览器不配拿长期密钥。
6.3 namespace 是怎么真正生效的
握手时写进 socket.data.namespace(类型见 hub/src/socket/socketTypes.ts:4 的 SocketData)之后,每一次访问都要过一道解析:
// hub/src/socket/handlers/cli/index.ts:61 resolveSessionAccess
// 压缩片段:略去了开头 `if (!namespace) return { ok: false, reason: 'namespace-missing' }`
const session = store.sessions.getSessionByNamespace(sessionId, namespace)
if (session) return { ok: true, value: session }
if (store.sessions.getSession(sessionId)) return { ok: false, reason: 'access-denied' }
return { ok: false, reason: 'not-found' }
失败原因一共三个值(SocketErrorReason,shared/src/socket.ts:7):namespace-missing(握手压根没带租户)、access-denied(存在但不属于你)、not-found(根本不存在)。机器侧有一个逐行同构的 resolveMachineAccess(hub/src/socket/handlers/cli/index.ts:75)。
注意后两者是分开回的——存在但不属于你 vs 根本不存在。这对调试友好,但也意味着 Hub 会承认"这个 id 存在"。在 local-first 单人场景下这是合理取舍。
7. SSE 扇出:一条广播怎么变成精准投递
SSEManager(hub/src/sse/sseManager.ts:40)是 Hub 的最后一跳。它管三件事:订阅、过滤、心跳。
边界说明: 事件是怎么产生的(会话缓存、版本号、消息账本)归 Hub 的状态与同步;本节只讲事件怎么出门。
7.1 订阅粒度:三选一
前端连 /api/events 时可以带 all / sessionId / machineId 三种 query(hub/src/web/routes/events.ts:49-60),分别对应三种订阅意图:
| 订阅粒度 | 前端场景 | 收到什么 |
|---|---|---|
all=true | 会话列表页 | 本 namespace 内几乎所有事件 |
sessionId=xxx | 单个会话详情页 | 只要这个会话的 |
machineId=xxx | 机器/Runner 页 | 只要这台机器的 |
订阅前 Hub 会先验归属:sessionId 走 requireSession 守卫(hub/src/web/routes/guards.ts:16),machineId 直接比对 machine.namespace !== namespace 就回 403(hub/src/web/routes/events.ts:64-85)。这是租户隔离的第一道闸。
7.2 过滤逻辑:shouldSend
第二道闸在推送时(hub/src/sse/sseManager.ts:303,shouldSend),读的时候按顺序看:
事件到达
│
├─ type 不是 connection-changed?
│ └─ event.namespace 必须等于 connection.namespace,否则丢弃 ← 租户闸门
│
├─ type 是 message-received / scheduled-matured?
│ └─ 只发给 all 订阅 或 sessionId 精确匹配的连接 ← 高频事件收紧
│
├─ type 是 connection-changed? ─────► 无条件发(传输层信令)
│
├─ connection.all? ─────────────────► 发
├─ 事件带 sessionId 且与订阅相等? ──► 发
├─ 事件带 machineId 且与订阅相等? ──► 发
└─ 否则 ─────────────────────────────► 丢弃
这里有个容易漏掉的强约束:事件没有 namespace 就一律丢(:152-155 的 if (!eventNamespace || ...))。所以 §5.4 里 EventPublisher 补 namespace 那一步不是锦上添花——漏补就等于事件被静默吞掉。
message-received 被单独拎出来收紧也很有讲究:它是全系统最高频的事件,如果按默认规则走,列表页(all=true)之外的每个会话页也会互相收到对方的消息流。
7.3 心跳与自愈
心跳是 30 秒一次,在 startHub 里写死(hub/src/startHub.ts:186):
sseManager = new SSEManager(30_000, visibilityTracker)
心跳定时器按需启停:每次 subscribe 都会调 ensureHeartbeat()(:55),但函数体第一行就用"已有定时器则直接返回"挡住重复(:127-130),所以真正起表的只有第一个订阅;最后一个退订就 stopHeartbeat()(:68-70)。
自愈机制则是一条统一的规则——发送失败即退订:
| 位置 | 行为 |
|---|---|
broadcast :113 | connection.send() reject → unsubscribe(connection.id) |
ensureHeartbeat :134 | 心跳 reject → unsubscribe |
sendToast :101 | 投递失败的那些 id → unsubscribe |
正常退订则由请求生命周期驱动:streamSSE 的回调里挂一个 Promise,等 c.req.raw.signal 的 abort 或 stream.onAbort,任一触发就 manager.unsubscribe(subscriptionId)(hub/src/web/routes/events.ts:133-139)。
前端还有第三层保险:一个看门狗定时器,发现超过阈值没收到任何活动就主动重连,理由记作 heartbeat-timeout(web/src/hooks/useSSE.ts:863-873)。
7.4 VisibilityTracker:只给前台连接发 toast
toast 走的是 sendToast 而不是 broadcast(hub/src/sse/sseManager.ts:217),多了一道判断:
if (!this.visibilityTracker.isVisibleConnection(connection.id)) {
continue
}
VisibilityTracker(hub/src/visibility/visibilityTracker.ts:3,判定函数 isVisibleConnection 在 :45)维护 namespace → 可见连接 id 集合。前端监听 visibilitychange,标签页切前台/后台就 POST /api/visibility 报备:Hub 侧路由在 hub/src/web/routes/events.ts:146,前端侧的监听在 web/src/hooks/useVisibilityReporter.ts:120、实际请求走 web/src/api/client.ts:345 的 setVisibility。
这个设计解决的是通知重复:标签页在前台时,页内 toast 就够了;切到后台就该走 Web Push / FCM / Telegram。sendToast 返回成功投递数,通知系统据此决定要不要再发一次推送——PushNotificationChannel 和 FcmNotificationChannel 构造时都被注入了 sseManager 和 visibilityTracker(hub/src/startHub.ts:228-255)。
8. 装配顺序:startHub 里的先后有讲究
startHub(hub/src/startHub.ts:109)是整个 Hub 的组装现场。顺序不是随意的:
① 解析 CORS 源 normalizeOrigins / mergeCorsOrigins :118-122
② 建 Store(SQLite) new Store(config.dbPath) :170
③ 取 JWT 密钥 getOrCreateJwtSecret() :171
④ 建 SSE 层 VisibilityTracker → SSEManager :176-177
⑤ 建 socket server createSocketServer({...}) :179
⑥ 建 syncEngine new SyncEngine(store, io, rpc, sse) :200
⑦ 建通知渠道 Push / FCM / ServerChan / Telegram :214-248
⑧ 起 HTTP 服务 startWebServer({...}) :251
⑨ 最后才起隧道 TunnelManager.start() :275-290
两处顺序值得单独说:
- ④ 在 ⑤⑥ 之前,因为
SyncEngine构造时要拿sseManager去建EventPublisher(hub/src/sync/syncEngine.ts:211)。 - ⑨ 在 ⑧ 之后,源码里的注释说得很直白:先起 HTTP 服务,隧道才有东西可转发(
hub/src/startHub.ts:282)。
8.1 CORS 归一化:两个小函数,一堆坑
--relay 模式下,前端跑在官方域(https://app.hapi.run),Hub 跑在你的隧道域,天然跨域。HAPI 用三个函数处理(全在 hub/src/startHub.ts):
| 函数 | 行 | 干什么 |
|---|---|---|
normalizeOrigin | 58 | 把 https://a.com/path 用 new URL().origin 削成 https://a.com;解析失败就原样返回 |
normalizeOrigins | 70 | 批量归一 + 去重;只要含 * 就直接坍缩成 ['*'] |
mergeCorsOrigins | 80 | 合并两组;任一侧含 * 同样坍缩成 ['*'] |
relay 模式下自动放行官方 web 源(:120-122):
const corsOrigins = relayFlag.enabled
? mergeCorsOrigins(baseCorsOrigins, relayCorsOrigin ? [relayCorsOrigin] : [])
: baseCorsOrigins
* 坍缩是个正确的偏执:如果不坍缩,['*', 'https://a.com'] 这种数组喂给 socket.io 或 hono 的 CORS 配置,行为要看库怎么实现。先归一成单一形态,下游就只有两种情况要处理。
同一份 corsOrigins 被喂给两处:socket 层(hub/src/socket/server.ts:57-82,包括 engine 的 allowRequest 白名单)和 HTTP 层(hub/src/web/server.ts:237-248)。两层用同一份配置,避免"HTTP 通了 socket 不通"这类只在生产出现的怪事。
8.2 relay 模式下 Hub 不发前端
--relay 打开时,Hub 完全跳过静态资源服务,根路径只返回一个说明页,引导用户去官方 web(hub/src/web/server.ts:313-335)。源码里给的理由是:隧道带宽贵,前端从 GitHub Pages 发更快。
9. 巧妙之处
① 用 token 后缀做多租户,零 schema 改动。
CLI_API_TOKEN:<namespace> 这一招把租户维度塞进了已有的凭证里(hub/src/utils/accessToken.ts:8)。老用户不写后缀就是 default,新用户加个后缀就隔离——不需要注册流程、不需要用户表。
② 常时比较还比了一次长度。
constantTimeEquals(hub/src/utils/crypto.ts:3)补零对齐后额外校验原始长度(:18),堵住了"补零导致短串被判等"的洞。很多手写实现会漏这一刀。
③ SSE 用 zlib 手动 Z_SYNC_FLUSH,而不是标准压缩中间件。
hub/src/web/sseCompression.ts:58 的 compressSseResponse 注释解释得很清楚:CompressionStream 和 hono 的 compress() 都要等流结束才吐数据,对一条挂几小时的 SSE 连接来说等于事件永远到不了。手动驱动 zlib 并每块后 Z_SYNC_FLUSH,损失约一个百分点的压缩率换即时投递。SSE 载荷字段名高度重复,实测压掉约 75%。
④ socket 缓冲区调到 48 MB,并把理由写进常量注释。
SOCKET_MAX_HTTP_BUFFER_SIZE(hub/src/socket/socketLimits.ts:10)记录了一个真实 bug:engine.io 默认 1e6 字节,base64 膨胀 4/3 后,超过约 750 KB 的图片 ack 帧会被静默丢弃——于是 MCP 工具接受了 25 MB 的图,浏览器却永远收不到(issue #927)。
⑤ 事件缺 namespace 就丢,失败即退订。
shouldSend 的第一道判断(hub/src/sse/sseManager.ts:305)让"忘记打租户标签"变成可见的功能缺失而不是隐蔽的越权。配合"发送失败即 unsubscribe"(:113、:134、:101),死连接不会在 Map 里堆积。
10. 边界与局限
- 单 Hub、无水平扩展。 房间路由靠
cliNamespace.adapter.rooms的进程内 Map(pickCliSocketId,hub/src/socket/handlers/terminal.ts:73-74),SSE 连接也存在进程内的Map(hub/src/sse/sseManager.ts:41)。跑两个 Hub 实例,两边看不见对方的连接。这对 local-first 定位是合理的,但不是可以直接拿去做 SaaS 的架构。 - namespace 不是安全边界,是隔离便利。 所有 namespace 共享同一个
baseToken——拿到 token 的人可以随便写后缀访问任意租户。它防的是"误操作串台",不防"攻击者"。 - SSE token 在 URL 里。 §4.3 说过,这会进浏览器历史和潜在的代理日志。
access-denied与not-found分开回,泄露 id 存在性。 见 §6.3。- 心跳 30 秒硬编码。
hub/src/startHub.ts:186没有走配置,前端的看门狗阈值也在前端常量里,两边要改得同步改。
11. 接着读哪一章
| 你想知道 | 去哪 |
|---|---|
session-alive 里那个 mode: 'local' | 'remote' 到底怎么切 | 本地/远程双模接管 |
rpc-request 从手机点"允许"到 CLI 执行的全程 | 反向 RPC 与权限审批 |
expectedVersion / seq / 消息账本怎么工作 | Hub 的状态与同步 |
machine-alive 和 machine: 房间给谁用 | Runner 守护进程 |
terminal:* 事件的 PTY 侧实现 | 多 agent 抽象、远程终端与外围能力 |
12. 代码地图
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| CLI ↔ Hub 事件契约 | shared/src/socket.ts | ClientToServerEvents、ServerToClientEvents |
update 信封与四种 body | shared/src/socket.ts | UpdateSchema、UpdateNewMessageBodySchema、UpdateSessionBodySchema、UpdateMachineBodySchema |
| Hub → Web 事件投影 | shared/src/schemas.ts | SyncEventSchema、SyncEvent |
| socket 服务器与双 namespace | hub/src/socket/server.ts | createSocketServer |
| socket 缓冲区上限与踩坑记录 | hub/src/socket/socketLimits.ts | SOCKET_MAX_HTTP_BUFFER_SIZE、MAX_GENERATED_IMAGE_BYTES |
| CLI token 解析 + namespace 后缀 | hub/src/utils/accessToken.ts | parseAccessToken、DEFAULT_NAMESPACE |
| 常时字符串比较 | hub/src/utils/crypto.ts | constantTimeEquals |
| Web JWT 签发(namespace 传导) | hub/src/web/routes/auth.ts | createAuthRoutes |
| Web REST 鉴权中间件 | hub/src/web/middleware/auth.ts | createAuthMiddleware、WebAppEnv |
| socket 握手数据类型 | hub/src/socket/socketTypes.ts | SocketData、CliSocketWithData |
| CLI 房间归属与访问解析 | hub/src/socket/handlers/cli/index.ts | registerCliHandlers、resolveSessionAccess、resolveMachineAccess |
| CLI 消息/元数据处理 | hub/src/socket/handlers/cli/sessionHandlers.ts | registerSessionHandlers |
| 终端跨 namespace 转发 | hub/src/socket/handlers/terminal.ts | registerTerminalHandlers、pickCliSocketId |
| SSE 扇出与订阅过滤 | hub/src/sse/sseManager.ts | SSEManager、subscribe、broadcast、shouldSend、sendToast |
| SSE 路由与生命周期 | hub/src/web/routes/events.ts | createEventsRoutes |
| REST 侧会话归属守卫 | hub/src/web/routes/guards.ts | requireSession、requireSessionFromParam |
| SSE 流式 gzip | hub/src/web/sseCompression.ts | compressSseResponse |
| 前台/后台可见性追踪 | hub/src/visibility/visibilityTracker.ts | VisibilityTracker、isVisibleConnection |
| 事件补 namespace 后扇出 | hub/src/sync/eventPublisher.ts | EventPublisher、emit |
| namespace 反查 | hub/src/sync/syncEngine.ts | resolveNamespace、handleRealtimeEvent |
| Hub 装配与 CORS 归一化 | hub/src/startHub.ts | startHub、normalizeOrigins、mergeCorsOrigins |
| HTTP 路由挂载与 relay 分支 | hub/src/web/server.ts | createWebApp、startWebServer |
| CLI 侧 socket 客户端 | cli/src/api/apiSession.ts | ApiSessionClient |
| Web 侧 SSE 客户端 | web/src/hooks/useSSE.ts | useSSE、buildEventsUrl |
| Web 侧前后台上报 | web/src/hooks/useVisibilityReporter.ts、web/src/api/client.ts | useVisibilityReporter、setVisibility |
| Web 侧终端 socket 客户端 | web/src/hooks/useTerminalSocket.ts | useTerminalSocket |