Bridge 协议

跨线程通信:
消息格式、握手和错误处理

packages/bridge 不关心 wx API、页面栈,也不碰 glass-easel。它只负责消息信封校验、帧传输、调用关联、事件批处理、超时和 JSON 可序列化检查。职责足够窄,浏览器、测试和未来的原生宿主才能共用一套协议。

1 协议版本4 消息类型7 消息域30s 默认调用超时

职责

Bridge 只负责传输

这是 ADR-0004 定下的边界:Bridge 处理传输和连接,不处理 API 或渲染语义。

Bridge 负责

  • 版本化的消息信封和帧结构
  • Transport 抽象:MessagePort 或内存通道
  • call、control、event 三种发送方式
  • replyTo 关联、等待中的调用和超时
  • 按来源和 pageId 批处理事件
  • JSON 可序列化校验
  • 结构化的 ProtocolError

Bridge 不负责

  • wx API 的参数和返回值语义
  • 页面栈、路由和生命周期顺序
  • glass-easel 的渲染指令
  • 权限、鉴权和加密
  • 重试、幂等和消息持久化
  • 二进制或流式传输

消息格式

每条消息都带路由和关联信息

消息是自描述的,接收方不需要额外上下文,就能判断版本、方向、类型、消息域和关联关系。

{
  protocolVersion: 1,
  messageId: "msg-m...-1-a1b2c3d4",
  kind: "call" | "reply" | "event" | "control",
  domain: "runtime" | "page" | "data" | "event"
        | "api" | "diagnostic" | "canvas",
  method: "navigate.to",
  source: "host" | "service" | "render" | "devtool",
  target: "host" | "service" | "render" | "devtool",
  payload: { ... },
  pageId?: "page-1",
  replyTo?: "msg-...",
  timestamp: 1710000000000
}

字段要求

  • protocolVersion 必须是 1
  • messageId 和 method 不能为空
  • kind、domain、source、target 必须属于枚举值
  • timestamp 必须是有限数字
  • reply 必须带 replyTo
  • payload 必须能通过 JSON 序列化校验

点对点发送,不做广播

source 和 target 必须和连接双方完全一致;如果收到对不上的消息,会抛 INVALID_ENVELOPE。

宿主转发事件时会创建一条新消息(新的 messageId),不会原样转发信封。

握手

由宿主发起,两个线程各自就绪

service 和 render 是两条独立连接,握手不能跨连接复用。

  1. 对端先安装 hello 处理器

    service 和 render 的 BridgeConnection 在构造时注册 runtime.hello:收到请求后先发 runtime.ready,再返回握手信息。

    onCall('runtime', 'hello')
  2. 宿主等待 ready

    宿主的 BridgeConnection 把 respondToHello 设为 false,只注册 runtime.ready 的控制处理器,把收到的信息交给 readyWaiter。

    onControl('runtime', 'ready')
  3. 宿主发起调用

    handshake() 只允许宿主调用;payload 包含 protocolVersion、role 和 capabilities,默认 30 秒超时。

    call('runtime', 'hello', { protocolVersion, role })
  4. 对端回复并发送 ready

    hello 的回复带上对端的握手信息;同时对端发出 runtime.ready,宿主的 readyWaiter 收到后结束等待。

    control('runtime', 'ready', handshakePayload())
  5. 宿主返回握手信息

    宿主等的是 ready,而不只是 hello 的回复。如果 ready 没到就超时,避免出现“hello 成功但线程还没装配好”的情况。

    return await ready

capabilities 目前只是元数据

service 和 render 在构造连接时会声明 { role: "service" } 或 { role: "render" };宿主没有注入 capabilities,也没有代码根据它做能力协商或降级。

它现在只是握手信息,不是必须遵守的约定。

版本不一致会断开连接

信封或帧的 protocolVersion 不是 1 时会抛 PROTOCOL_VERSION_MISMATCH;连接发送一条 error 诊断后主动 close()。

没有兼容降级,也没有多版本协商。

传输

更换通信方式,只需要实现 Transport

消息层不关心底层是 MessagePort、内存对象还是原生 bridge,只要求实现三个方法。

export interface Transport {
  send(frame: TransportFrame): void
  close(): void | Promise<void>
  onFrame(listener: (frame: TransportFrame) => void): () => void
}

export type TransportFrame = {
  protocolVersion: 1
  messages: ProtocolMessage[]   // 至少一条
}
实现用途行为
createMessagePortTransport(port)浏览器 iframe / worker通过 postMessage 发帧,校验入站帧,close 时清空 onmessage 并关闭端口
createInMemoryTransportPair()runtime 单测、进程内模拟同步发送到对端,没有序列化开销,但仍会走完整校验
createHostMessageChannelPair()单 render 浏览器宿主创建 MessageChannel,返回 hostTransport 和可转移的 peerPort
自定义 Transport原生宿主 / WebSocket / 进程桥必须保持帧边界、非空 messages 和 protocolVersion 校验
帧边界不能丢

一次 send 可以携带多条消息,但不能是空数组。接收端会先校验整个帧,再逐条校验信封;任何一条消息非法都会产生协议错误,不会被悄悄丢掉。

