跳到主要内容

数据截至 (上游 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 ↔ HubSocket.IO /cli namespace双向Hub 必须能主动向 CLI 下发 rpc-request(权限审批、读文件),纯请求-响应做不到
Web → HubREST(hono 路由)单向请求所有写操作(发消息、批准权限、改元数据)都是普通 HTTP,便宜、可缓存、易调试
Hub → WebSSE /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 实现(路由工厂 createEventsRouteshub/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 时才自己退避重连(useSSEonerror,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-clientManager/terminal(useTerminalSocketweb/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(registerTerminalHandlershub/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.tsSyncEventSchema
位置shared/src/socket.ts:236:202shared/src/schemas.ts:543
谁跟谁CLI ↔ HubHub → 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 的上报面,挑几条代表性的:

事件说什么
messageagent 又产出了一条消息
session-alive我还活着;顺带汇报 thinking / mode(local 还是 remote)/ model / permissionMode
session-readyagent 加载完了,可以接 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 / errorPTY 上行

反方向 ServerToClientEvents(shared/src/socket.ts:236-252)只有七个,归成四类:updaterpc-request、四个 terminal:*(open / write / resize / close)、errorHub 对 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

注意 UpdateSessionBodySchemametadataagentState 各自带 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 的重命名版,证据有三:

  1. 来源不止 CLI。 heartbeatconnection-changed 是 SSE 传输层自己造的(hub/src/web/routes/events.ts:100-126);toast 是 Hub 的通知系统造的;scheduled-matured 是 Hub 定时器造的。
  2. 粒度不同。 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 本体)。
  3. 信息量被裁剪。 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/authparseAccessToken + 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 小时(createAuthRoutessetExpirationTime('4h'),hub/src/web/routes/auth.ts:66)
泄露后果等同拿到整个 Hub4 小时后自动作废
谁持有你自己机器上的进程浏览器 localStorage,跑在不受控环境

一句话:浏览器不配拿长期密钥。

6.3 namespace 是怎么真正生效的

握手时写进 socket.data.namespace(类型见 hub/src/socket/socketTypes.ts:4SocketData)之后,每一次访问都要过一道解析:

// 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 会先验归属:sessionIdrequireSession 守卫(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-155if (!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 :113connection.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:345setVisibility

这个设计解决的是通知重复:标签页在前台时,页内 toast 就够了;切到后台就该走 Web Push / FCM / Telegram。sendToast 返回成功投递数,通知系统据此决定要不要再发一次推送——PushNotificationChannelFcmNotificationChannel 构造时都被注入了 sseManagervisibilityTracker(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):

函数干什么
normalizeOrigin58https://a.com/pathnew URL().origin 削成 https://a.com;解析失败就原样返回
normalizeOrigins70批量归一 + 去重;只要含 * 就直接坍缩成 ['*']
mergeCorsOrigins80合并两组;任一侧含 * 同样坍缩成 ['*']

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:58compressSseResponse 注释解释得很清楚: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-deniednot-found 分开回,泄露 id 存在性。 见 §6.3。
  • 心跳 30 秒硬编码。 hub/src/startHub.ts:186 没有走配置,前端的看门狗阈值也在前端常量里,两边要改得同步改。

11. 接着读哪一章

你想知道去哪
session-alive 里那个 mode: 'local' | 'remote' 到底怎么切本地/远程双模接管
rpc-request 从手机点"允许"到 CLI 执行的全程反向 RPC 与权限审批
expectedVersion / seq / 消息账本怎么工作Hub 的状态与同步
machine-alivemachine: 房间给谁用Runner 守护进程
terminal:* 事件的 PTY 侧实现多 agent 抽象、远程终端与外围能力

12. 代码地图

主题文件路径符号名
CLI ↔ Hub 事件契约shared/src/socket.tsClientToServerEventsServerToClientEvents
update 信封与四种 bodyshared/src/socket.tsUpdateSchemaUpdateNewMessageBodySchemaUpdateSessionBodySchemaUpdateMachineBodySchema
Hub → Web 事件投影shared/src/schemas.tsSyncEventSchemaSyncEvent
socket 服务器与双 namespacehub/src/socket/server.tscreateSocketServer
socket 缓冲区上限与踩坑记录hub/src/socket/socketLimits.tsSOCKET_MAX_HTTP_BUFFER_SIZEMAX_GENERATED_IMAGE_BYTES
CLI token 解析 + namespace 后缀hub/src/utils/accessToken.tsparseAccessTokenDEFAULT_NAMESPACE
常时字符串比较hub/src/utils/crypto.tsconstantTimeEquals
Web JWT 签发(namespace 传导)hub/src/web/routes/auth.tscreateAuthRoutes
Web REST 鉴权中间件hub/src/web/middleware/auth.tscreateAuthMiddlewareWebAppEnv
socket 握手数据类型hub/src/socket/socketTypes.tsSocketDataCliSocketWithData
CLI 房间归属与访问解析hub/src/socket/handlers/cli/index.tsregisterCliHandlersresolveSessionAccessresolveMachineAccess
CLI 消息/元数据处理hub/src/socket/handlers/cli/sessionHandlers.tsregisterSessionHandlers
终端跨 namespace 转发hub/src/socket/handlers/terminal.tsregisterTerminalHandlerspickCliSocketId
SSE 扇出与订阅过滤hub/src/sse/sseManager.tsSSEManagersubscribebroadcastshouldSendsendToast
SSE 路由与生命周期hub/src/web/routes/events.tscreateEventsRoutes
REST 侧会话归属守卫hub/src/web/routes/guards.tsrequireSessionrequireSessionFromParam
SSE 流式 gziphub/src/web/sseCompression.tscompressSseResponse
前台/后台可见性追踪hub/src/visibility/visibilityTracker.tsVisibilityTrackerisVisibleConnection
事件补 namespace 后扇出hub/src/sync/eventPublisher.tsEventPublisheremit
namespace 反查hub/src/sync/syncEngine.tsresolveNamespacehandleRealtimeEvent
Hub 装配与 CORS 归一化hub/src/startHub.tsstartHubnormalizeOriginsmergeCorsOrigins
HTTP 路由挂载与 relay 分支hub/src/web/server.tscreateWebAppstartWebServer
CLI 侧 socket 客户端cli/src/api/apiSession.tsApiSessionClient
Web 侧 SSE 客户端web/src/hooks/useSSE.tsuseSSEbuildEventsUrl
Web 侧前后台上报web/src/hooks/useVisibilityReporter.tsweb/src/api/client.tsuseVisibilityReportersetVisibility
Web 侧终端 socket 客户端web/src/hooks/useTerminalSocket.tsuseTerminalSocket