Files
trade-message-center/.trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md
T

14 KiB
Raw Blame History

OneTalk 页面桥、Port 与命令路由契约

1. Scope / Trigger

当 OneTalk 页面需要把 MAIN world 的页面事实、页面命令和当前身份安全地连接到 ISOLATED Content Script 与 Service Worker 时,必须遵循本契约。

本文件只描述页面桥、Port 生命周期、页面身份注册和命令路由。OneTalk 发送参数与发送事实遵循 OneTalk PWA 出站发送 SOP;耐久同步遵循 OneTalk 耐久同步

2. Signatures

Page bridge messages

```ts type OneTalkPageMessage = | { type: "onetalk.page.hello"; channelAccountId: string; conversationId?: string; conversationSelection?: "none" | "multiple"; } | { type: "onetalk.page.observed"; batch: ObservedOneTalkMessage[]; historyProgress?: HistoryPageProgress; diagnostics?: { unsupportedSkippedCount: number; invalidObservationCount: number; anomalies: Array<{ code: OneTalkMediaAnomalyCode; mediaKind: "image" | "file" | "card"; count: number; }>; }; } | { type: "onetalk.page.profile-observed"; channelAccountId: string; profiles: OneTalkContactProfile[]; } | { type: "onetalk.page.command"; requestId: string; command: PageCommand; } | { type: "onetalk.page.command-result"; requestId: string; result: PageCommandResult; } | { type: "onetalk.page.connection-status"; disconnected: boolean; };

type OneTalkPageHello = { type: "onetalk.page.hello"; channelAccountId: string; conversationId?: string; conversationSelection?: "none" | "multiple"; }; ```

共享常量由 `src/onetalk/page-bridge/model.ts` 唯一拥有:

```ts const ONE_TALK_PAGE_BRIDGE_SOURCE = "trade-message-center.onetalk.page-bridge"; const ONE_TALK_PAGE_BRIDGE_VERSION = 4; const ONE_TALK_PAGE_PORT_NAME = "trade-message-center.onetalk.page"; ```

3. Contracts

Ownership

  • MAIN world 只拥有 OneTalk SDK 访问、页面事实采集和页面命令执行;不得持有 Bright WebSocket、认证凭证或扩展 IndexedDB。
  • OneTalk raw contentTypecustom.data、SDK envelope 和认证字段只能在 MAIN 内短暂存在;MAIN 唯一 decoder 必须先生成 shared OneTalkMessageContenttext | image | file | business_card | inquiry | order v1 联合,才允许跨 bridge。业务卡只允许历史 SDK 完整 tuple 的白名单投影;名片跨 bridge 的形状严格是 { version: 1, kind: "business_card" } marker,不携带客户资料。原始正文、item.contact、完整 contact/paramssign、加密标识和 token 不得跨 bridge。
  • onetalk.page.observed.diagnostics 是可选 exact-shape 的安全计数投影:只允许非负 unsupportedSkippedCountinvalidObservationCount,以及无重复的 { code, mediaKind, count } anomaly。code 只能是 shared media_*card_* 白名单,mediaKind 只能是 imagefilecard;不得放入原始 SDK 字段、失败输入或异常文本。
  • ISOLATED Content Script 只拥有页面桥和 `runtime.Port`;不得解释业务 payload、保存同步状态或选择备用页面。
  • Service Worker 拥有 Bright 插件 WebSocket、页面连接注册、账号隔离、命令路由、上传编排和 IndexedDB 访问。
  • Bright 是 OneTalk 消息事实的服务端写入口;TradeMind 不直接写 Bright 消息事实表。
  • 联系人资料走独立的 onetalk.page.profile-observed → profile ledger → contact.profile.observed 路径;不得并入 message.observed。
  • __conversationListData__ 与消息观察的到达时序不保证一致;page bridge 不等待或拼接 profile。名片读取 view 只能由服务端按 [channelAccountId, conversationId] 读取当前 profile 后以内存组合产生,不能把 view 形状回写消息事实。

Bidirectional flow

页面事实:

```text MAIN -> window.postMessage -> isolated Port -> Service Worker -> durable sync pipeline -> Bright upload -> per-message ACK ```

服务端命令:

```text Bright -> Service Worker WebSocket -> unique page Port -> window.postMessage -> MAIN -> page command result ```

页面桥与 Bright WebSocket 是两条独立连接,不得混用连接状态、request ID 或消息事实。

onetalk.page.connection-status 是 Service Worker → ISOLATED → MAIN 的 additive v4 exact-shape envelope,只允许 sourceversiontypedisconnected: boolean 四个字段。 它不属于 command 或 command-resultMAIN 只调用本地显示回调,不产生结果消息;bridge 不携带账号、binding、错误或 WebSocket 细节。