批处理

事件会攒一帧,调用立即发送

只有 event 会批处理;call/control 和诊断都会立即发出。

API行为常见用途
call(domain, method, payload)立即发送并等待回复,有超时需要返回值的跨线程请求
control(domain, method, payload)立即发送并等待回复,语义上偏控制而不是查询bootstrap、navigate、inspectTree
sendEvent(domain, method, payload)放进队列,按 source:pageId 分组,在 microtask 中统一发出setData、事件、canvas 帧、生命周期
sendDiagnostic(level, payload)立即发送,不参与事件批处理日志、状态和错误
flush()手动清空所有队列并发送测试或需要强制同步时

顺序保证

同一分组内严格先进先出;跨分组只保证“同一个 microtask 内的队列一起发出”,不承诺不同 pageId 之间的全局顺序。

Bridge 测试覆盖了“事件会批处理但顺序不变”和“诊断会插队立即发送”。

不会自动重试

调用超时只会 reject 并清掉等待记录,不会重发。重试策略要由发送方决定;目前 runtime 基本不重试,只把失败转成诊断。

错误

错误也有统一结构

ProtocolError 带有 code、message 和 details;跨线程时会序列化成错误 payload,在接收端还原成 ProtocolError。

Code触发条件处理方式
INVALID_ENVELOPE信封字段缺失、枚举非法,或 source/target 不匹配拒绝消息,并产生 error 诊断
INVALID_PAYLOADpayload 不是 JSON 安全的、reply 结构错误、空帧拒绝发送或拒绝回复
PROTOCOL_VERSION_MISMATCH消息或帧的版本不是 1发送 error 诊断并关闭连接
TRANSPORT_CLOSED端口已关闭,或对端不可用send 抛错;close 时 reject 所有等待中的调用
TIMEOUTcall/control 超过 timeoutMsreject,删除等待记录,不重试
UNSUPPORTED_METHOD收到没有注册 handler 的 call/control自动回复 ok:false + UNSUPPORTED_METHOD
HANDLER_ERRORhandler 抛了非 ProtocolError,或 reply 发送失败包装成错误 payload 返回给调用方
// 请求端
try {
  await connection.call('api', 'navigate.to', { url: '/pages/next/next' })
} catch (error) {
  if (error instanceof ProtocolError && error.code === 'TIMEOUT') {
    // 30 秒内没有回复;不会自动重试
  }
}

// 响应端:任何 handler 异常都会变成 reply
reply(request, { ok: false, error: { code, message, details } })
事件没有回复

event 是单向的。接收端处理失败只会产生诊断,不会通知发送端。如果需要确认结果,请使用 call 或 control。

序列化

payload 必须能通过 JSON 边界

每次发送前都会检查,不是可选项。违规会抛 INVALID_PAYLOAD,消息根本发不出去。

允许

  • null、string、boolean
  • 有限的 number(NaN / Infinity 会被拒绝)
  • 普通对象(原型必须是 Object.prototype 或 null)
  • 数组
  • 只要没有循环引用,可以任意嵌套

拒绝

  • undefined、function、bigint、symbol
  • Date、Map、Set
  • class 实例、DOM 节点、Promise
  • 循环引用
  • NaN / Infinity
两套清洗函数

Bridge 的 assertJsonSafe 做严格校验;runtime 的 toJsonSafe 会主动丢弃函数和 undefined、转换 Date/Map/Set,并把循环引用写成 [Circular]。事件序列化会先用后者清洗,确保能通过前者的校验。

消息列表

v1 现在有哪些消息

domain 和 method 都是字符串,没有集中式的 schema 注册表;下面是当前代码实际注册和发送的消息。组件语义扩展通过新增 method 完成,协议版本仍是 1。

