From ef2200f8ac0e11fb5dd90189692fbbb8874d635a Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 19 Jun 2026 13:33:46 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E5=8F=A3=E5=AE=BF=E4=B8=BB=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E5=9B=9E=E6=94=BE=E7=BC=93=E5=AD=98=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 HostBridge 响应回放缓存上限提升到共享契约 让 Expo 移动壳复用共享缓存上限并拒绝本地重声明 让 Tauri 桌面壳配置检查反查共享缓存上限 补充两端回放缓存淘汰测试和壳方案文档 --- apps/desktop-shell/scripts/check-config.mjs | 8 +++++ .../src-tauri/src/host_bridge/protocol.rs | 29 +++++++++++++++++++ apps/mobile-shell/scripts/check-config.mjs | 9 ++++++ .../src/host-bridge/bridge.test.ts | 24 +++++++++++++++ apps/mobile-shell/src/host-bridge/bridge.ts | 2 +- apps/mobile-shell/src/host-bridge/protocol.ts | 2 -- .../shared-memory/decision-log.md | 6 ++-- ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 4 ++- .../shared/src/contracts/hostBridge.test.ts | 5 ++++ packages/shared/src/contracts/hostBridge.ts | 1 + 10 files changed, 83 insertions(+), 7 deletions(-) diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index b73498d3c..c688bcc3b 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -1030,6 +1030,10 @@ const sharedHostBridgePayloadLimits = { sharedContractSource, 'HOST_BRIDGE_REQUEST_ID_MAX_LENGTH', ), + HOST_BRIDGE_RESPONSE_CACHE_MAX: extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_RESPONSE_CACHE_MAX', + ), HOST_BRIDGE_BADGE_COUNT_MAX: extractTsNumberConst( sharedContractSource, 'HOST_BRIDGE_BADGE_COUNT_MAX', @@ -1084,6 +1088,10 @@ const desktopHostBridgePayloadLimits = { rustHostSource, 'HOST_BRIDGE_REQUEST_ID_MAX_LENGTH', ), + HOST_BRIDGE_RESPONSE_CACHE_MAX: extractRustNumberConst( + rustHostSource, + 'HOST_BRIDGE_RESPONSE_CACHE_MAX', + ), HOST_BRIDGE_BADGE_COUNT_MAX: extractRustNumberConst( rustHostSource, 'BADGE_COUNT_MAX', diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs b/apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs index ca9810728..2942f4d30 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs @@ -325,6 +325,35 @@ mod tests { assert_eq!(second_response.result, first_response.result); } + #[test] + fn host_bridge_replay_state_evicts_oldest_response_after_cache_limit() { + let replay_state = HostBridgeReplayState::default(); + + match replay_state.reserve("request-0") { + HostBridgeReplayReservation::Execute(slot) => { + replay_state.complete(slot, ok("request-0".to_string(), json!(0))); + } + HostBridgeReplayReservation::Wait(_) => panic!("first request must execute"), + } + + for index in 1..=HOST_BRIDGE_RESPONSE_CACHE_MAX { + let request_id = format!("request-{index}"); + match replay_state.reserve(&request_id) { + HostBridgeReplayReservation::Execute(slot) => { + replay_state.complete(slot, ok(request_id, json!(index))); + } + HostBridgeReplayReservation::Wait(_) => panic!("new request must execute"), + } + } + + match replay_state.reserve("request-0") { + HostBridgeReplayReservation::Execute(_) => {} + HostBridgeReplayReservation::Wait(_) => { + panic!("oldest request must be evicted after cache limit") + } + } + } + #[test] fn invalid_string_payload_is_rejected() { let mut invalid = request("clipboard.writeText"); diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 17ee05079..2f7b06ca9 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -14,6 +14,8 @@ const bridgePath = new URL('../src/host-bridge/bridge.ts', import.meta.url); const bridgeSource = fs.readFileSync(bridgePath, 'utf8'); const dispatchPath = new URL('../src/host-bridge/dispatch.ts', import.meta.url); const dispatchSource = fs.readFileSync(dispatchPath, 'utf8'); +const protocolPath = new URL('../src/host-bridge/protocol.ts', import.meta.url); +const protocolSource = fs.readFileSync(protocolPath, 'utf8'); const sharePath = new URL('../src/host-bridge/share.ts', import.meta.url); const shareSource = fs.readFileSync(sharePath, 'utf8'); const bridgeDirPath = new URL('../src/host-bridge/', import.meta.url); @@ -671,6 +673,13 @@ if (!hostBridgeSource.includes('function assertImportedFileSizeWithinLimit')) { throw new Error('mobile shell must centralize imported file size checks'); } +if (!bridgeSource.includes('HOST_BRIDGE_RESPONSE_CACHE_MAX,')) { + throw new Error('mobile shell bridge must import shared HostBridge response cache limit'); +} +if (/const HOST_BRIDGE_RESPONSE_CACHE_MAX\s*=/.test(protocolSource)) { + throw new Error('mobile shell protocol must not redeclare HostBridge response cache limit'); +} + for (const [functionName, readCall] of [ ['importTextFile', 'file.text()'], ['importDocumentFile', 'file.base64()'], diff --git a/apps/mobile-shell/src/host-bridge/bridge.test.ts b/apps/mobile-shell/src/host-bridge/bridge.test.ts index f6a4c4e81..14df20ad8 100644 --- a/apps/mobile-shell/src/host-bridge/bridge.test.ts +++ b/apps/mobile-shell/src/host-bridge/bridge.test.ts @@ -17,6 +17,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { HOST_BRIDGE_BADGE_COUNT_MAX, HOST_BRIDGE_PROTOCOL, + HOST_BRIDGE_RESPONSE_CACHE_MAX, HOST_BRIDGE_VERSION, type HostBridgeMethod, type HostBridgeRequest, @@ -611,6 +612,29 @@ describe('handleMobileHostBridgeMessage', () => { expect(Share.share).toHaveBeenCalledTimes(1); }); + test('HostBridge request id 回放缓存超过共享上限后淘汰最早结果', async () => { + const firstRequest = request('clipboard.writeText', { + text: 'first', + }); + + await send(firstRequest); + expect(Clipboard.setStringAsync).toHaveBeenCalledTimes(1); + + for (let index = 0; index < HOST_BRIDGE_RESPONSE_CACHE_MAX; index += 1) { + await send( + request('clipboard.writeText', { + text: `cache-${index}`, + }), + ); + } + + await send(firstRequest); + + expect(Clipboard.setStringAsync).toHaveBeenCalledTimes( + HOST_BRIDGE_RESPONSE_CACHE_MAX + 2, + ); + }); + test('network.status 返回 Expo Network 真实状态', async () => { vi.mocked(Network.getNetworkStateAsync).mockResolvedValue({ type: Network.NetworkStateType.CELLULAR, diff --git a/apps/mobile-shell/src/host-bridge/bridge.ts b/apps/mobile-shell/src/host-bridge/bridge.ts index ab5b4afef..609033e78 100644 --- a/apps/mobile-shell/src/host-bridge/bridge.ts +++ b/apps/mobile-shell/src/host-bridge/bridge.ts @@ -1,4 +1,5 @@ import { + HOST_BRIDGE_RESPONSE_CACHE_MAX, type HostBridgeRequest, type HostBridgeResponse, normalizeHostBridgeRequestId, @@ -8,7 +9,6 @@ import { resetMobileHostBridgeDispatchForTest, } from './dispatch'; import { - HOST_BRIDGE_RESPONSE_CACHE_MAX, failure, invalidRequest, isHostBridgeRequest, diff --git a/apps/mobile-shell/src/host-bridge/protocol.ts b/apps/mobile-shell/src/host-bridge/protocol.ts index 1c6cc6c82..507ecbdc3 100644 --- a/apps/mobile-shell/src/host-bridge/protocol.ts +++ b/apps/mobile-shell/src/host-bridge/protocol.ts @@ -10,8 +10,6 @@ import { } from '../../../../packages/shared/src/contracts/hostBridge'; import type { MobileShellUrlOptions } from '../shell/url'; -export const HOST_BRIDGE_RESPONSE_CACHE_MAX = 128; - export type MobileHostBridgeNavigation = { allowedOrigin: string; urlOptions: MobileShellUrlOptions; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 3becb5f4c..5e3518974 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2429,9 +2429,9 @@ ## 2026-06-18 HostBridge request id replay - 背景:H5 transport 会为每个 HostBridge 请求生成 id 并设置超时,但系统分享、外链打开、文件选择 / 保存、本地通知和窗口导航等宿主副作用如果收到重复 id,不应因为消息重试、快速双发或 WebView 事件抖动被执行两次。 -- 决策:Expo 移动壳缓存已完成响应,并让进行中的同 id 请求共用同一个执行 Promise;Tauri 桌面壳在唯一 `host_bridge_request` command 外层通过 `HostBridgeReplayState` 让同 id 请求等待 / 回放首次结果。重复 id 只返回首次响应,不二次执行宿主能力。 -- 影响范围:`apps/mobile-shell/src/host-bridge/bridge.ts`、`apps/desktop-shell/src-tauri/src/host_bridge/`、`apps/desktop-shell/src-tauri/src/main.rs`、两端配置检查、Expo / Tauri HostBridge 方案文档。 -- 验证方式:`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 +- 决策:Expo 移动壳缓存已完成响应,并让进行中的同 id 请求共用同一个执行 Promise;Tauri 桌面壳在唯一 `host_bridge_request` command 外层通过 `HostBridgeReplayState` 让同 id 请求等待 / 回放首次结果。重复 id 只返回首次响应,不二次执行宿主能力。已完成响应缓存上限由 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_RESPONSE_CACHE_MAX` 统一声明,Expo 壳直接导入,Tauri 壳保留 Rust 镜像并由桌面配置检查反查共享常量。 +- 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`apps/mobile-shell/src/host-bridge/bridge.ts`、`apps/mobile-shell/src/host-bridge/protocol.ts`、`apps/desktop-shell/src-tauri/src/host_bridge/`、两端配置检查、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run test -- packages/shared/src/contracts/hostBridge.test.ts apps/mobile-shell/src/host-bridge/bridge.test.ts`、`npm run desktop-shell:test`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 ## 2026-06-18 HostBridge request envelope 校验 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index c9d6a3883..0d686af50 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -259,7 +259,7 @@ GameBridge 禁止: - HostBridge request 必须校验 `bridge`、`version`、`id`、`method` 和 payload shape;`id` 归一后必须是 1-120 字符且不含控制字符,`method` 必须来自共享白名单,未知 method 作为非法 request 拒绝。 - 壳层只接受来自允许 origin / packaged asset 的消息。 - H5 侧 HostBridge listener 只接收原生壳注入到当前窗口的 message;带有非当前窗口 `source` 或非当前页面 `origin` 的消息必须忽略,避免 AI sandbox iframe 或其它子上下文伪造 HostBridge response / event。 -- 每个请求必须有超时;H5 的 React Native WebView transport 和 Tauri `invoke` transport 都必须在前端侧按 `timeoutMs` 释放请求,默认请求超时和最大请求超时以 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS` / `HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS` 为唯一来源,宿主侧执行超时也只能返回标准 HostBridge 错误。重复 `id` 不得重复执行支付、登录、系统分享、文件导入导出、本地通知等宿主副作用;Expo 和 Tauri 壳都必须按 request id 回放首次完成结果。 +- 每个请求必须有超时;H5 的 React Native WebView transport 和 Tauri `invoke` transport 都必须在前端侧按 `timeoutMs` 释放请求,默认请求超时和最大请求超时以 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS` / `HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS` 为唯一来源,宿主侧执行超时也只能返回标准 HostBridge 错误。重复 `id` 不得重复执行支付、登录、系统分享、文件导入导出、本地通知等宿主副作用;Expo 和 Tauri 壳都必须按 request id 回放首次完成结果,已完成响应缓存上限以共享契约 `HOST_BRIDGE_RESPONSE_CACHE_MAX` 为唯一来源。 - HostBridge 的 capability profile、宿主上下文 query 字段和值、文件 MIME 清单、导入 / 导出体积上限、文件名 fallback / 长度上限、request id 长度、角标上限、剪贴板文本长度、二维码文本长度和本地通知标题 / 正文长度都必须以 `packages/shared/src/contracts/hostBridge.ts` 为声明来源;Expo 移动壳直接导入共享 profile 和契约常量,微信小程序壳和 Tauri 壳分别保留小程序 CommonJS / Rust 运行时代码镜像并由测试和配置门禁反查共享契约。 - 能力按 `capabilities` / `hostCapabilities` 下发,H5 会过滤未知能力,并根据声明结果决定是否展示入口、发起宿主请求或走 fallback;进入 `native_app` 后主 App 会再通过真实 `host.getRuntime` 回读一次宿主 runtime 并缓存能力,用来补齐裁剪壳或旧入口 URL 缺少 `hostCapabilities` 的场景。不能只凭 `native_app` 宿主类型假设能力可用。 - 壳能力声明与三端壳验收必须通过 `npm run check:native-shells` 统一校验;排查单端问题时可再分别运行微信壳测试集合、`npm run mobile-shell:typecheck`、`npm run mobile-shell:test`、`npm run mobile-shell:config`、`npm run mobile-shell:export`、`npm run desktop-shell:typecheck`、`npm run desktop-shell:test` 或 `npm run desktop-shell:build -- --no-bundle`。声明的 capability 必须来自共享 HostBridge profile 并存在于共享白名单,壳 runtime 回包、H5 URL `hostCapabilities`、壳实现、文件载荷边界、微信 WebView / 支付 / 订阅 / 分享桥接行为、微信小程序页面路由、WebView source query、微信请求头运行时标记、H5 runtime parser、H5 路由保留字段、微信壳 H5 / API HTTPS 域名格式、Expo managed config、移动端 production bundle、桌面 release 构建入口和微信 / Expo / Tauri 三端生产源码临时替身词扫描不得漂移。微信小程序壳不使用 Expo / Tauri 式统一 request dispatcher,但每个声明 capability 都必须在根级门禁中映射到真实流程文件、关键 `wx.*` 或页面工厂调用和对应测试清单。 @@ -457,6 +457,8 @@ GameBridge 禁止: 2026-06-18 追加:HostBridge request id 进入宿主侧 replay 门禁。Expo 壳会缓存已完成响应并让进行中的同 id 请求共用同一执行结果;Tauri 壳在唯一 `host_bridge_request` command 外层通过 `HostBridgeReplayState` 对同 id 请求做等待 / 回放。重复 id 只返回首次结果,不会二次触发系统分享、外链、剪贴板、文件选择 / 保存、本地通知或窗口动作。 +2026-06-19 追加:HostBridge response replay 缓存上限进入共享契约。`packages/shared/src/contracts/hostBridge.ts` 导出 `HOST_BRIDGE_RESPONSE_CACHE_MAX=128`,Expo 移动壳直接导入该常量裁剪已完成响应缓存;Tauri 桌面壳保留 Rust 运行时镜像,但 `apps/desktop-shell/scripts/check-config.mjs` 会反查共享常量并拒绝数值漂移。两端测试都覆盖超过上限后淘汰最早响应,避免 request id 重试缓存无限增长。 + 2026-06-18 追加:HostBridge request envelope 校验收紧。共享契约提供 `isHostBridgeMethod` 和 `normalizeHostBridgeRequestId`;Expo 壳直接复用,Tauri 壳镜像同一 method 白名单和 request id 规则。空 id、控制字符 id、超长 id 和未知 method 都在进入 replay / 能力分发前返回 `invalid_request`,已知但当前壳未实现的登录 / 支付等 method 才返回 `unsupported_method`。 2026-06-18 追加:HostBridge method 白名单进入跨壳门禁。`packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_METHODS` 是唯一协议来源;Expo 壳的 HostBridge 分发 case 不得处理共享契约外 method,Tauri 壳 Rust `HOST_BRIDGE_METHODS` 必须与共享契约逐项一致。两端配置检查会在 `npm run check:native-shells` 中拒绝 method 白名单漂移,新增宿主能力必须先更新共享契约,再落壳实现。 diff --git a/packages/shared/src/contracts/hostBridge.test.ts b/packages/shared/src/contracts/hostBridge.test.ts index c278fbdc4..49e631d32 100644 --- a/packages/shared/src/contracts/hostBridge.test.ts +++ b/packages/shared/src/contracts/hostBridge.test.ts @@ -21,6 +21,7 @@ import { HOST_BRIDGE_EVENTS, HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES, HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS, + HOST_BRIDGE_RESPONSE_CACHE_MAX, HOST_BRIDGE_TEXT_MIME_TYPES, isHostBridgeMethod, isHostBridgeCapability, @@ -53,6 +54,10 @@ describe('HostBridge shared contract helpers', () => { expect(HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS).toBe(60000); }); + test('固定宿主侧响应回放缓存边界', () => { + expect(HOST_BRIDGE_RESPONSE_CACHE_MAX).toBe(128); + }); + test('固定宿主上下文 query 契约', () => { expect(HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY).toEqual({ clientRuntime: 'clientRuntime', diff --git a/packages/shared/src/contracts/hostBridge.ts b/packages/shared/src/contracts/hostBridge.ts index 06a96bf00..33abdf322 100644 --- a/packages/shared/src/contracts/hostBridge.ts +++ b/packages/shared/src/contracts/hostBridge.ts @@ -218,6 +218,7 @@ export type HostBridgeRequest = { export const HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS = 8000; export const HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS = 60000; +export const HOST_BRIDGE_RESPONSE_CACHE_MAX = 128; export const HOST_BRIDGE_REQUEST_ID_MAX_LENGTH = 120; export function normalizeHostBridgeRequestId(rawId: unknown) {