14 KiB
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
contentType、custom.data、SDK envelope 和认证字段只能在 MAIN 内短暂存在;MAIN 唯一 decoder 必须先生成 sharedOneTalkMessageContent的text | image | file | business_card | inquiry | orderv1 联合,才允许跨 bridge。业务卡只允许历史 SDK 完整 tuple 的白名单投影;名片跨 bridge 的形状严格是{ version: 1, kind: "business_card" }marker,不携带客户资料。原始正文、item.contact、完整contact/params、sign、加密标识和 token 不得跨 bridge。 onetalk.page.observed.diagnostics是可选 exact-shape 的安全计数投影:只允许非负unsupportedSkippedCount、invalidObservationCount,以及无重复的{ code, mediaKind, count }anomaly。code只能是 sharedmedia_*或card_*白名单,mediaKind只能是image、file或card;不得放入原始 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,只允许 source、version、type 与 disconnected: boolean 四个字段。
它不属于 command 或 command-result:MAIN 只调用本地显示回调,不产生结果消息;bridge 不携带账号、binding、错误或 WebSocket 细节。
Bridge implementation
`decodeOneTalkPageMessage(value: unknown)` 是 MAIN、ISOLATED 和 Service Worker 使用页面消息的唯一运行时入口。消费者必须先解码,再用方向守卫收窄:
- `isOneTalkMainToIsolatedMessage`
- `isOneTalkIsolatedToMainMessage`
- `isOneTalkPageCommandResultMessage`
ISOLATED 只做以下动作:
- 检查 `event.source === window`;
- 检查当前 `location.origin`;
- 使用共享页面 decoder;
- 检查消息方向;
- 在固定 Port 上原样转发。
Port 断开或发送失败时,丢弃当前内存消息,不建立页面本地队列、重试或持久化。
bridge v4 只允许 exact-shape normalized message 和聚合后的安全诊断。每条业务消息还必须携带精确的内部来源证明 upstreamType: 1:MAIN 只在 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 conversation;command 的 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:页面先发送合法 hello,Service Worker 按账号和 command 类型选择唯一 Port;发送目标通过 command payload 传给 MAIN。
- Good:当前页面打开会话 A,但发送目标为会话 B;只要同账号页面唯一,command 仍投递,不改变页面 selected 状态。
- Good:全量 discovery 已在同页缓存会话 B,页面当前选中会话 A;onetalk.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
contentType、custom.data、顶层text或未知 content version 均不能通过 bridge;history/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" }; ```