DomainMethod类型方向语义
runtimehellocallhost → service/render握手,返回 protocolVersion、role 和 capabilities
runtimereadycontrolservice/render → host线程装配完成,宿主结束握手等待
runtimebootstrapcontrolhost → service创建首屏,返回 pageId/initialData/componentSchemas/pageComponents
runtimebootstrapcontrolhost → render注册样式、schema 和页面选项,挂载首屏并返回组件快照
runtimeinspectTreecontrolhost/devtool → render按 pageId 返回 InspectNode 树
runtimecomponentLifecycleeventrender → servicecreated/attached/ready/moved/detached,附带 data、pageId/parent/tag/id/class
runtimecomponentDataeventrender → servicerender 侧组件 data 变化,service 同步逻辑实例
runtimecomponentObservedeventrender → servicerender 侧 observer 变化,service 用旧值执行 observer
runtimecomponentPageLifetimeeventrender → serviceshow/hide;service 目前忽略,由 page.show/hide 统一分发
pagereadycontrolhost → service携带 render 的组件快照,先建立逻辑树,再触发 onReady
pagenavigateTocontrolhost → service当前页 onHide,创建新页,返回 path/pageId/query/initialData
pagenavigateTocontrolhost → render卸载 previousPageId,挂载新页并返回组件
pagenavigateBackcontrolhost → service移除 delta 个页面,恢复栈顶,返回 unloadedPageIds
pagenavigateBackcontrolhost → render卸载 unloadedPageIds,重新挂载恢复页并返回组件
pageswitchTabcontrolhost → service/render切换 tabBar 页面;service 复用缓存页面,render 切换或挂载对应帧
pagehidecontrolhost → serviceApp.onHide、页面 onHide、所有组件 pageLifetimes hide
pageshowcontrolhost → serviceApp.onShow、页面 onShow、所有组件 pageLifetimes show
pagescrolleventrender → service把页面滚动位置和事件交给 service
pagereachBottomeventrender → service触发页面触底回调
pageunloadcontrolhost → serviceonUnload,并移除页面实例(内置宿主没有调用)
pagegetInitialDatacallhost → service返回页面定义的 data(已注册,但目前没有宿主使用)
datasetDataeventservice → render页面或组件的路径更新;同一 tick 的多个 patch 合并为一条
eventdispatcheventrender → service序列化后的小程序事件,用来调用页面或组件方法
eventtriggereventservice → renderservice 侧组件实例触发 glass-easel 事件
canvasmeasurecall/controlservice → host → render按 selector 测量,返回 width/height/canvasId
canvasframeeventservice → render宿主 canvas 适配器产生的 PNG 帧,render 侧负责上屏
queryselectcall/controlservice → host → render按 selector 在组件/页面作用域查询真实 DOM,返回 rect/dataset/scroll
queryintersectioncall/controlservice → host → render创建/更新/断开组件级 IntersectionObserver
queryintersectioneventrender → service投影相交 entry,触发 observe 回调
diagnosticdiagnostic.log/warn/error/stateeventservice/render → host统一转成 RuntimeDiagnostic
apisystem.infocallservice → host获取宿主系统信息快照
apistorage.getcallservice → host读取宿主 storage
apistorage.setcallservice → host写入宿主 storage
apitoast.showcallservice → host宿主 toast(目前只返回 errMsg)
apinavigate.tocallservice → host宿主协调 service 和 render 完成导航
apinavigate.backcallservice → host宿主协调 service 和 render 完成返回
api*callservice → hostapiHandlers 注入的任意宿主 API

转发

宿主如何转发消息

转发不是透明的:宿主会重新创建消息、选择目标连接,有时还要把 call 变成 control。

来源收到宿主处理发出
renderevent.dispatch取出 payload 和 pageId,新建事件service event.dispatch
renderruntime.componentLifecycle取出 payload(含 parent/tag/id/class)service runtime.componentLifecycle
renderruntime.componentData转发 payload(pageId 在 payload 里)service runtime.componentData
renderruntime.componentObserved转发 payload(pageId 在 payload 里)service runtime.componentObserved
renderpage.scroll / page.reachBottom按 pageId 转发service 对应页面事件
serviceevent.trigger取出 payloadrender event.trigger
servicedata.setData取出 payload 和 pageIdrender data.setData
servicecanvas.frame取出 payload 和 pageIdrender canvas.frame
servicecanvas.measure把调用 Promise 直接返回render canvas.measure(control)
hostpage.navigateTo / bootstrap等待 render 返回组件service page.ready({ components })
service/renderdiagnostic.*normalize 后写入 HostDiagnosticStore不转发,供 devtool 或宿主回调
serviceapi.*在 HostApiRegistry 里查 handler本地执行;导航类 API 再调用两侧 control
转发时不会保留 replyTo

事件没有 reply,宿主新建一条消息就可以;转发中唯一保留请求语义的是 canvas.measure:宿主的 onCall 返回 render control 的 Promise,Bridge 会用新的 messageId/replyTo 完成关联。导航 handler 也会在 render 返回组件后补发 page.ready。

扩展

新增一条消息要改哪些地方

协议没有运行时 schema 注册表,正确性靠两端实现和测试,而不是靠信封自动发现。

01

定义 domain / method / payload

优先复用现有 domain。payload 必须能通过 JSON 校验;带页面语义的消息要带 pageId,组件语义要带 componentId。

02

两端都要注册 handler

call/control 用 onCall/onControl,事件用 onEvent + sendEvent。缺少 handler 会得到 UNSUPPORTED_METHOD,不会静默失败。

03

需要转发就改宿主

service 和 render 不直连。任何跨线程消息都必须在 createTinyRuntime 里显式转发。

04

补协议测试

至少覆盖信封校验、方向、超时/错误和批处理顺序;Bridge 现有测试可以作为模板。

什么时候才需要升级 protocolVersion

改变信封或帧结构、默认语义,或者既有字段的含义时。单纯新增 method 不需要升版本,但旧版本对端会返回 UNSUPPORTED_METHOD。

哪些东西不应该放进 Bridge

wx API 的参数校验、页面栈规则、glass-easel 节点操作和权限模型。这些属于 runtime 或宿主,不属于传输层。

继续阅读

宿主如何创建和装配消息,可以看 宿主适配;每条消息在运行时内部怎么处理,可以看 运行时内核。