【声明】本博客所有内容均为个人业余时间创作,所述技术案例均来自公开开源项目(如Github,Apache基金会),不涉及任何企业机密或未公开技术,如有侵权请联系删除
标题
167、【Agent】【OpenCode】TuiThreadCmd(EvenSource)
背景
上篇 blog
【Agent】【OpenCode】TuiThreadCmd(流式传输&二进制Blob)
分析了流式传输和二进制 Blob 两个概念,这是理解为什么代码里要写 await request.text() 的关键,接着解释了什么是二进制 Blob,Blob = Binary Large Object(二进制大对象),可以把它理解为 "一团原始的、未解释的字节数据",RPC 通道通常只能传输 JSON 可序列化的数据。而 JSON 只支持字符串、数字、布尔、数组、对象这几种类型,所以 fetch 代理不能传文件,图片被 .text() 强行当 UTF-8 解码后会变成乱码,这也是这段代码最危险的地方:它不是"禁止传文件",而是 "假装能传,然后默默把数据毁掉"。接着解释了什么是流式传输,流式传输 = 数据像水流一样"边产生边发送",而不是"攒够了再一次性发",最后提到如果场景只涉及 JSON API 请求(body 都是小文本),这个妥协完全没问题;但如果要传文件或大数据量,就需要升级 RPC 通道本身的设计,下面继续分析
OpenCode
之前 blog 还有个点
关键点:Object.fromEntries(request.headers.entries()) 将 Headers 对象转为普通键值对,因为 Headers 对象无法直接跨 RPC 序列化。
这句话的意思是:Headers 是一个特殊的浏览器/Node.js 内置对象,它不能直接被 JSON 序列化,所以必须先把它"拆"成普通的 { key: value } 对象,才能通过 RPC 发送,下面详细分析下,分三步拆解:
- Headers 对象长什么样?
javascript
const headers = new Headers({
"Content-Type": "application/json",
"Authorization": "Bearer xxx"
})
它不是普通对象 {},而是一个有内部状态的类实例。直接打印或序列化它:
javascript
console.log(JSON.stringify(headers)) // "{}" ← 空的!字段全丢了
console.log(Object.keys(headers)) // [] ← 也是空的
因为 Headers 把数据存在了内部私有槽位里,而不是挂在可枚举的属性上。JSON 序列化器只能看到对象的自有可枚举属性,所以什么都读不到。
.entries()做了什么?
javascript
headers.entries()
// → Iterator [["content-type", "application/json"], ["authorization", "Bearer xxx"]]
它返回一个迭代器 ,逐个吐出 [key, value] 键值对数组。这是 Headers 提供的标准读取接口,能正确访问到内部存储的数据。
Object.fromEntries()做了什么?
javascript
Object.fromEntries(headers.entries())
// → { "content-type": "application/json", "authorization": "Bearer xxx" }
它把迭代器里的每一对 [key, value] 重新组装成一个纯普通对象。这个对象:
- ✅ 没有特殊原型链
- ✅ 所有属性都可枚举
- ✅
JSON.stringify()能完整序列化 - ✅ 可以安全地作为
client.call()的参数跨 RPC 传输
📌 整条链路一图看懂
bash
Headers 实例(不可序列化)
↓ .entries()
Iterator<[key, value]>(仍不可序列化)
↓ Object.fromEntries()
Plain Object { key: value }(✅ 可 JSON 序列化)
↓ JSON.stringify / RPC 编码
发送到远端
一句话总结 :Headers 是个"带锁的保险箱 ",JSON 序列化器打不开它。.entries() 是钥匙 ,把内容取出来;Object.fromEntries() 是把取出来的东西装进一个"透明塑料袋 "(普通对象),这样 RPC 通道才能安全运输。
OK,下面继续分析

这个函数的功能是:创建一个基于 RPC 通道 的"伪 EventSource"对象,用来替代浏览器原生的 EventSource(SSE )。它和上一个 createWorkerFetch 是同一套设计思路:对外暴露熟悉的 API,对内走 RPC 通道。但这次适配的不是 HTTP 请求,而是服务端推送事件。
🔍 下面逐行拆解
javascript
function createEventSource(client: RpcClient): EventSource {
return {
// 1️⃣ 订阅事件:把 handler 注册到 RPC client 的 "event" 消息上
on: (handler) => client.on<Event>("event", handler),
// 2️⃣ 切换工作区:通过 RPC 调用通知服务端改变当前 workspace
setWorkspace: (workspaceID) => {
void client.call("setWorkspace", { workspaceID })
},
}
}
on(handler) --- 事件订阅
- 原生 EventSource :通过
addEventListener("message", cb)接收 SSE 推送 - 这里 :改为
client.on("event", handler),从 RPC 通道监听服务端主动推过来的 "event" 消息
本质 :把 SSE 的"HTTP 长连接推送 "替换成了"RPC 通道的事件订阅"
setWorkspace(workspaceID) --- 控制指令
这不是标准 EventSource 的 API,是业务扩展,通过 RPC 单向调用告诉服务端:"现在要切换到这个 workspace,后续推送的事件都应该是这个 workspace 的"
void 表示不关心返回值,这是一个"发了就行"的通知型调用
📌 和原生 EventSource 的对比
| 原生 EventSource | 这个代理 EventSource | |
|---|---|---|
| 传输通道 | HTTP SSE 长连接 | RPC 通道(WebSocket/IPC 等) |
| 订阅方式 | addEventListener() |
client.on("event", handler) |
| 连接管理 | 浏览器自动重连 | 由 RpcClient 内部管理 |
| 业务扩展 | ❌ 不支持 | ✅ setWorkspace() |
| 返回类型 | 真正的 EventSource 实例 |
只是形状相似的普通对象 { on, setWorkspace } |
⚠️ 注意:这不是真正的 EventSource
返回类型标注为 EventSource,但实际上只是一个结构兼容的子集(Duck Typing)。它没有:
readyState、url、withCredentials等属性close()、dispatchEvent()等方法- 浏览器的自动重连机制
代码能正常工作的前提是:消费方只用到了 on() 和 setWorkspace() 这两个方法。如果某处代码调用了 eventSource.close(),运行时会直接报错。
💡 一句话总结
它是一个 RPC 版的事件推送适配器 :用 client.on 替代 SSE 长连接来接收服务端推送,同时扩展了 setWorkspace 业务能力,让上层代码可以用类 EventSource 的方式消费 RPC 事件流。
OK,本篇先到这里,如有疑问,欢迎评论区留言讨论,祝各位功力大涨,技术更上一层楼!!!更多内容见下篇 blog