Bridge implementation

`decodeOneTalkPageMessage(value: unknown)` 是 MAIN、ISOLATED 和 Service Worker 使用页面消息的唯一运行时入口。消费者必须先解码,再用方向守卫收窄:

  • `isOneTalkMainToIsolatedMessage`
  • `isOneTalkIsolatedToMainMessage`
  • `isOneTalkPageCommandResultMessage`

ISOLATED 只做以下动作:

  1. 检查 `event.source === window`
  2. 检查当前 `location.origin`
  3. 使用共享页面 decoder
  4. 检查消息方向;
  5. 在固定 Port 上原样转发。

Port 断开或发送失败时,丢弃当前内存消息,不建立页面本地队列、重试或持久化。

bridge v4 只允许 exact-shape normalized message 和聚合后的安全诊断。每条业务消息还必须携带精确的内部来源证明 upstreamType: 1MAIN 只在 raw 上游 type === 1 后生成它,decoder 拒绝缺失或错误值,Service Worker 在 durable 转换前再次验证并剥离该字段。type === 2 的平台消息不进入 bridge;其它缺失或未知 raw type 作为 invalid observation 暴露。unsupported_skipped 或媒体 anomaly 只含稳定 code/类型/计数;不得携带消息 ID、正文、URL 或 raw payload。ISOLATED 与 Service Worker 不得重新解析 Base64、custom.data、平台文案或推测媒体链接。

每个 historyProgress 必须携带从 OneTalk 会话列表 lastContactTimeLong 归一化得到的 latestMessageAtMs: number | null。该字段只描述会话列表活动时间,不改变消息历史的 (sentAtMs, messageId) 分页。

Chrome content script 入口不依赖 Service Worker 的 module 声明。MAIN 与 ISOLATED 产物必须按当前构建约束生成自包含入口,不能依赖 Manifest 未声明的共享 chunk。

Page identity

页面账号身份从 OneTalk 运行时登录人字段 `currentUserAccountId`(或 `IcbuIM.UserUtil.currentUser.accountId`)读取;URL 的 `activeAccountId` 只是当前对话账号,不能作为 `channelAccountId`。当前 SPA 会话身份从页面当前状态读取:

  • 唯一 selected 会话通过 hello 携带 `conversationId`
  • 没有 selected 会话通过 `conversationSelection: "none"` 表达;
  • 多个 selected 会话通过 `conversationSelection: "multiple"` 表达;
  • 不得从 URL 的 `conversationId` 猜测 SPA 当前会话;
  • 不得取多个 selected 节点中的第一个。

页面身份变化时,旧页面 command correlation 必须先收敛,再用新身份替换注册;迟到的旧结果不得恢复旧请求。

重复、相同的合法 hello 保持既有页面身份,不得重启 snapshot、buyer delivery、page-ready 或命令路由生命周期;但当该账号的断线 UI latch 仍活跃时,Service Worker 必须只向同账号页面重放 disconnected: true。该重放不是 command,也不改变任何页面身份或同步状态。

Profile envelope 必须显式携带当次读取的 channelAccountId。Service Worker 只接受它与同一 Port 最近一次合法 hello 及当前配置账号完全相同的消息;logout、切账号和旧 Port 消息均 fail closed。

Command routing

所有页面 command 都必须先按 `channelAccountId` 隔离:

  • 不得广播到多个标签页;
  • 不得跨账号回退;
  • 不得随机选择页面;
  • onetalk.contact.snapshot 是唯一同账号页面的 account-level observation trigger;重复相同 hello 不重复触发,snapshot result 不阻塞消息 bootstrap
  • 账号级同步命令只发送到唯一同账号页面;
  • onetalk.sync.conversation 只在同一 MAIN 页面已完成 onetalk.discover-conversations 并缓存 direct 会话后使用;它必须令 command 与 route 的 conversationId 相等,并投递到唯一同账号页面,不要求该页面当前 selected 会话就是历史目标;MAIN cache 继续是目标会话存在性的唯一证明;
  • `onetalk.sync` 保持 `channelAccountId + conversationId` 的精确页面路由;
  • `onetalk.send` 只要求唯一同账号页面,忽略该页面当前 selected conversationcommand 的 conversationId 交给 MAIN 后,SDK input 必须以同值 `cid` 指定目标,`conversationCode` 只作兼容字段;
  • `onetalk.send` 的零页面和多页面分别返回 `waiting_for_page`、`ambiguous_page_route`
  • 页面当前 selected 会话与发送目标不一致,不属于发送路由拒绝;
  • 页面身份与 binding/账号不一致时仍 fail closed。

`sendRequestId`、页面 `requestId` 和 Port pending map 只用于当前内存关联。发送终态、页面断线或身份变化后,不把 command 写入 IndexedDB、消息表或任务表。

4. Validation & Error Matrix

条件 行为
Port 名称、sender URL、tab ID 或 frame ID 不合法 忽略连接,不加入注册表
envelope、方向或 JSON 结构非法 忽略消息
connection-status 缺字段、额外字段或错误方向 忽略消息,不调用 MAIN 显示回调
observation 尚未收到合法 hello 不调用观察回调
页面账号无有效 active binding fail closed
onetalk.sync.conversation 的 command/route ID 不同、无同账号页面或同账号多页 不投递;分别维持精确路由、waiting_for_page 或 ambiguous_page_route
`onetalk.sync` 无精确页面 返回同步对应的 `waiting_for_page`、`page_identity_mismatch` 或 `ambiguous_page_route`
`onetalk.send` 无同账号页面 `rejected_before_send/waiting_for_page`
`onetalk.send` 同账号页面多于一个 `rejected_before_send/ambiguous_page_route`,不广播
`onetalk.send` 页面当前会话不是目标 仍投递到唯一同账号页面,不返回 `page_identity_mismatch`
页面 Port post 失败或断开 `delivery_unknown/send_connection_lost`,不重试
页面 hello 身份变化 清理旧 pending correlation,待定命令返回 `delivery_unknown/send_connection_lost`
Service Worker 重新实例化 页面注册表为空,等待页面重新连接
Profile envelope 账号与 hello/config 账号不一致 丢弃 profile observation,不写 ledger、不发 Bright
同一 page identity 重复 hello 保持已有 identity,不重复启动 snapshot lifecycle;活跃断线 latch 仅重放同账号 UI 状态

5. Good / Base / Bad Cases

  • Good:页面先发送合法 helloService Worker 按账号和 command 类型选择唯一 Port;发送目标通过 command payload 传给 MAIN。
  • Good:当前页面打开会话 A,但发送目标为会话 B;只要同账号页面唯一,command 仍投递,不改变页面 selected 状态。
  • Good:全量 discovery 已在同页缓存会话 B,页面当前选中会话 Aonetalk.sync.conversation(B) 仍投递到该唯一账号页面,并由 MAIN cache 解析 B。
  • Base:页面 selected 状态变化后重新发送 hello;旧请求不会被新身份的迟到结果完成。
  • Bad:将 URL `conversationId` 当成 SPA 会话身份、取第一个 selected 节点、广播到所有页面或跨账号回退。
  • Bad:把页面 Port 当作持久化队列,或在 Port 断开后自动重新发送原 command。

6. Tests Required

  • 非法 Port 名称、origin、tab/frame、envelope 和方向不会注册或调用回调。
  • hello 的唯一/零/多 selected 状态能正确编码,且 URL 不变时 selected 变化能刷新身份。
  • onetalk.sync.conversation 在当前 selected 会话不同但同账号页面唯一时仍投递;同账号多页时必须拒绝且不广播。
  • `onetalk.sync` 继续使用精确会话路由。
  • `onetalk.send` 在当前 selected 为其它会话、零 selected 或多 selected 时,仍向唯一同账号页面投递。
  • `onetalk.send` 同账号零页面返回 `waiting_for_page`,多页面返回 `ambiguous_page_route`,均不广播。
  • 页面 post 失败、Port 断开和身份变化都返回带 reason 的 unknown。
  • 页面桥 build 产物为自包含入口,Manifest 路径、world 和 Port 名称一致。
  • connection-status 覆盖 exact-shape、错误 source/version/origin/direction、无 command-result、同账号 Port fan-out 与重复 matching hello 的离线状态重放。
  • raw contentTypecustom.data、顶层 text 或未知 content version 均不能通过 bridgehistory/live 必须复用 MAIN 的同一 decoder。

7. Wrong vs Correct

```ts // Wrong: 发送也要求当前页面 selected 会话先等于目标。 if (page.conversationId !== command.conversationId) { return { status: "rejected_before_send", reason: "page_identity_mismatch" }; }

// Correct: 发送只路由到唯一同账号页面,目标由 MAIN 传给 SDK 的 cid 指定。 const page = findUniqueAccountPage(channelAccountId); if (!page) return { status: "rejected_before_send", reason: "waiting_for_page" }; page.port.postMessage(createPageCommand(command)); ```

```ts // Wrong: 页面断开后重新广播,可能造成重复发送。 for (const port of allPagePorts) port.postMessage(command);

// Correct: 断开收敛为 unknown;迟到事实由普通 observation 链路处理。 return { status: "delivery_unknown", reason: "send_connection_lost" }; ```